Symptom (prod tenant hongmingwang): GET /org/tokens → 500 orgtoken list: orgtoken: list: pq: invalid input syntax for type uuid: "" Postgres rejects COALESCE(uuid_col, '') because it can't cast the empty string to UUID. Cast to ::text first so the COALESCE operates on matching types. OrgID on the Go side is already string, so no scan changes needed. sqlmock doesn't exercise pq type coercion — it accepts any AddRow value for any column — which is why the existing tests pass while prod 500s. Real-Postgres integration coverage is the systemic fix (tracked separately), but this PR unblocks the Settings → Org Tokens page today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
239 lines
8.8 KiB
Go
239 lines
8.8 KiB
Go
// 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
|
||
}
|