forked from molecule-ai/molecule-core
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:
parent
f6ebf7fb64
commit
91187342b4
250
canvas/src/components/settings/OrgTokensTab.tsx
Normal file
250
canvas/src/components/settings/OrgTokensTab.tsx
Normal 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`;
|
||||
}
|
||||
@ -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">
|
||||
|
||||
138
workspace-server/internal/handlers/org_tokens.go
Normal file
138
workspace-server/internal/handlers/org_tokens.go
Normal 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"
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
185
workspace-server/internal/orgtoken/tokens.go
Normal file
185
workspace-server/internal/orgtoken/tokens.go
Normal 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
|
||||
}
|
||||
208
workspace-server/internal/orgtoken/tokens_test.go
Normal file
208
workspace-server/internal/orgtoken/tokens_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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-
|
||||
|
||||
2
workspace-server/migrations/035_org_api_tokens.down.sql
Normal file
2
workspace-server/migrations/035_org_api_tokens.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS org_api_tokens_live_idx;
|
||||
DROP TABLE IF EXISTS org_api_tokens;
|
||||
40
workspace-server/migrations/035_org_api_tokens.up.sql
Normal file
40
workspace-server/migrations/035_org_api_tokens.up.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user