Adds programmatic org management for partner platforms, CI/CD, and automation. Partners authenticate with mol_pk_* API keys (SHA-256 hashed, scoped, rate-limited, revocable) alongside existing WorkOS browser auth. - Full architecture doc with schema, scopes, middleware integration, security considerations, and use cases - Phase 34 in PLAN.md (4 sub-phases) - CLAUDE.md cross-reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
Partner API Keys — Programmatic Org Management
Status: Planned
Problem: All CP endpoints require a WorkOS browser session (OAuth redirect →
mcp_sessioncookie). This blocks: partner integrations, CI/CD automation, programmatic testing, marketplace resellers, and any non-browser client that needs to create/manage orgs.Solution: Add API key authentication as a parallel auth path alongside WorkOS sessions. Partners authenticate with
Authorization: Bearer mol_pk_*and access the same org management endpoints.
Architecture
Browser user:
WorkOS AuthKit → OAuth redirect → mcp_session cookie → RequireSession()
Partner/API client:
Authorization: Bearer mol_pk_xxxxx → ValidatePartnerKey() → same handlers
Both paths converge at the handler layer — the handler doesn't know or care
which auth method was used. It receives a validated identity context.
Auth flow
POST /cp/orgs
Authorization: Bearer mol_pk_live_a1b2c3d4e5f6...
Content-Type: application/json
{"slug": "acme", "name": "Acme Corp", "plan": "starter"}
→ CP middleware:
1. Check Authorization header for "Bearer mol_pk_*" prefix
2. SHA-256 hash the token
3. Look up hash in partner_api_keys table
4. Verify: not revoked, not expired, scopes include required scope
5. Set auth context: { partner_id, partner_name, scopes, org_id }
6. Continue to handler
→ Handler creates org as normal (same code path as browser flow)
→ 201 Created { id, slug, name, status: "provisioning" }
Key format
mol_pk_live_<32 random hex chars> — production key
mol_pk_test_<32 random hex chars> — test/sandbox key (future)
Prefix mol_pk_ makes keys easily identifiable in logs, .env files, and
secret scanners. The live_/test_ segment enables environment separation
when we add sandbox mode.
Keys are 44 characters total: mol_pk_live_ (12) + 32 hex = 44 chars.
Displayed once at creation; stored as SHA-256 hash (irreversible).
Database schema
-- Migration: 0XX_partner_api_keys.up.sql
CREATE TABLE IF NOT EXISTS partner_api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- "Acme Reseller", "CI Pipeline"
key_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the full key
key_prefix TEXT NOT NULL, -- "mol_pk_live_a1b2" (first 16 chars, for identification)
partner_id TEXT NOT NULL, -- external partner identifier
org_id UUID, -- NULL = can manage any org; set = scoped to one org
scopes TEXT[] NOT NULL DEFAULT '{}', -- {"orgs:create","orgs:read","orgs:delete","billing:read"}
rate_limit INTEGER NOT NULL DEFAULT 60, -- requests per minute
created_by TEXT NOT NULL, -- admin user ID who created it
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL = never expires
revoked_at TIMESTAMPTZ, -- NULL = active
last_used_at TIMESTAMPTZ -- updated on each use
);
CREATE INDEX IF NOT EXISTS partner_api_keys_hash_idx ON partner_api_keys(key_hash);
CREATE INDEX IF NOT EXISTS partner_api_keys_partner_idx ON partner_api_keys(partner_id);
Scopes
| Scope | Grants |
|---|---|
orgs:create |
POST /cp/orgs — create organizations |
orgs:read |
GET /cp/orgs, GET /cp/orgs/:slug, GET /cp/orgs/:slug/instance |
orgs:delete |
DELETE /cp/orgs/:slug — full GDPR cascade |
orgs:export |
GET /cp/orgs/:slug/export — data export |
billing:read |
GET /cp/orgs/:slug/usage |
billing:manage |
POST /cp/billing/checkout, POST /cp/billing/portal |
provision:status |
GET /cp/orgs/:slug/provision-status |
admin:keys |
POST/DELETE /cp/admin/partner-keys — manage other keys |
Scopes are additive. A key with ["orgs:create", "orgs:read"] can create
and list orgs but cannot delete them or manage billing.
Org-scoped keys: When org_id is set, the key can only access that
specific org. Useful for giving a partner access to manage their own org
without seeing others. When org_id is NULL, the key is global (admin-level).
API endpoints
Create a partner key (admin only)
POST /cp/admin/partner-keys
Authorization: Bearer <admin-session-or-existing-admin-key>
Content-Type: application/json
{
"name": "Acme Reseller",
"partner_id": "partner_acme",
"scopes": ["orgs:create", "orgs:read", "billing:read"],
"org_id": null,
"expires_in_days": 365,
"rate_limit": 120
}
→ 201 Created
{
"id": "uuid",
"name": "Acme Reseller",
"key": "mol_pk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", ← shown ONCE
"key_prefix": "mol_pk_live_a1b2",
"scopes": ["orgs:create", "orgs:read", "billing:read"],
"expires_at": "2027-04-17T00:00:00Z",
"rate_limit": 120
}
The full key is returned exactly once. Store it securely — we only keep the SHA-256 hash.
List partner keys (admin only)
GET /cp/admin/partner-keys
→ 200 OK
[
{
"id": "uuid",
"name": "Acme Reseller",
"key_prefix": "mol_pk_live_a1b2",
"partner_id": "partner_acme",
"scopes": ["orgs:create", "orgs:read"],
"created_at": "2026-04-17T...",
"last_used_at": "2026-04-17T...",
"expires_at": "2027-04-17T...",
"revoked_at": null
}
]
Revoke a partner key (admin only)
DELETE /cp/admin/partner-keys/:id
→ 204 No Content
Revocation is immediate. The key's revoked_at is set to now() and all
subsequent requests with that key return 401.
Middleware integration
The CP's auth middleware checks in order:
func Middleware(authProvider auth.Provider) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. Check for partner API key (Bearer mol_pk_*)
if token := bearerToken(c); strings.HasPrefix(token, "mol_pk_") {
partner, err := validatePartnerKey(c.Request.Context(), db, token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"})
return
}
// Set partner context — handlers read this instead of session
c.Set("partner", partner)
c.Set("auth_method", "api_key")
c.Next()
return
}
// 2. Fall back to WorkOS session (existing flow)
session := authProvider.GetSession(c)
// ... existing session logic
}
}
Handlers that need the caller's identity read from context:
// Works for both session and API key callers
func getCallerID(c *gin.Context) string {
if p, ok := c.Get("partner"); ok {
return p.(*PartnerKey).PartnerID
}
if s := auth.FromContext(c.Request.Context()); s != nil {
return s.UserID
}
return ""
}
Rate limiting
Partner keys have per-key rate limits (default: 60 req/min, configurable). Separate from the session-based rate limiter so partner traffic doesn't compete with browser users.
// In middleware, after validating the key:
if !rateLimiter.Allow(partner.ID, partner.RateLimit) {
c.AbortWithStatusJSON(429, gin.H{
"error": "rate limit exceeded",
"retry_after": rateLimiter.RetryAfter(partner.ID),
})
return
}
Security considerations
- Key storage: SHA-256 hash only in DB. Full key shown once at creation.
- Key rotation: Create new key → update partner config → revoke old key. No "update" endpoint — always create-then-revoke for clean audit trail.
- Scope enforcement: Each handler checks required scope before executing. Missing scope → 403 Forbidden (not 401 — the key is valid, just not authorized for this action).
- Org isolation: Org-scoped keys cannot access other orgs. Global keys (org_id=NULL) are admin-level — issue sparingly.
- Audit trail:
last_used_atupdated on each use.created_bytracks who issued the key. Full request logging includespartner_id. - Expiration: Optional
expires_at. Expired keys return 401 with a clear message ("API key expired"). - Pre-commit hook: The
mol_pk_prefix is added to the secret scanner pattern in.githooks/pre-committo prevent accidental commits.
Use cases
Partner platform integration
# Partner creates an org for their customer
curl -X POST https://api.moleculesai.app/cp/orgs \
-H "Authorization: Bearer mol_pk_live_a1b2c3d4..." \
-H "Content-Type: application/json" \
-d '{"slug": "customer-xyz", "name": "Customer XYZ", "plan": "starter"}'
# Partner polls provisioning status
curl https://api.moleculesai.app/cp/orgs/customer-xyz/provision-status \
-H "Authorization: Bearer mol_pk_live_a1b2c3d4..."
# Partner checks usage for billing
curl https://api.moleculesai.app/cp/orgs/customer-xyz/usage \
-H "Authorization: Bearer mol_pk_live_a1b2c3d4..."
CI/CD testing
# Create test org, run tests, delete
ORG=$(curl -s -X POST .../cp/orgs -H "Authorization: Bearer $MOL_API_KEY" \
-d '{"slug":"ci-test-'$GITHUB_RUN_ID'","name":"CI Test"}' | jq -r .slug)
# ... run E2E tests against $ORG.moleculesai.app ...
curl -X DELETE .../cp/orgs/$ORG -H "Authorization: Bearer $MOL_API_KEY"
Internal automation (what Claude Code needs for testing)
# CEO's assistant agent creates test orgs programmatically
curl -X POST https://api.moleculesai.app/cp/orgs \
-H "Authorization: Bearer mol_pk_live_internal..." \
-d '{"slug":"autotest1","name":"Auto Test 1"}'
Implementation order
- Migration:
partner_api_keystable - Middleware: Partner key validation in
auth.Middleware - Admin endpoints: Create, list, revoke keys
- Scope enforcement: Per-handler scope checks
- Rate limiter: Per-key rate limiting
- Pre-commit hook: Add
mol_pk_to secret scanner - Docs: API reference + partner onboarding guide
Files to change
| File | Change |
|---|---|
internal/migrations/0XX_partner_api_keys.up.sql |
New table |
internal/auth/middleware.go |
Add partner key check before session |
internal/auth/partner_keys.go |
Key validation, hashing, scope check |
internal/handlers/partner_keys.go |
Admin CRUD endpoints |
internal/router/router.go |
Wire new endpoints |
docs/runbooks/saas-secrets.md |
Document key management |
.githooks/pre-commit |
Add mol_pk_ to secret scanner |