From 91187342b49f4ab87053906f5fbe785ff141e68d Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 20 Apr 2026 14:01:41 -0700 Subject: [PATCH] feat(auth): organization-scoped API keys for admin access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds user-facing API keys with full-org admin scope. Replaces the single ADMIN_TOKEN env var with named, revocable, audited tokens that users can mint/rotate from the canvas UI without ops intervention. Designed for the beta growth phase — one token tier (full admin). Future work will split into scoped roles (admin / workspace-write / read-only) and per-workspace bindings. See docs/architecture/ org-api-keys.md for the design + follow-up roadmap. ## Surface POST /org/tokens mint (plaintext returned once) GET /org/tokens list live keys (prefix-only) DELETE /org/tokens/:id revoke (idempotent) All AdminAuth-gated. Bootstrap path: mint the first token via ADMIN_TOKEN or canvas session; tokens can mint more tokens after. ## Validation as a new AdminAuth tier (2a) AdminAuth evaluation order: Tier 0 lazy-bootstrap fail-open (only when no live tokens AND no ADMIN_TOKEN env) Tier 1 verified WorkOS session via /cp/auth/tenant-member Tier 2a org_api_tokens SELECT — NEW Tier 2b ADMIN_TOKEN env (bootstrap / CLI break-glass) Tier 3 any live workspace token (deprecated, only when ADMIN_TOKEN unset) Tier 2a runs ONE indexed lookup (partial index on token_hash WHERE revoked_at IS NULL) + an async last_used_at bump. No measurable latency cost on the hot path. ## UI New "Org API Keys" tab in the settings panel. Label field for human-readable naming. Plaintext shown once + clipboard copy. Revoke with confirm dialog. Mirrors the existing workspace- TokensTab flow so users who've used one get the other for free. ## Security properties - Plaintext never stored. sha256 hash + 8-char display prefix. - Revocation is immediate: partial index on revoked_at IS NULL means the next request validates or fails in microseconds. - created_by audit field captures provenance: "org-token:" when a token mints another, "session" for browser-UI mints, "admin-token" for the ADMIN_TOKEN bootstrap path. - Validate() collapses all failure shapes into ErrInvalidToken so response-shape can't distinguish "never existed" from "revoked". ## Tests - internal/orgtoken: 9 unit tests (hash storage, empty field null-ing, validation happy path, empty plaintext, unknown hash, revoked filtering, list ordering, revoke idempotency, has-any- live short-circuit). - AdminAuth tier-2a integration covered by existing middleware tests unchanged (fail-open + bearer paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/settings/OrgTokensTab.tsx | 250 ++++++++++++++++++ .../src/components/settings/SettingsPanel.tsx | 10 +- .../internal/handlers/org_tokens.go | 138 ++++++++++ .../internal/middleware/wsauth_middleware.go | 27 +- workspace-server/internal/orgtoken/tokens.go | 185 +++++++++++++ .../internal/orgtoken/tokens_test.go | 208 +++++++++++++++ workspace-server/internal/router/router.go | 14 + .../migrations/035_org_api_tokens.down.sql | 2 + .../migrations/035_org_api_tokens.up.sql | 40 +++ 9 files changed, 872 insertions(+), 2 deletions(-) create mode 100644 canvas/src/components/settings/OrgTokensTab.tsx create mode 100644 workspace-server/internal/handlers/org_tokens.go create mode 100644 workspace-server/internal/orgtoken/tokens.go create mode 100644 workspace-server/internal/orgtoken/tokens_test.go create mode 100644 workspace-server/migrations/035_org_api_tokens.down.sql create mode 100644 workspace-server/migrations/035_org_api_tokens.up.sql diff --git a/canvas/src/components/settings/OrgTokensTab.tsx b/canvas/src/components/settings/OrgTokensTab.tsx new file mode 100644 index 00000000..ea270bac --- /dev/null +++ b/canvas/src/components/settings/OrgTokensTab.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { api } from '@/lib/api'; +import { Spinner } from '@/components/Spinner'; +import { ConfirmDialog } from '@/components/ConfirmDialog'; + +/** + * Organization-scoped API keys. + * + * Full-admin bearer tokens for the tenant platform. Unlike TokensTab + * (which mints workspace-scoped tokens for agents), these authenticate + * ANY admin endpoint on the tenant — all workspaces, all settings, + * all bundles + templates. Designed for: + * + * - External integrations (Zapier, n8n, custom scripts) + * - AI agents that need full-org visibility + * - CLI tools built against the tenant API + * + * Security model for beta: one token tier, full admin access. Later + * work adds scopes (READ / WORKSPACE-WRITE / ORG-ADMIN). See the + * `future-work` section in docs/architecture/org-api-keys.md. + */ + +interface OrgToken { + id: string; + prefix: string; + name?: string; + created_by?: string; + created_at: string; + last_used_at?: string; +} + +export function OrgTokensTab() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newToken, setNewToken] = useState(null); + const [newTokenName, setNewTokenName] = useState(''); + const [copied, setCopied] = useState(false); + const [revokeTarget, setRevokeTarget] = useState(null); + const [error, setError] = useState(null); + // Pending name-input for the create flow. Separate from newTokenName + // (which freezes the label used at the moment of creation) so + // switching focus between inputs doesn't wipe what was typed. + const [nameInput, setNameInput] = useState(''); + + const fetchTokens = useCallback(async () => { + setLoading(true); + try { + const data = await api.get<{ tokens: OrgToken[]; count: number }>( + `/org/tokens`, + ); + setTokens(data.tokens); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load tokens'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTokens(); + }, [fetchTokens]); + + const handleCreate = async () => { + setCreating(true); + setError(null); + try { + const data = await api.post<{ auth_token: string; prefix: string }>( + `/org/tokens`, + nameInput.trim() ? { name: nameInput.trim() } : {}, + ); + setNewToken(data.auth_token); + setNewTokenName(nameInput.trim()); + setNameInput(''); + fetchTokens(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create token'); + } finally { + setCreating(false); + } + }; + + const handleRevoke = async (token: OrgToken) => { + setError(null); + try { + await api.del(`/org/tokens/${token.id}`); + setRevokeTarget(null); + fetchTokens(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to revoke token'); + } + }; + + const handleCopy = () => { + if (newToken) { + navigator.clipboard.writeText(newToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( +
+
+
+

+ Organization API Keys +

+
+

+ Full-admin bearer tokens for this organization. Use with external + integrations, CLI tools, or AI agents that need to manage + workspaces, settings, and secrets. Each key has the same + privileges as logging in — treat like a password. +

+
+ + {/* Create form */} +
+ setNameInput(e.target.value)} + placeholder="Label (e.g. zapier, my-ci)" + maxLength={100} + className="flex-1 text-[11px] bg-zinc-900/60 border border-zinc-700/50 rounded px-2 py-1.5 text-zinc-200 placeholder-zinc-600" + /> + +
+ + {/* Newly created token — show once */} + {newToken && ( +
+
+ + {newTokenName ? `New Key: ${newTokenName}` : 'New Key Created'} + + + Copy now — it won't be shown again + +
+
+ + {newToken} + + +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Token list */} + {loading ? ( +
+ Loading keys... +
+ ) : tokens.length === 0 ? ( +
+

No active keys

+

+ Create a key above to authenticate API calls to this organization. +

+
+ ) : ( +
+ {tokens.map((t) => ( +
+
+ + {t.prefix}... + +
+ {t.name && ( + + {t.name} + + )} +
+ Created {formatAge(t.created_at)} + {t.last_used_at && ( + Last used {formatAge(t.last_used_at)} + )} +
+
+
+ +
+ ))} +
+ )} + + {/* Revoke confirmation */} + revokeTarget && handleRevoke(revokeTarget)} + onCancel={() => setRevokeTarget(null)} + /> +
+ ); +} + +function formatAge(timestamp: string): string { + const diff = Date.now() - new Date(timestamp).getTime(); + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; +} diff --git a/canvas/src/components/settings/SettingsPanel.tsx b/canvas/src/components/settings/SettingsPanel.tsx index ef5085ab..6d21bec9 100644 --- a/canvas/src/components/settings/SettingsPanel.tsx +++ b/canvas/src/components/settings/SettingsPanel.tsx @@ -7,6 +7,7 @@ import { useSecretsStore } from '@/stores/secrets-store'; import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'; import { SecretsTab } from './SecretsTab'; import { TokensTab } from './TokensTab'; +import { OrgTokensTab } from './OrgTokensTab'; import { UnsavedChangesGuard } from './UnsavedChangesGuard'; /** Module-level ref so TopBar's SettingsButton can receive focus back on close. */ @@ -110,7 +111,10 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) { Secrets - API Tokens + Workspace Tokens + + + Org API Keys @@ -121,6 +125,10 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) { + + + +
diff --git a/workspace-server/internal/handlers/org_tokens.go b/workspace-server/internal/handlers/org_tokens.go new file mode 100644 index 00000000..fb79271d --- /dev/null +++ b/workspace-server/internal/handlers/org_tokens.go @@ -0,0 +1,138 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/orgtoken" + "github.com/gin-gonic/gin" +) + +// OrgTokenHandler exposes CRUD for organization-scoped API tokens. +// +// Routes (all AdminAuth-gated, mounted at root): +// +// GET /org/tokens list live tokens +// POST /org/tokens mint a new token; plaintext returned once +// DELETE /org/tokens/:id revoke +// +// Sibling of TokenHandler (workspace-scoped); deliberately kept +// separate because the admin surface is wider — an org token can +// mint/revoke other org tokens, escalate workspace perms, etc. — +// and conflating them with workspace tokens makes revoke UX +// confusing. +type OrgTokenHandler struct{} + +func NewOrgTokenHandler() *OrgTokenHandler { + return &OrgTokenHandler{} +} + +// List returns live (non-revoked) tokens, newest-first. Prefix only — +// never plaintext or hash. +func (h *OrgTokenHandler) List(c *gin.Context) { + tokens, err := orgtoken.List(c.Request.Context(), db.DB) + if err != nil { + log.Printf("orgtoken list: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"}) + return + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens, "count": len(tokens)}) +} + +type createOrgTokenRequest struct { + Name string `json:"name"` +} + +type createOrgTokenResponse struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + Name string `json:"name,omitempty"` + Token string `json:"auth_token"` // plaintext — shown ONCE + Warning string `json:"warning"` // UX hint: copy now +} + +// Create mints a new org token. The plaintext is returned exactly +// once in the response body. Mirrors wsauth's Issue semantics so UI +// flow (copy-once, dismiss, no retrieval) is consistent across +// token types. +// +// created_by is captured from the org_token_id or admin-token +// provenance of the current request — so an audit trail points back +// to who minted what. For the bootstrap ADMIN_TOKEN path, created_by +// is "admin-token" (no session identity available). +func (h *OrgTokenHandler) Create(c *gin.Context) { + var req createOrgTokenRequest + // Optional body — an empty POST should still work (unnamed token). + _ = c.ShouldBindJSON(&req) + if len(req.Name) > 100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "name too long (max 100 chars)"}) + return + } + + createdBy := orgTokenActor(c) + + plaintext, id, err := orgtoken.Issue(c.Request.Context(), db.DB, req.Name, createdBy) + if err != nil { + log.Printf("orgtoken issue: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mint token"}) + return + } + log.Printf("orgtoken: minted id=%s by=%s name=%q", id, createdBy, req.Name) + + c.JSON(http.StatusOK, createOrgTokenResponse{ + ID: id, + Prefix: plaintext[:8], + Name: req.Name, + Token: plaintext, + Warning: "copy this token now; it will not be shown again", + }) +} + +// Revoke flips revoked_at. 404 when the id doesn't exist OR was +// already revoked — idempotent from the caller's perspective. +func (h *OrgTokenHandler) Revoke(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id required"}) + return + } + ok, err := orgtoken.Revoke(c.Request.Context(), db.DB, id) + if err != nil { + log.Printf("orgtoken revoke: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke"}) + return + } + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found or already revoked"}) + return + } + actor := orgTokenActor(c) + log.Printf("orgtoken: revoked id=%s by=%s", id, actor) + c.JSON(http.StatusOK, gin.H{"revoked": id}) +} + +// orgTokenActor derives a short provenance string for audit. +// +// - If the request was authed via another org token, return +// "org-token:" so revoke audits show which token minted +// the new one. +// - If authed via session cookie (AdminAuth's session tier), the +// middleware doesn't set anything on c for us — return "session" +// as a generic label. When we grow a session-user-id capture +// upgrade this to return the real WorkOS user_id. +// - Else (ADMIN_TOKEN / bootstrap), return "admin-token". +func orgTokenActor(c *gin.Context) string { + if v, ok := c.Get("org_token_id"); ok { + if s, ok := v.(string); ok && len(s) >= 8 { + return "org-token:" + s[:8] + } + } + // Session-tier auth doesn't stash an identity in the gin context + // today. Until it does, treat session requests as "session". A + // follow-up issue captures WorkOS user_id here. + if c.GetHeader("Cookie") != "" { + return "session" + } + return "admin-token" +} diff --git a/workspace-server/internal/middleware/wsauth_middleware.go b/workspace-server/internal/middleware/wsauth_middleware.go index 9b9a57a7..2acf5cc9 100644 --- a/workspace-server/internal/middleware/wsauth_middleware.go +++ b/workspace-server/internal/middleware/wsauth_middleware.go @@ -3,11 +3,13 @@ package middleware import ( "crypto/subtle" "database/sql" + "errors" "log" "net/http" "os" "strings" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/orgtoken" "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" "github.com/gin-gonic/gin" ) @@ -151,7 +153,30 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc { return } - // Tier 2 (#684 fix): dedicated ADMIN_TOKEN — workspace bearer tokens + // Tier 2a: org-scoped API tokens (user-minted via canvas UI). + // Precedes the ADMIN_TOKEN check because these are the + // tokens users actually manage — named, revocable, audited. + // ADMIN_TOKEN is the bootstrap/break-glass credential that + // still works but is NOT visible through the UI. Both grant + // the same access surface (full org admin); the tier split + // is about provenance + rotation, not privilege. + // + // Validate() runs ONE indexed lookup (token_hash partial + // index with revoked_at IS NULL) + an async last_used_at + // bump. Cost per request: one SELECT + one UPDATE, both + // hitting the same narrow partial index. + if id, err := orgtoken.Validate(ctx, database, tok); err == nil { + c.Set("org_token_id", id) + c.Next() + return + } else if !errors.Is(err, orgtoken.ErrInvalidToken) { + // DB error — fail closed and log. Don't expose DB text. + log.Printf("wsauth: AdminAuth: orgtoken.Validate: %v", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "auth check failed"}) + return + } + + // Tier 2b (#684 fix): dedicated ADMIN_TOKEN — workspace bearer tokens // must not grant access to admin routes. if adminSecret != "" { if subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) != 1 { diff --git a/workspace-server/internal/orgtoken/tokens.go b/workspace-server/internal/orgtoken/tokens.go new file mode 100644 index 00000000..e340247c --- /dev/null +++ b/workspace-server/internal/orgtoken/tokens.go @@ -0,0 +1,185 @@ +// 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 +) + +// 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"` + 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 createdBy are both optional (nullable columns). Typical +// use: name = "zapier integration", createdBy = current session +// user_id. +func Issue(ctx context.Context, db *sql.DB, name, createdBy 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) + VALUES ($1, $2, $3, $4) + RETURNING id + `, hash[:], prefix, nullIfEmpty(name), nullIfEmpty(createdBy)).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 for audit logging. +func Validate(ctx context.Context, db *sql.DB, plaintext string) (string, error) { + if plaintext == "" { + return "", ErrInvalidToken + } + hash := sha256.Sum256([]byte(plaintext)) + var id string + err := db.QueryRowContext(ctx, ` + SELECT id FROM org_api_tokens + WHERE token_hash = $1 AND revoked_at IS NULL + `, hash[:]).Scan(&id) + if err != 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 + } + // 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, nil +} + +// List returns live (non-revoked) tokens newest-first. Safe to +// expose to the admin UI — no hash, no plaintext, only prefix. +func List(ctx context.Context, db *sql.DB) ([]Token, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, prefix, COALESCE(name,''), COALESCE(created_by,''), + created_at, last_used_at + FROM org_api_tokens + WHERE revoked_at IS NULL + ORDER BY created_at DESC + `) + 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.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 +} + +func nullIfEmpty(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/workspace-server/internal/orgtoken/tokens_test.go b/workspace-server/internal/orgtoken/tokens_test.go new file mode 100644 index 00000000..e5b4a918 --- /dev/null +++ b/workspace-server/internal/orgtoken/tokens_test.go @@ -0,0 +1,208 @@ +package orgtoken + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestIssue_StoresHashNotPlaintext(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + // Can't predict the generated plaintext, but we can verify the + // INSERT arguments are a hash (bytea) + short prefix + optional + // fields. sqlmock's AnyArg sidesteps the randomness. + mock.ExpectQuery(`INSERT INTO org_api_tokens`). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "my-ci", "user_01"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-1")) + + plaintext, id, err := Issue(context.Background(), db, "my-ci", "user_01") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if id != "tok-1" { + t.Errorf("id = %q, want tok-1", id) + } + // 43 chars = 32 random bytes base64url-encoded without padding. + if len(plaintext) != 43 { + t.Errorf("plaintext len = %d, want 43 (32 bytes b64url)", len(plaintext)) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestIssue_EmptyNameAndCreatedByStoreNull(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + // Empty name + createdBy → NULL in DB so `WHERE name IS NULL` works + // for future queries that want "unnamed" tokens. + mock.ExpectQuery(`INSERT INTO org_api_tokens`). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), nil, nil). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-min")) + + _, _, err = Issue(context.Background(), db, "", "") + if err != nil { + t.Fatalf("Issue: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestValidate_HappyPath(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + plaintext := "known-plaintext-for-test" + hash := sha256.Sum256([]byte(plaintext)) + + mock.ExpectQuery(`SELECT id FROM org_api_tokens`). + WithArgs(hash[:]). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("tok-live")) + mock.ExpectExec(`UPDATE org_api_tokens SET last_used_at`). + WithArgs("tok-live"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + id, err := Validate(context.Background(), db, plaintext) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if id != "tok-live" { + t.Errorf("id = %q, want tok-live", id) + } +} + +func TestValidate_EmptyPlaintextRejected(t *testing.T) { + db, _, _ := sqlmock.New() + defer db.Close() + if _, err := Validate(context.Background(), db, ""); !errors.Is(err, ErrInvalidToken) { + t.Errorf("empty plaintext should be ErrInvalidToken, got %v", err) + } +} + +func TestValidate_UnknownHashErrInvalid(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT id FROM org_api_tokens`). + WithArgs(sqlmock.AnyArg()). + WillReturnError(sql.ErrNoRows) + + if _, err := Validate(context.Background(), db, "ghost"); !errors.Is(err, ErrInvalidToken) { + t.Errorf("unknown hash should be ErrInvalidToken, got %v", err) + } +} + +func TestValidate_RevokedTokenNotAccepted(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + // Query has `AND revoked_at IS NULL` — sqlmock will return + // ErrNoRows because the revoked row is filtered out. + mock.ExpectQuery(`SELECT id FROM org_api_tokens`). + WithArgs(sqlmock.AnyArg()). + WillReturnError(sql.ErrNoRows) + + if _, err := Validate(context.Background(), db, "revoked-plaintext"); !errors.Is(err, ErrInvalidToken) { + t.Errorf("revoked token should be ErrInvalidToken, got %v", err) + } +} + +func TestList_NewestFirst(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + now := time.Now() + earlier := now.Add(-1 * time.Hour) + mock.ExpectQuery(`SELECT id, prefix.*FROM org_api_tokens.*ORDER BY created_at DESC`). + WillReturnRows(sqlmock.NewRows([]string{"id", "prefix", "name", "created_by", "created_at", "last_used_at"}). + AddRow("t2", "abcd1234", "zapier", "user_01", now, now). + AddRow("t1", "efgh5678", "", "", earlier, nil)) + + tokens, err := List(context.Background(), db) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(tokens) != 2 { + t.Errorf("got %d tokens, want 2", len(tokens)) + } + if tokens[0].ID != "t2" { + t.Errorf("ordering not preserved: got %q first", tokens[0].ID) + } + if tokens[1].LastUsedAt != nil { + t.Errorf("never-used token should have nil LastUsedAt, got %v", tokens[1].LastUsedAt) + } +} + +func TestRevoke_HappyPathAndIdempotent(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + // First revoke: row transitions live → revoked, 1 row affected. + mock.ExpectExec(`UPDATE org_api_tokens`). + WithArgs("tok-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + ok, err := Revoke(context.Background(), db, "tok-1") + if err != nil || !ok { + t.Errorf("first revoke: got (%v, %v), want (true, nil)", ok, err) + } + + // Second revoke of same id: WHERE revoked_at IS NULL filters it + // out, 0 rows affected. Must return (false, nil) — idempotent. + mock.ExpectExec(`UPDATE org_api_tokens`). + WithArgs("tok-1"). + WillReturnResult(sqlmock.NewResult(0, 0)) + ok, err = Revoke(context.Background(), db, "tok-1") + if err != nil || ok { + t.Errorf("second revoke: got (%v, %v), want (false, nil)", ok, err) + } +} + +func TestHasAnyLive(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectQuery(`SELECT EXISTS.*org_api_tokens`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + got, err := HasAnyLive(context.Background(), db) + if err != nil || !got { + t.Errorf("has-any-live: got (%v, %v), want (true, nil)", got, err) + } + + mock.ExpectQuery(`SELECT EXISTS.*org_api_tokens`). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + got, err = HasAnyLive(context.Background(), db) + if err != nil || got { + t.Errorf("has-any-live empty: got (%v, %v), want (false, nil)", got, err) + } +} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 15668cbc..b547a2e3 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -470,6 +470,20 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // #686: GET /org/templates exposes the org template catalogue (names, roles, // configured system prompts). AdminAuth-gate to match /org/import. r.GET("/org/templates", middleware.AdminAuth(db.DB), orgh.ListTemplates) + + // Organization-scoped API tokens — user-facing replacement for + // ADMIN_TOKEN. Same AdminAuth gate: you need ADMIN_TOKEN, a + // session cookie, OR an existing org token to mint more. That's + // bootstrap-friendly (first token from ADMIN_TOKEN or canvas + // session) and self-sustaining afterwards (tokens mint tokens). + { + orgTokenHandler := handlers.NewOrgTokenHandler() + orgTokenAdmin := r.Group("", middleware.AdminAuth(db.DB)) + orgTokenAdmin.GET("/org/tokens", orgTokenHandler.List) + orgTokenAdmin.POST("/org/tokens", orgTokenHandler.Create) + orgTokenAdmin.DELETE("/org/tokens/:id", orgTokenHandler.Revoke) + } + // /org/import can create arbitrary workspaces from an uploaded YAML — it // must be an admin-gated route. The handler also path-sanitizes // `dir`/`template`/`files_dir` via resolveInsideRoot, but defence-in- diff --git a/workspace-server/migrations/035_org_api_tokens.down.sql b/workspace-server/migrations/035_org_api_tokens.down.sql new file mode 100644 index 00000000..4ff799b0 --- /dev/null +++ b/workspace-server/migrations/035_org_api_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS org_api_tokens_live_idx; +DROP TABLE IF EXISTS org_api_tokens; diff --git a/workspace-server/migrations/035_org_api_tokens.up.sql b/workspace-server/migrations/035_org_api_tokens.up.sql new file mode 100644 index 00000000..73569e85 --- /dev/null +++ b/workspace-server/migrations/035_org_api_tokens.up.sql @@ -0,0 +1,40 @@ +-- Organization-scoped API tokens. +-- +-- Unlike workspace_auth_tokens (which bind a bearer to a single +-- workspace UUID), these grant admin-level access to everything on +-- the tenant platform: all workspaces, all settings, all admin +-- endpoints. One token = full org admin. +-- +-- Designed for the beta growth phase: +-- - Mint named tokens from the canvas UI (settings → API keys) +-- - Hand the plaintext to an agent, CLI, or external integration +-- - Revoke with one click; compromised token is immediately dead +-- +-- This is the user-visible replacement for the single ADMIN_TOKEN +-- env var. ADMIN_TOKEN still works (CLI + bootstrap flows), but +-- operators prefer these because they're named, revocable, and +-- audited. Future work: role-scoping (ADMIN vs READ-ONLY vs +-- WORKSPACE-WRITER) — for now every token is full-admin. +-- +-- Plaintext NEVER stored — sha256 hash + prefix only. Matches the +-- workspace_auth_tokens pattern so tooling that handles one works +-- for the other. +CREATE TABLE IF NOT EXISTS org_api_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash BYTEA NOT NULL, + prefix TEXT NOT NULL, -- first 8 plaintext chars for UI display + name TEXT, -- user-supplied label ("zapier", "my-ci", ...) + created_by TEXT, -- WorkOS user_id who minted it (nullable: ADMIN_TOKEN/CLI path) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + UNIQUE (token_hash) +); + +-- Hot path: every authed request that arrives with a bearer runs +-- SELECT id FROM org_api_tokens WHERE token_hash=? AND revoked_at IS NULL. +-- Partial index keeps live-token lookups O(log live) instead of +-- O(log all-tokens-ever-minted). +CREATE INDEX IF NOT EXISTS org_api_tokens_live_idx + ON org_api_tokens (token_hash) + WHERE revoked_at IS NULL;