feat(auth): organization-scoped API keys for admin access

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:<short>"
    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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-20 14:01:41 -07:00
parent f6ebf7fb64
commit 91187342b4
9 changed files with 872 additions and 2 deletions

View File

@ -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<OrgToken[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [newTokenName, setNewTokenName] = useState('');
const [copied, setCopied] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<OrgToken | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="p-4 space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-semibold text-zinc-200">
Organization API Keys
</h3>
</div>
<p className="text-[10px] text-zinc-500 leading-relaxed">
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.
</p>
</div>
{/* Create form */}
<div className="flex gap-2 items-stretch">
<input
type="text"
value={nameInput}
onChange={(e) => 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"
/>
<button
onClick={handleCreate}
disabled={creating}
className="px-3 py-1.5 bg-blue-600/20 hover:bg-blue-600/30 border border-blue-500/30 rounded-lg text-[11px] text-blue-300 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{creating ? (
<>
<Spinner size="sm" /> Creating...
</>
) : (
'+ New Key'
)}
</button>
</div>
{/* Newly created token — show once */}
{newToken && (
<div className="bg-emerald-950/30 border border-emerald-800/40 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-[10px] text-emerald-400 font-semibold uppercase tracking-wider">
{newTokenName ? `New Key: ${newTokenName}` : 'New Key Created'}
</span>
<span className="text-[9px] text-emerald-500/70">
Copy now it won't be shown again
</span>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] text-emerald-200 bg-emerald-950/50 px-2 py-1.5 rounded font-mono break-all select-all">
{newToken}
</code>
<button
onClick={handleCopy}
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-emerald-300 transition-colors"
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<button
onClick={() => setNewToken(null)}
className="text-[9px] text-emerald-500/60 hover:text-emerald-400 transition-colors"
>
Dismiss
</button>
</div>
)}
{error && (
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-red-400">
{error}
</div>
)}
{/* Token list */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-zinc-500 text-xs">
<Spinner /> Loading keys...
</div>
) : tokens.length === 0 ? (
<div className="text-center py-6">
<p className="text-xs text-zinc-500">No active keys</p>
<p className="text-[10px] text-zinc-600 mt-1">
Create a key above to authenticate API calls to this organization.
</p>
</div>
) : (
<div className="space-y-1.5">
{tokens.map((t) => (
<div
key={t.id}
className="flex items-center justify-between bg-zinc-800/40 border border-zinc-700/30 rounded-lg px-3 py-2"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-[11px] font-mono text-zinc-300 bg-zinc-900/60 px-1.5 py-0.5 rounded shrink-0">
{t.prefix}...
</code>
<div className="flex flex-col min-w-0">
{t.name && (
<span className="text-[11px] text-zinc-200 truncate">
{t.name}
</span>
)}
<div className="text-[9px] text-zinc-500 space-x-3">
<span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span>
)}
</div>
</div>
</div>
<button
onClick={() => setRevokeTarget(t)}
className="text-[10px] text-red-400/70 hover:text-red-400 transition-colors px-2 py-1 shrink-0"
>
Revoke
</button>
</div>
))}
</div>
)}
{/* Revoke confirmation */}
<ConfirmDialog
open={!!revokeTarget}
title="Revoke API Key"
message={`Revoke ${revokeTarget?.name ? `"${revokeTarget.name}" ` : ''}(${revokeTarget?.prefix}...)? Any integration using this key will immediately lose access.`}
confirmLabel="Revoke"
confirmVariant="danger"
onConfirm={() => revokeTarget && handleRevoke(revokeTarget)}
onCancel={() => setRevokeTarget(null)}
/>
</div>
);
}
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`;
}

View File

@ -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
</Tabs.Trigger>
<Tabs.Trigger value="tokens" className="settings-panel__tab">
API Tokens
Workspace Tokens
</Tabs.Trigger>
<Tabs.Trigger value="org-tokens" className="settings-panel__tab">
Org API Keys
</Tabs.Trigger>
</Tabs.List>
@ -121,6 +125,10 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) {
<Tabs.Content value="tokens" className="settings-panel__content">
<TokensTab workspaceId={workspaceId} />
</Tabs.Content>
<Tabs.Content value="org-tokens" className="settings-panel__content">
<OrgTokensTab />
</Tabs.Content>
</Tabs.Root>
<div className="settings-panel__footer">

View File

@ -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:<short>" 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"
}

View File

@ -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 {

View File

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

View File

@ -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)
}
}

View File

@ -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-

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS org_api_tokens_live_idx;
DROP TABLE IF EXISTS org_api_tokens;

View File

@ -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;