molecule-core/workspace-server/internal/orgtoken/tokens.go
Hongming Wang 7d01f13500 fix(orgtoken): cast org_id to text in COALESCE to prevent 500
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>
2026-04-22 17:18:56 -07:00

239 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}