// Package orgtoken — organization-scoped API tokens. // // These are full-admin bearer tokens for the tenant platform. One // token authorizes every admin-gated endpoint on the tenant (all // workspaces, all org settings, all bundles + templates, all // secrets). Designed for beta integrations and CLI usage where // session cookies aren't available. // // Mirrors internal/wsauth for plaintext/hash handling + UI display // format so tooling that understands one format works for the other. // Intentionally does NOT bind to a workspace — the whole point is // org-wide scope. // // Forward path (post-beta): split into roles (admin, editor, reader) // + per-workspace scoping. For now every token is full-admin and // the only authorization is "does it match a live row". package orgtoken import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "errors" "fmt" "time" ) const ( // 256 bits of entropy, base64url encoded. Same as wsauth so // prefix-based log correlation uses the same leading character // set. tokenPayloadBytes = 32 // First 8 chars shown in UI for revoke/audit UX. Reveals nothing // crackable on its own (6 bits × 8 = 48 bits of prefix space — // good enough to disambiguate, nowhere near guessable). tokenPrefixLen = 8 // listMax caps the number of rows List returns. Realistic org- // admin UIs show on the order of 10. 500 is enough headroom that // no legitimate flow hits it, low enough that a mint-storm can't // force a big allocation on every list render. listMax = 500 ) // ErrInvalidToken is returned when a presented bearer doesn't match // a live row. Callers map to HTTP 401 and must NOT distinguish // "bad bytes" from "revoked" — that would be an enumeration signal // on which tokens were ever minted. var ErrInvalidToken = errors.New("invalid or revoked org api token") // Token is the admin-UI shape. Plaintext is NEVER part of this — // the only place plaintext exists is the return value of Issue. type Token struct { ID string `json:"id"` Prefix string `json:"prefix"` Name string `json:"name,omitempty"` OrgID string `json:"org_id,omitempty"` CreatedBy string `json:"created_by,omitempty"` CreatedAt time.Time `json:"created_at"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` } // Issue mints a fresh token and persists sha256(plaintext) + prefix. // Returns (plaintext, id, error). Plaintext is returned to the // caller once and must be handed to the user verbatim — we cannot // recover it from the database. // // name and orgID are both optional (nullable columns). createdBy // records provenance for audit. orgID is the caller's org workspace // ID and is used by requireCallerOwnsOrg to enforce org isolation // on org-scoped routes (#1200 / F1094). func Issue(ctx context.Context, db *sql.DB, name, createdBy, orgID string) (plaintext, id string, err error) { buf := make([]byte, tokenPayloadBytes) if _, err := rand.Read(buf); err != nil { return "", "", fmt.Errorf("orgtoken: generate: %w", err) } plaintext = base64.RawURLEncoding.EncodeToString(buf) hash := sha256.Sum256([]byte(plaintext)) prefix := plaintext[:tokenPrefixLen] err = db.QueryRowContext(ctx, ` INSERT INTO org_api_tokens (token_hash, prefix, name, created_by, org_id) VALUES ($1, $2, $3, $4, $5) RETURNING id `, hash[:], prefix, nullIfEmpty(name), nullIfEmpty(createdBy), nullIfEmpty(orgID)).Scan(&id) if err != nil { return "", "", fmt.Errorf("orgtoken: persist: %w", err) } return plaintext, id, nil } // Validate looks up a presented bearer, returns ErrInvalidToken on // any mismatch (bad bytes, revoked, deleted). On success, updates // last_used_at best-effort (the hot path — failure to update doesn't // fail the request) and returns the token id + display prefix + org_id // for audit logging and org isolation. // // Returning the prefix alongside the id lets callers produce audit // strings that match what users see in the UI (the plaintext prefix, // not the UUID). Keeps the "who did what" trail visually // correlatable to the revoke button in the token list. // // The org_id is the workspace UUID of the org that owns this token. // May be empty for pre-migration tokens minted before #1212. Callers // that need org isolation should use requireCallerOwnsOrg (which does // a second lookup) rather than trusting an empty org_id here — this // avoids a breaking change to the Validate interface while still // populating the Gin context for callers that don't need it. func Validate(ctx context.Context, db *sql.DB, plaintext string) (id, prefix, orgID string, err error) { if plaintext == "" { return "", "", "", ErrInvalidToken } hash := sha256.Sum256([]byte(plaintext)) var orgIDNull sql.NullString queryErr := db.QueryRowContext(ctx, ` SELECT id, prefix, org_id FROM org_api_tokens WHERE token_hash = $1 AND revoked_at IS NULL `, hash[:]).Scan(&id, &prefix, &orgIDNull) if queryErr != nil { // Collapse all failure shapes into ErrInvalidToken so the // caller can't accidentally leak "row exists but revoked" vs // "row never existed" via response shape. return "", "", "", ErrInvalidToken } if orgIDNull.Valid { orgID = orgIDNull.String } // Best-effort last_used_at bump. Failure here is acceptable — the // request is already authenticated; we don't want a transient DB // blip to flip a 200 into a 500. _, _ = db.ExecContext(ctx, `UPDATE org_api_tokens SET last_used_at = now() WHERE id = $1`, id) return id, prefix, orgID, nil } // List returns live (non-revoked) tokens newest-first. Safe to // expose to the admin UI — no hash, no plaintext, only prefix. // // Capped at listMax rows. A UI page with more than that is a // symptom of abuse or a bug — the hard cap prevents one runaway // minting loop from O(N) pageloads in the admin UI. func List(ctx context.Context, db *sql.DB) ([]Token, error) { // org_id is a UUID column — COALESCE must cast to text first, // otherwise Postgres rejects the empty-string literal with // "pq: invalid input syntax for type uuid: ''". sqlmock doesn't // exercise pq type coercion, so this bug only surfaces against // a real Postgres (prod). rows, err := db.QueryContext(ctx, ` SELECT id, prefix, COALESCE(name,''), COALESCE(org_id::text,''), COALESCE(created_by,''), created_at, last_used_at FROM org_api_tokens WHERE revoked_at IS NULL ORDER BY created_at DESC LIMIT $1 `, listMax) if err != nil { return nil, fmt.Errorf("orgtoken: list: %w", err) } defer rows.Close() out := []Token{} for rows.Next() { var t Token var lastUsed sql.NullTime if err := rows.Scan(&t.ID, &t.Prefix, &t.Name, &t.OrgID, &t.CreatedBy, &t.CreatedAt, &lastUsed); err != nil { return nil, fmt.Errorf("orgtoken: scan: %w", err) } if lastUsed.Valid { v := lastUsed.Time t.LastUsedAt = &v } out = append(out, t) } return out, rows.Err() } // Revoke flips revoked_at on the row with id. Idempotent — revoking // an already-revoked token returns (false, nil). Returns (true, nil) // when a row transitioned from live → revoked; (false, nil) when // already revoked or absent. The caller maps (false, nil) to 404 so // ops tooling can distinguish "already dealt with" from "silently // worked". func Revoke(ctx context.Context, db *sql.DB, id string) (bool, error) { res, err := db.ExecContext(ctx, ` UPDATE org_api_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL `, id) if err != nil { return false, fmt.Errorf("orgtoken: revoke: %w", err) } n, _ := res.RowsAffected() return n > 0, nil } // HasAnyLive returns true when at least one non-revoked token // exists. Used by the middleware to decide whether to check the // org-token tier at all — skipping a DB round-trip per request when // nobody has minted any yet. func HasAnyLive(ctx context.Context, db *sql.DB) (bool, error) { var ok bool err := db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM org_api_tokens WHERE revoked_at IS NULL) `).Scan(&ok) if err != nil { return false, fmt.Errorf("orgtoken: has-any-live: %w", err) } return ok, nil } // OrgIDByTokenID looks up the org workspace ID for a token. // Used by requireCallerOwnsOrg to enforce org isolation on org-scoped // routes (#1200 / F1094). Returns ("", nil) when the token has no org_id // set (e.g. pre-migration tokens, ADMIN_TOKEN bootstrap tokens) — the // caller treats this as "deny by default". func OrgIDByTokenID(ctx context.Context, db *sql.DB, tokenID string) (string, error) { var orgID sql.NullString err := db.QueryRowContext(ctx, `SELECT org_id FROM org_api_tokens WHERE id = $1`, tokenID, ).Scan(&orgID) if err != nil { return "", fmt.Errorf("orgtoken: org_id lookup: %w", err) } if !orgID.Valid || orgID.String == "" { return "", nil // unanchored token — deny by default } return orgID.String, nil } func nullIfEmpty(s string) interface{} { if s == "" { return nil } return s }