rotate: 6 service-API drivers (mongo, npm, gcp, twilio, flyio, k8s)
Each driver is real-service code only (no test awareness; tests inject just an HTTPClient/Bin), following the established patterns: - mongo: in-place changeUserPassword over the old connection; no secret on argv (host/db on the URI, both passwords on stdin, output scrubbed). - npm: create token -> /-/whoami -> delete-by-key; blob records the key. - gcp: service-account KEY rotation; mints an RS256 JWT signed with the SA's own private key (crypto/rsa, stdlib) to self-auth the IAM create/verify/delete. - twilio: API Key create -> get -> delete, Basic auth (KeySid/secret). - flyio: GraphQL — a managing token mints/deletes the rotated deploy token. - k8s: create a service-account-token Secret, await the populated token, verify against the SA, delete the old Secret (invalidates the old token). All 6 register their honest proof level (MOCK-ONLY) in proofs.go + ROTATION-PROOFS.md; mongo/npm/k8s are LIVE-VM candidates pending real-target VM POCs. Emulators enforce credential validity (gcp verifies the JWT signature), tests assert Verify(old) fails after revoke + a secret-leak canary. Full suite: 103 green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// Flyio rotates a Fly.io app-scoped DEPLOY TOKEN via the GraphQL API by CREATING a new
|
||||
// limited-access token, verifying it, then DELETING the old one — the provider-API
|
||||
// rotation pattern (create-new → verify → revoke-old).
|
||||
//
|
||||
// A deploy token is itself too limited to mint or delete tokens, so rotation is driven
|
||||
// by a separate MANAGING token (a personal/org access token). The credential blob
|
||||
// therefore carries both — the managing token (auth=, NOT rotated) and the deploy token
|
||||
// being rotated (token=, with its id= for deletion) — the same "auth rotates a value"
|
||||
// shape as the GitHub-Actions secret driver:
|
||||
//
|
||||
// flyio://<host>/?org=<orgId>&app=<appId>&auth=<managing-token>&id=<tok-id>&token=<deploy-token>
|
||||
//
|
||||
// - scheme "flyio" maps to https; "http"/"https" are accepted for a loopback emulator.
|
||||
// - <host> API host; "flyio" (or empty) defaults to api.fly.io.
|
||||
// - org=/app= the organization and app the deploy token is scoped to (createLimited...).
|
||||
// - auth= the managing token (Bearer for create/delete) — non-secret to the rotation
|
||||
// of the deploy token in that it is preserved, but still kept vault-only.
|
||||
// - id= the deploy token's limited-access-token id — required to DELETE the old one.
|
||||
// - token= the deploy token value being rotated.
|
||||
//
|
||||
// SECURITY: both tokens live only in the vault blob and Bearer headers; neither reaches
|
||||
// Identity/Meta/logs/argv. The new deploy token is decoded straight from the create
|
||||
// response into the rebuilt blob.
|
||||
type Flyio struct {
|
||||
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout client.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// init self-registers the driver. Availability alone changes nothing; the
|
||||
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
||||
func init() { Register(&Flyio{}) }
|
||||
|
||||
// Name identifies the driver in plans and audit records.
|
||||
func (f *Flyio) Name() string { return "flyio" }
|
||||
|
||||
// Detect claims credentials tagged Source == "flyio".
|
||||
func (f *Flyio) Detect(c discover.Credential) bool { return c.Source == "flyio" }
|
||||
|
||||
func (f *Flyio) client() *http.Client {
|
||||
if f.HTTPClient != nil {
|
||||
return f.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// flyioSecret is the parsed credential blob. None of its fields are ever logged.
|
||||
type flyioSecret struct {
|
||||
base string // scheme://host
|
||||
org string
|
||||
app string
|
||||
auth string // managing token
|
||||
id string // deploy token id (for delete)
|
||||
token string // deploy token value (rotated)
|
||||
}
|
||||
|
||||
func parseFlyioSecret(v *vault.Vault, h *vault.Handle) (flyioSecret, error) {
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
return flyioSecret{}, err
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil {
|
||||
return flyioSecret{}, fmt.Errorf("flyio: parse secret url: %w", err)
|
||||
}
|
||||
if u.Scheme != "flyio" && u.Scheme != "http" && u.Scheme != "https" {
|
||||
return flyioSecret{}, fmt.Errorf("flyio: unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
scheme := u.Scheme
|
||||
host := u.Host
|
||||
if scheme == "flyio" {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "" || host == "flyio" {
|
||||
host = "api.fly.io"
|
||||
}
|
||||
q := u.Query()
|
||||
s := flyioSecret{
|
||||
base: scheme + "://" + host,
|
||||
org: q.Get("org"),
|
||||
app: q.Get("app"),
|
||||
auth: q.Get("auth"),
|
||||
id: q.Get("id"),
|
||||
token: q.Get("token"),
|
||||
}
|
||||
if s.auth == "" {
|
||||
return flyioSecret{}, fmt.Errorf("flyio: secret has no managing token (auth=)")
|
||||
}
|
||||
if s.token == "" {
|
||||
return flyioSecret{}, fmt.Errorf("flyio: secret has no deploy token")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// build re-encodes a flyioSecret into the single-line blob form (scheme normalised
|
||||
// back to "flyio").
|
||||
func (s flyioSecret) build() string {
|
||||
host := hostOf(s.base)
|
||||
if host == "api.fly.io" {
|
||||
host = "flyio"
|
||||
}
|
||||
u := &url.URL{Scheme: "flyio", Host: host, Path: "/"}
|
||||
q := url.Values{}
|
||||
if s.org != "" {
|
||||
q.Set("org", s.org)
|
||||
}
|
||||
if s.app != "" {
|
||||
q.Set("app", s.app)
|
||||
}
|
||||
q.Set("auth", s.auth)
|
||||
if s.id != "" {
|
||||
q.Set("id", s.id)
|
||||
}
|
||||
q.Set("token", s.token)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// gql POSTs a GraphQL request authenticated by the given Bearer token and decodes the
|
||||
// data envelope into out. A non-2xx status or a non-empty errors array is an error.
|
||||
func (f *Flyio) gql(ctx context.Context, base, bearer, query string, vars map[string]any, out any) error {
|
||||
body, _ := json.Marshal(map[string]any{"query": query, "variables": vars})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/graphql", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
resp, err := f.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("graphql status %s", resp.Status)
|
||||
}
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||
return fmt.Errorf("decode graphql: %w", err)
|
||||
}
|
||||
if len(env.Errors) > 0 {
|
||||
return fmt.Errorf("graphql error: %s", env.Errors[0].Message)
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(env.Data, out); err != nil {
|
||||
return fmt.Errorf("decode graphql data: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const flyCreateMutation = `mutation($org:ID!,$app:ID!,$name:String!){createLimitedAccessToken(input:{name:$name,organizationId:$org,profile:"deploy",profileParams:{app_id:$app}}){limitedAccessToken{id,token}}}`
|
||||
|
||||
const flyDeleteMutation = `mutation($id:ID!){deleteLimitedAccessToken(input:{id:$id}){token}}`
|
||||
|
||||
const flyAppQuery = `query($n:String!){app(name:$n){id,name}}`
|
||||
|
||||
// Rotate creates a NEW deploy token (authenticated by the managing token) and returns
|
||||
// a vault handle to a rebuilt blob carrying the new token + id. It does not delete the
|
||||
// old deploy token.
|
||||
func (f *Flyio) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
||||
s, err := parseFlyioSecret(v, c.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Create struct {
|
||||
Tok struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
} `json:"limitedAccessToken"`
|
||||
} `json:"createLimitedAccessToken"`
|
||||
}
|
||||
vars := map[string]any{"org": s.org, "app": s.app, "name": "incredigo-rotated"}
|
||||
if err := f.gql(ctx, s.base, s.auth, flyCreateMutation, vars, &out); err != nil {
|
||||
return nil, fmt.Errorf("flyio: create token: %w", err)
|
||||
}
|
||||
if out.Create.Tok.Token == "" {
|
||||
return nil, fmt.Errorf("flyio: create token: empty token in response")
|
||||
}
|
||||
ns := s
|
||||
ns.id = out.Create.Tok.ID
|
||||
ns.token = out.Create.Tok.Token
|
||||
return v.Store([]byte(ns.build())), nil
|
||||
}
|
||||
|
||||
// Verify proves the newly minted deploy token authenticates by running the app query
|
||||
// with it as the Bearer token.
|
||||
func (f *Flyio) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
||||
s, err := parseFlyioSecret(v, newSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out struct {
|
||||
App struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"app"`
|
||||
}
|
||||
if err := f.gql(ctx, s.base, s.token, flyAppQuery, map[string]any{"n": s.app}, &out); err != nil {
|
||||
return fmt.Errorf("flyio verify: %w", err)
|
||||
}
|
||||
if out.App.ID == "" {
|
||||
return fmt.Errorf("flyio verify: deploy token did not resolve the app")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOld deletes the OLD deploy token by id, authenticated by the managing token.
|
||||
func (f *Flyio) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
||||
s, err := parseFlyioSecret(v, c.Secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.id == "" {
|
||||
return fmt.Errorf("flyio revoke: old token has no recorded id — delete it manually (guided)")
|
||||
}
|
||||
if err := f.gql(ctx, s.base, s.auth, flyDeleteMutation, map[string]any{"id": s.id}, nil); err != nil {
|
||||
return fmt.Errorf("flyio revoke: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user