WorkOS Auth Implementation Plan
Status: DRAFT — blocked on WorkOS sales call. Phase 0 cannot start until questions 1, 2, and 4 from ADR-017's open questions are answered (exact SSO+AuthKit billing, startup program terms, M2M/API Keys pricing). Target: schedule 15-minute call with WorkOS sales.
Target state reference: 13 — Authentication and User Management
Supersedes: 2026-03-01 User Authentication Plan — moved to archive.
Context
Replace sv0-platform's placeholder auth (header-based tenant, API key, REQUIRE_AUTH=false bypass) with a WorkOS-backed B2B multi-tenant authentication system. The new system uses WorkOS AuthKit for the login experience, WorkOS Organizations as the tenant primitive, and a thin local mirror of users/memberships for fast middleware and cross-tenant super-admin queries.
Two motivating problems this plan solves:
- We cannot onboard an enterprise customer today. No SAML, no SCIM, no per-tenant isolation story, no Admin Portal. This is a sales blocker.
- Our team cannot efficiently work across tenants today. Tenant switching requires editing
localStorage. Shareable links do not work because tenant context is not in the URL. Demo provisioning is a manual multi-step process.
The plan is phased so each phase is independently shippable and leaves the system in a working state.
Prerequisites (must be done before Phase 1)
- WorkOS account created with two environments:
staging— used bydev.securityv0.comand localngroktestingproduction— used byapp.securityv0.com
securityv0-internalOrganization created in both environments, with domain restriction@securityv0.com.- Google Workspace OAuth enabled on the
securityv0-internalOrganization so staff can log in with Google (free; no SSO connection fee). - Webhook signing secret obtained from WorkOS and stored as
WORKOS_WEBHOOK_SECRETin 1Password. - API keys (
WORKOS_API_KEY,WORKOS_CLIENT_ID) obtained for both environments and stored in 1Password. - WorkOS API Keys product enabled in both environments. This is required so staff can generate personal API keys in §1.8.a without a Phase 6 dependency. Configure prefix (
sv0_live_for prod,sv0_test_for staging) and default expiry policy. Scope registry can be minimal at this point — it will be expanded in Phase 6 for customer use. - Per-service M2M Applications created in the
securityv0-internalOrganization, one per service, with one credential pair per env. Initial set:github-actions-ci-{staging,production},workos-reconcile-{staging,production},deploy-runner-{staging,production}. Do not create a sharedsecurityv0-claude-codeM2M Application — staff bots use personal API keys per §1.8.a. Each credential pair goes in 1Password undersecurityv0 → m2m → <service-name> → <env>. - Pricing and startup program confirmed with WorkOS sales per the ADR-017 open questions. If the startup program applies, document the terms in 1Password.
- GitHub Issue created for this plan with label
implementationand assigned to @ivanfofanov.
Phase 0 — Prep and scaffolding
Goal: Set up the WorkOS account, env vars, and feature flag. No user-visible changes.
Tasks
- Create WorkOS account and
securityv0-internalOrganization per prerequisites above. - Add env var shape to
.env.example:# WorkOS
WORKOS_API_KEY=sk_...
WORKOS_CLIENT_ID=client_...
WORKOS_WEBHOOK_SECRET=wh_...
WORKOS_REDIRECT_URI=https://dev.securityv0.com/auth/callback
WORKOS_COOKIE_PASSWORD=<at-least-32-char-random> # iron-session encryption key
# Auth provider selection — deployment-time choice
AUTH_PROVIDER=workos # "workos" | "oidc" | "dev"
# OIDC provider config (only when AUTH_PROVIDER=oidc, for partner-deployed installations)
# OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant}/v2.0
# OIDC_CLIENT_ID=...
# OIDC_CLIENT_SECRET=... - Add
AUTH_PROVIDERtosrc/shared/config/env.tswith validation. - Install dependencies:
npm install @workos-inc/node iron-session
cd ui && npm install @workos-inc/authkit-react - Document the feature flag in
sv0-documentation/docs/runbooks/.
Verification
npm run typecheckandnpm run lintpass.npm run devstill runs withAUTH_PROVIDER=dev— no behavior change.- Env validation rejects WorkOS path if keys are missing when
AUTH_PROVIDER=workos.
Files touched
sv0-platform/.env.examplesv0-platform/src/shared/config/env.tssv0-platform/package.json,sv0-platform/ui/package.jsonsv0-documentation/docs/runbooks/(new entry)
Phase 1 — Backend foundation (data model + middleware)
Goal: Introduce the AuthProvider interface, the WorkOS implementation, the new collections, the middleware, and the webhook receiver. The AUTH_PROVIDER env var selects the active provider at startup; dev is the default for local development.
1.1 New domain types
Create sv0-platform/src/domain/tenants/types.ts:
export interface TenantDoc {
_id: ObjectId;
slug: string;
display_name: string;
provider_org_id: string;
status: "evaluation" | "active" | "churned" | "internal";
sso_enforced: boolean;
verified_domains: string[]; // e.g. ["acme.com"] — for domain-match auto-join on social login
created_at: Date;
archived_at: Date | null;
}
export interface TenantConfigDoc {
_id: ObjectId;
tenant_id: ObjectId;
jira_base_url?: string;
jira_project_key?: string;
branding?: { logo_url?: string; primary_color?: string; display_name_override?: string };
feature_flags?: Record<string, boolean>;
connector_credential_refs?: Record<string, string>;
updated_at: Date;
updated_by_user_id: ObjectId;
}
Create sv0-platform/src/domain/users/types.ts:
export interface UserDoc {
_id: ObjectId;
provider_user_id: string;
email: string;
display_name: string;
is_super_admin: boolean;
created_at: Date;
updated_at: Date;
last_seen_at: Date | null;
}
export interface MembershipDoc {
_id: ObjectId;
user_id: ObjectId;
tenant_id: ObjectId;
role: "owner" | "admin" | "member";
created_at: Date;
updated_at: Date;
}
1.2 Storage adapter extensions
Add to sv0-platform/src/storage/mongo/collections.ts:
{
name: "tenants",
indexes: [
{ key: { slug: 1 }, unique: true, name: "slug_unique" },
{ key: { provider_org_id: 1 }, unique: true, name: "provider_org_id_unique" },
{ key: { status: 1 }, name: "status_lookup" },
],
},
{
name: "users",
indexes: [
{ key: { provider_user_id: 1 }, unique: true, name: "provider_user_id_unique" },
{ key: { email: 1 }, unique: true, name: "email_unique" },
{ key: { is_super_admin: 1 }, name: "super_admin_lookup" },
],
},
{
name: "memberships",
indexes: [
{ key: { user_id: 1, tenant_id: 1 }, unique: true, name: "user_tenant_unique" },
{ key: { tenant_id: 1, role: 1 }, name: "tenant_role_lookup" },
{ key: { user_id: 1 }, name: "user_memberships" },
],
},
{
name: "tenant_configs",
indexes: [
{ key: { tenant_id: 1 }, unique: true, name: "tenant_config_unique" },
],
},
{
name: "webhook_events", // idempotency tracking
indexes: [
{ key: { event_id: 1 }, unique: true, name: "event_id_unique" },
{ key: { received_at: 1 }, expireAfterSeconds: 2592000, name: "ttl_30_days" },
],
},
Add repository methods to sv0-platform/src/storage/storage-adapter.ts:
findTenantBySlug(slug),findTenantByProviderOrgId(id),createTenant(doc),updateTenant(id, patch),listAllTenants().findUserByProviderUserId(id),findUserByEmail(email),upsertUser(doc).findMembership(userId, tenantId),listMembershipsByUser(userId),listMembershipsByTenant(tenantId),upsertMembership(doc),deleteMembership(userId, tenantId).getTenantConfig(tenantId),upsertTenantConfig(tenantId, patch, updatedByUserId).recordWebhookEvent(eventId)— returnstrueif newly recorded,falseif duplicate (for idempotency).
1.3 Auth provider interface and implementations
Create sv0-platform/src/api/auth/auth-provider.ts:
- Defines the
AuthProviderinterface per §2.1 of the architecture doc. - Defines the
AuthCallbackResult,VerifiedSession,VerifiedApiKey,VerifiedM2MToken,AuthWebhookEventtypes. - This is the only file the middleware imports. Provider-specific SDKs are never imported outside their implementation.
Create sv0-platform/src/api/auth/providers/workos-provider.ts (implements AuthProvider):
- Wraps
@workos-inc/nodewith env-validated config. - Implements all interface methods:
getLoginUrl(redirect to AuthKit),handleCallback(exchange code),verifySession(decrypt iron-session cookie + load from mirror),verifyApiKey(WorkOS API Keys endpoint with cache),verifyM2MToken(JWKS verification),generateAdminPortalLink,listActiveConnections,verifyWebhook. - Also exposes WorkOS-specific admin methods not on the interface:
createInvitation(email, orgId),createOrganization(name, domains),getUser(id),getOrganization(id),listMemberships(orgId). These are accessed via a type-narrowing check (if (provider instanceof WorkOSAuthProvider)) in provisioning scripts and admin routes only.
Create sv0-platform/src/api/auth/providers/oidc-provider.ts (implements AuthProvider):
- Wraps
openid-clientfor direct OIDC against any compliant IdP (Okta, Entra ID, PingFederate, Keycloak). - Configured via
OIDC_ISSUER_URL,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET. - Implements:
getLoginUrl(OIDC authorization URL),handleCallback(code exchange via token endpoint),verifySession(JWT verification against IdP JWKS),logout(RP-initiated logout if supported). verifyApiKey,verifyM2MToken,generateAdminPortalLink,verifyWebhookreturn null / throwUnsupportedError— these features are WorkOS-specific.listActiveConnectionsreturns[](SSO enforcement is handled by the IdP itself in this model — the tenant has exactly one IdP, always enforced).- Phase 1 scope: stub implementation with the interface defined. Full implementation deferred until the first partner-deployed installation is real. The interface is the load-bearing deliverable; the OIDC implementation is a fast-follow.
Create sv0-platform/src/api/auth/providers/dev-provider.ts (implements AuthProvider):
- Replaces the current
dev-bootstrap.tsapproach. init()seeds the internal tenant, dev user, and demo tenant (same as current dev-bootstrap).verifySession()always returns a valid session for the seeded dev user.getLoginUrl()/handleCallback()are no-ops that auto-authenticate the dev user.- No external dependencies.
Create sv0-platform/src/api/auth/provider-factory.ts:
- Reads
AUTH_PROVIDERenv var. - Returns the appropriate
AuthProviderimplementation. - Called once at server startup; the returned instance is injected into the middleware pipeline via Express
app.localsor a module-level singleton.
// src/api/auth/provider-factory.ts
export function createAuthProvider(): AuthProvider {
switch (process.env.AUTH_PROVIDER) {
case "workos": return new WorkOSAuthProvider();
case "oidc": return new OIDCAuthProvider();
case "dev": return new DevAuthProvider();
default: throw new Error(`Unknown AUTH_PROVIDER: ${process.env.AUTH_PROVIDER}`);
}
}
1.4 WorkOS client (admin operations)
The WorkOSAuthProvider also serves as the entry point for WorkOS-specific admin operations that don't fit the generic AuthProvider interface:
Create sv0-platform/src/api/auth/session.ts:
iron-sessionwrapper for encrypted session cookies.- Session payload (provider-agnostic — populated from
AuthCallbackResult):{
provider_user_id: string; // From AuthCallbackResult.providerUserId
auth_method: "sso" | "password" | "magic_link" | "oauth_google" | "oauth_microsoft"
| "oauth_github" | "passkey" | "impersonation";
auth_connection_id: string | null; // SSO connection ID for enforcement; null for non-SSO
provider_session_id: string; // For logout/revocation via authProvider.logout()
expires_at: number;
} auth_methodandauth_connection_idare read from theAuthCallbackResultat callback time and persisted in the cookie so downstream SSO enforcement can check them without another provider round-trip.- Helpers:
createSession(res, result: AuthCallbackResult),destroySession(res),readSession(req).
Create sv0-platform/src/api/auth/permissions.ts:
Permissionenum andROLE_PERMISSIONSmap per §7.1 of the architecture doc (tenant-scoped roles:owner,admin,member— note: nosuper_adminentry; super-admin is not a tenant role).INTERNAL_PERMISSIONSmap per §7.2 of the architecture doc (internal-org roles:owner,admin,member).membergrants read-only across all tenants; write capabilities require internaladminorowner.resolvePermissions(membership, user)— returns the union ofROLE_PERMISSIONS[membership.role]and (ifuser.is_super_admin)INTERNAL_PERMISSIONS[user.internal_role]. This is the only way permissions are computed; callers never look up either map directly.hasPermission(authContext, permission): booleanhelper that delegates to the resolved permission set onauthContext.membership.permissions.
Create sv0-platform/src/api/middleware/auth-middleware.ts (provider-agnostic):
- Takes an
AuthProviderinstance as a constructor/factory parameter. sessionMiddleware— callsauthProvider.verifySession()(orverifyApiKey()/verifyM2MToken()for bearer tokens), loads user from local mirror, attaches toreq.sessionUser.tenantMiddleware— extracts:tenantSlugfrom URL, loads tenant, rejects mismatched slug.membershipMiddleware— checks super-admin or membership row, computes role, permissions. Includes domain-match auto-join for social login users (see below).permissionMiddleware(permission)— route-level gate.- Composed into
req.authContextas described in §5 of the architecture doc. - This file never imports
@workos-inc/nodeor any provider SDK — it only callsAuthProviderinterface methods.
Domain-match auto-join (inside membershipMiddleware):
When a user authenticates via social login (Google/Microsoft/GitHub OAuth) or magic link and has no existing membership in the current tenant, check if the user's email domain matches one of the tenant's verified_domains. If yes, auto-create a member-role membership row. This gives social-login clients the "just sign in and you're in" experience without a paid SSO connection. ~20 lines of code:
// Inside membershipMiddleware, after checking for super-admin and direct membership:
if (!membership && tenant.verified_domains.length > 0) {
const emailDomain = user.email.split("@")[1];
if (tenant.verified_domains.includes(emailDomain)) {
membership = await storage.upsertMembership({
user_id: user._id,
tenant_id: tenant._id,
role: "member",
created_at: new Date(),
updated_at: new Date(),
});
}
}
This replaces the need for WorkOS SAML SSO for clients whose IT team doesn't mandate IdP-level control. See auth method tiering analysis for the business rationale.
Domain verification admin UI: Phase 3 adds a "Verify domain" button to AdminTenantDetailPage that either (a) generates a DNS TXT record for the client to add, or (b) lets a super-admin manually mark a domain as verified. For Phase 1, domains are set directly in the tenants collection by the super-admin.
Modify sv0-platform/src/api/middleware/auth.ts:
- Replace the old REQUIRE_AUTH branching with a single call to
createAuthProvider()(fromprovider-factory.ts). - Pass the provider instance to the
auth-middleware.tsfactory. All downstream middleware calls go through theAuthProviderinterface.
1.4 Webhook receiver
Create sv0-platform/src/api/routes/webhooks/workos.ts:
POST /api/v1/webhooks/workos.- Verifies
WorkOS-Signatureheader with HMAC againstWORKOS_WEBHOOK_SECRET. - Checks idempotency via
recordWebhookEvent(event_id). - Routes to handler per event type (table in §11.1 of the architecture doc).
- Handlers are pure upserts/deletes on the local mirror; no external side effects.
- Recomputes
users.is_super_adminwhenever an internal-org membership changes.
Unit tests: test/api/webhooks/workos.test.ts with fixtures for each event type.
1.6 Dev provider (replaces dev-bootstrap)
The DevAuthProvider (created in §1.3) replaces the old dev-bootstrap.ts approach:
init()seeds the internal tenant, dev user, membership, and demo tenant — same data as before.verifySession()auto-mints a session fordev@securityv0.comif no cookie is present.- Selected automatically when
AUTH_PROVIDER=dev.
1.7 Initial auth routes
Create sv0-platform/src/api/routes/auth.ts:
GET /login— callsauthProvider.getLoginUrl({ returnTo })and redirects. For WorkOS this goes to AuthKit; for OIDC this goes to the IdP's authorization endpoint; for dev this auto-authenticates.GET /auth/callback— callsauthProvider.handleCallback(code), upserts user/membership from theAuthCallbackResult, mints session, redirects toreturn_to.POST /auth/logout— callsauthProvider.logout(providerSessionId), destroys session cookie, redirects to/login.GET /api/v1/auth/me— returns currentauthContext(used by frontend).
Register these routes in sv0-platform/src/api/app.ts before the auth middleware pipeline (they are public).
1.8 Programmatic access for the SecurityV0 team — personal API keys + per-service M2M
Day-1 requirement: Claude Code sessions, CI/CD pipelines, and internal workers must be able to call dev/prod APIs once the legacy X-API-Key path is removed. Per ADR-017 §"SecurityV0 team bot access" (as amended by the Codex review), we deliberately do not ship a single shared securityv0-claude-code M2M credential. The two problems with that pattern are:
- Non-attributable. Every call logs the same
client_id, so we cannot tell whose Claude session ranPOST /api/v1/scans. - Single-secret blast radius. Leaking it compromises every staff bot at once; rotation requires coordinated updates across every engineer's laptop.
Instead, Phase 1 ships two distinct credential types, both resolved by the same middleware pipeline:
1.8.a Personal API keys (human-attributable staff automation)
- Who uses them: Individual engineers' Claude Code sessions, local dev scripts, ad-hoc curl calls.
- How they are issued: Each staff member signs into AuthKit, opens Settings → API Keys, and generates a key via the WorkOS API Keys widget (the same widget Phase 6 ships to customers — staff are just the first users of it). The key is bound to that staff user's WorkOS user record.
- Authorization: When a request arrives with
Authorization: Bearer sv0_...,apiKeyMiddlewareverifies the key with WorkOS, loads the owning user from the local mirror, and builds the exact sameauthContextthat an interactive session for that user would produce — including the union of their tenant membership permissions and (if they are insecurityv0-internal) theirINTERNAL_PERMISSIONS[internal_role]derived per §6.3 of the architecture doc. - Revocation: Deleting the staff user (or the key itself) invalidates every session and bot that user owns. No shared secret to rotate.
- Phase 1 scope: Because Phase 6 ships the widget to customers later, Phase 1 delivers the backend
apiKeyMiddlewareand a minimal dashboard-only path for staff to create keys via WorkOS's hosted widget; the customer-facing Settings tab is Phase 6.
1.8.b Per-service M2M Applications (shared-service automation)
- Who uses them: Long-running services that are not owned by a single human — CI/CD runners (
github-actions-ci), reconciliation workers (workos-reconcile), deploy runners (deploy-runner), scheduled seed refreshers. Each shared service gets its own M2M Application. - WorkOS setup (done during Phase 0 per service, not a single shared one):
- In the
securityv0-internalOrganization, create one M2M Application per service:github-actions-ci-staging,github-actions-ci-production,workos-reconcile-staging,workos-reconcile-production, etc. - Store each credential pair separately in 1Password under
securityv0 → m2m → <service-name> → <env>. - Every M2M Application has a declared internal role recorded in config (see below). That role is typically
member(read-only) and is neverowner. A service that needs write access is reviewed individually and may be grantedadminwith a documented justification.
- In the
- Declared role config. Create
sv0-platform/src/api/auth/m2m-applications.ts:// Static registry. client_id → declared internal role.
// Reviewed in PR; mismatches between this file and the WorkOS dashboard are flagged
// by the reconciliation worker in Phase 5.4.
export const M2M_APPLICATIONS: Record<string, { name: string; internal_role: "member" | "admin" }> = {
[process.env.M2M_GITHUB_ACTIONS_CI_CLIENT_ID!]: { name: "github-actions-ci", internal_role: "member" },
[process.env.M2M_WORKOS_RECONCILE_CLIENT_ID!]: { name: "workos-reconcile", internal_role: "member" },
[process.env.M2M_DEPLOY_RUNNER_CLIENT_ID!]: { name: "deploy-runner", internal_role: "admin" },
// NEVER "owner". Adding an entry requires PR review.
}; - Backend M2M middleware — extend
workos-auth.ts:- Add
m2mMiddlewareto the auth source detection step. - Detects
Authorization: Bearer <jwt>where the token has 3 JWT segments. - Verifies the JWT against the WorkOS JWKS endpoint (cached, 1-hour refresh).
- Extracts
client_idandorg_idclaims. - Looks up the
client_idinM2M_APPLICATIONS. If not registered, reject with 401 — no unregistered M2M Application may call the platform. - Confirms the
org_idclaim resolves to the localsecurityv0-internaltenant (cross-tenant M2M is not supported in Phase 1; customer-owned M2M would be Phase 6+). - Builds an
authContextwith a synthetic service user:{
user: {
id: <synthetic, stable per client_id>,
provider_user_id: `m2m:${client_id}`,
email: `m2m:${app.name}@securityv0.internal`,
display_name: `M2M: ${app.name}`,
is_super_admin: true, // via internal-org membership
internal_role: app.internal_role,
},
tenant: <resolved from URL path, not from the token>,
membership: {
source: "super_admin_derived",
role: null, // no direct tenant membership
permissions: resolvePermissions(
{ role: null, source: "super_admin_derived" }, // no tenant role
{ is_super_admin: true, internal_role: app.internal_role },
),
},
session: {
method: "m2m",
auth_connection_id: null,
expires_at: token.exp * 1000,
},
} - Crucially: the permission set is derived from
INTERNAL_PERMISSIONS[app.internal_role]only — there is noALL_SUPERADMIN_PERMISSIONSconstant and no short-circuit that grants blanket access. Amember-role M2M Application that tries a destructive write returns 403, same as amember-role human.
- Add
Client helper — create sv0-platform/scripts/lib/api-client.ts:
- Reads
SV0_M2M_CLIENT_ID,SV0_M2M_CLIENT_SECRET,SV0_API_BASE_URLfrom env. - Exchanges credentials for a short-lived JWT at the WorkOS token endpoint.
- Caches the JWT until 60 seconds before expiry, then refreshes.
- Exposes a
fetchSv0(path, opts)function that wrapsfetchwith the bearer token. - Each consuming service sets its own pair of env vars (e.g., GitHub Actions workflow sets
SV0_M2M_CLIENT_ID=${{ secrets.M2M_GITHUB_ACTIONS_CI_CLIENT_ID }}), so no credential is shared across services.
Staff runbook — document in docs/runbooks/staff-programmatic-access.md:
- When to use a personal API key (interactive staff work, Claude Code, one-off scripts) vs a per-service M2M Application (long-running services, CI, scheduled jobs).
- How a staff member generates a personal API key via Settings → API Keys.
- How to register a new per-service M2M Application (requires a PR against
m2m-applications.ts+ WorkOS dashboard provisioning + 1Password entry). - Credential rotation procedures for each type.
- Explicit policy: never distribute a personal API key or an M2M credential across multiple services or engineers.
1.9 Extended auth middleware: three sources
The auth middleware pipeline now supports three authentication sources, all resolving to the same AuthContext:
Cookie: sv0_session=...→ interactive user (Phase 1 §1.3 path)Authorization: Bearer ey...(JWT, 3 segments) → per-service M2M Application (§1.8.b above)Authorization: Bearer sv0_...(prefixed key) → WorkOS API Key — personal (staff, §1.8.a) or customer (Phase 6)
Phase 1 ships all three. For Phase 1, path (3) resolves to personal staff API keys only; Phase 6 adds the customer-facing widget embed and scope-gated permission intersection on top of the same middleware.
The pipeline:
auth-source-detection middleware
├─ cookie → sessionMiddleware
├─ JWT bearer → m2mMiddleware
├─ prefixed-key bearer → apiKeyMiddleware (Phase 6)
└─ none → 401 / redirect to /login
│
▼
tenantMiddleware → membershipMiddleware → permissionMiddleware → handler
All three paths land on the same route handlers. Route-level permission checks are identical regardless of auth source.
1.10 Integration tests
test/integration/auth/workos-login-flow.test.ts: mock WorkOS, exercise the full login → session → tenant route → logout flow.test/integration/auth/webhook-sync.test.ts: replay webhook fixtures, verify mirror state.test/integration/auth/tenant-isolation.test.ts: verify a non-member cannot access another tenant; verify super-admin can.test/integration/auth/dev-bootstrap.test.ts: verify dev bootstrap mints user and session correctly.test/integration/auth/m2m-flow.test.ts: mock WorkOS JWKS, issue a synthetic M2M JWT for the internal org, verify super-adminauthContextis produced; same for a regular tenant org.test/integration/auth/auth-source-detection.test.ts: verify the middleware correctly routes between cookie, JWT bearer, and unknown bearer.
Verification
npm run cipasses.npm run test:integrationpasses (requires MongoDB).- With
AUTH_PROVIDER=workosin staging: log in via AuthKit, land on root, see the seededsecurityv0-internaltenant. - Webhooks from WorkOS staging reach the endpoint and produce mirror rows.
- With
AUTH_PROVIDER=dev: existing behavior unchanged, all existing tests still pass. - Provider abstraction smoke test: swap
AUTH_PROVIDER=devtoAUTH_PROVIDER=workosand back; verify the middleware pipeline works identically for both. No provider-specific imports in middleware or route files. - Per-service M2M end-to-end: using the staging
github-actions-cicredentials, a curl call withAuthorization: Bearer <jwt>(obtained from WorkOS token endpoint) returns read-only data from any tenant, but a destructive write (e.g.DELETE /api/v1/findings/:id) returns 403 — confirming that the declaredinternal_role: "member"is enforced and that noALL_SUPERADMIN_PERMISSIONSshortcut exists. - Per-service M2M attribution: audit logs for the curl calls above show
client_id = github-actions-ci-staging, not a generic "securityv0-claude-code". - Unregistered M2M rejection: a valid JWT whose
client_idis not inm2m-applications.tsreturns 401. - Personal API key dry run: a staff member logs in, generates a personal API key via the WorkOS hosted widget, uses it in curl against
/api/v1/admin/tenants, gets the tenant list (because they are insecurityv0-internal), and then revoking the key invalidates subsequent calls within 60 seconds. - Claude Code dry run: a staff member puts their own personal API key in
~/.claude/config.json, Claude Code commands successfully call the staging API, and audit logs attribute the calls to that individual user — not a shared bot identity.
Files touched
New:
sv0-platform/src/domain/tenants/types.tssv0-platform/src/domain/users/types.tssv0-platform/src/api/auth/auth-provider.ts(interface + shared types)sv0-platform/src/api/auth/provider-factory.ts(readsAUTH_PROVIDER, returns implementation)sv0-platform/src/api/auth/providers/workos-provider.ts(WorkOS implementation)sv0-platform/src/api/auth/providers/oidc-provider.ts(stub OIDC implementation)sv0-platform/src/api/auth/providers/dev-provider.ts(local dev implementation)sv0-platform/src/api/auth/session.tssv0-platform/src/api/auth/permissions.tssv0-platform/src/api/middleware/auth-middleware.ts(provider-agnostic middleware pipeline)sv0-platform/src/api/routes/auth.tssv0-platform/src/api/routes/webhooks/workos.tssv0-platform/src/api/auth/m2m-applications.ts(static registry of per-service M2M Applications → internal role)sv0-platform/scripts/lib/api-client.ts(generic bearer-token client helper used by per-service M2M callers)sv0-documentation/docs/runbooks/staff-programmatic-access.mdsv0-platform/test/api/webhooks/workos.test.tssv0-platform/test/integration/auth/*.test.ts
Modified:
sv0-platform/src/storage/mongo/collections.tssv0-platform/src/storage/storage-adapter.tssv0-platform/src/api/middleware/auth.ts(delegates toauth-middleware.ts+provider-factory.ts)sv0-platform/src/api/app.ts
Phase 2 — Frontend foundation (routing + login + tenant switcher)
Goal: UI consumes the new auth system. /t/:tenantSlug/... routing, WorkOS login flow, tenant switcher dropdown in the header.
2.1 Router refactor
Update sv0-platform/ui/src/App.tsx:
- Wrap all existing tenant-scoped routes under a
/t/:tenantSlugparent route. - Add new public routes:
/login,/auth/callback(handled by a small component that hits the backend),/logout. - Add root route
/that resolves to the user's default tenant (first membership) and redirects. - Add super-admin routes:
/admin/tenants,/admin/tenants/:slug,/admin/tenants/:slug/config.
2.2 New TenantContext (replaces existing auth-context)
Create sv0-platform/ui/src/context/tenant-context.tsx:
- Reads
:tenantSlugfromuseParams(). - Fetches
/api/v1/auth/meonce on mount, caches current user and memberships via TanStack Query. - Exposes:
{ currentUser, currentTenant, availableTenants, isSuperAdmin, switchTenant(slug) }.
Delete sv0-platform/ui/src/context/auth-context.tsx and remove all its consumers. Replace with tenant-context. This is the biggest frontend diff in the plan.
2.3 API client refactor
Update sv0-platform/ui/src/api/client.ts:
- Remove
x-tenant-idandx-api-keyheader injection. - Add
credentials: "include"to all fetches (cookies carry the session). - On 401, redirect to
/login?return_to=<current URL>.
2.4 Tenant switcher component
Create sv0-platform/ui/src/components/TenantSwitcher.tsx:
- Header dropdown.
- Lists
availableTenantsfrom context. - For super-admins, additionally calls
/api/v1/admin/tenantsto get the full tenant list and renders them in a sectioned dropdown: "My tenants" / "All tenants (super-admin)". - Selecting a tenant navigates to
/t/:slug/(the currently-active page on a different tenant, if it makes sense — otherwise the tenant's dashboard).
Place in the global header (AppHeader.tsx or equivalent).
2.5 Login page
Create sv0-platform/ui/src/pages/LoginPage.tsx:
- Minimal page with a "Sign in" button that hits
GET /login?return_to=.... - Displays any auth error passed back as a query param.
2.6 Remove the old Settings auth form
Update sv0-platform/ui/src/pages/SettingsPage.tsx:
- Remove the tenant ID and API key fields.
- Settings page becomes: display current user info, logout button, (future: per-tenant config — deferred to Phase 3).
2.7 Super-admin UI (minimal)
Create sv0-platform/ui/src/pages/admin/AdminTenantsPage.tsx:
- Table of all tenants (for super-admins only) with columns: slug, display name, status, created at, SSO enforced.
- Each row links to
/t/:slug/(to "enter" the tenant) and/admin/tenants/:slug(for management).
Create sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx:
- Detail view of one tenant.
- Buttons: "Open as super-admin", "Generate Admin Portal link" (deferred to Phase 4), "Edit config" (deferred to Phase 3), "Convert to paid" (deferred to Phase 4).
2.8 Backend routes for admin UI
Create sv0-platform/src/api/routes/admin.ts:
GET /api/v1/admin/tenants— requiresINTERNAL_LIST_ALL_TENANTS; returns full tenant list.GET /api/v1/admin/tenants/:slug— requiresINTERNAL_LIST_ALL_TENANTS; returns tenant + config.
Verification
- Log in via staging AuthKit with a
@securityv0.comemail; land on the root, see the tenant switcher populated withsecurityv0-internal+demo-acme. - Navigate to
/t/demo-acme/clusters; see the tenant's data. - Paste
/t/demo-acme/findings/<id>in a new tab; see the same data after (re)login. - Log in as a non-super-admin test user that is a member of only
demo-acme; see only that tenant in the dropdown; attempt to navigate to/t/securityv0-internal/and receive 404. - All existing UI tests pass (with routes updated).
cd ui && npm run cipasses.
Files touched
New:
sv0-platform/ui/src/context/tenant-context.tsxsv0-platform/ui/src/components/TenantSwitcher.tsxsv0-platform/ui/src/pages/LoginPage.tsxsv0-platform/ui/src/pages/admin/AdminTenantsPage.tsxsv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsxsv0-platform/src/api/routes/admin.ts
Modified:
sv0-platform/ui/src/App.tsxsv0-platform/ui/src/api/client.tssv0-platform/ui/src/pages/SettingsPage.tsxsv0-platform/ui/src/components/AppHeader.tsx(or equivalent)
Deleted:
sv0-platform/ui/src/context/auth-context.tsx
Phase 3 — Evaluation flow tooling and per-tenant config
Goal: Make it trivial for a super-admin to provision a new evaluation tenant and edit per-tenant config. This is the phase that unblocks sales demos.
3.1 Provisioning script
Create sv0-platform/scripts/provision-eval-tenant.ts:
- CLI with flags:
--name "Acme Corp",--domain acme.com,--invite ciso@acme.com,--seed,--env dev|prod. - Performs:
- Create WorkOS Organization via
createOrganization. - Insert
tenantsrow (status: "evaluation", generated slug from name). - Insert empty
tenant_configsrow. - If
--seed, run the existingseed-demo-w1.tspattern against this new tenant. - Create WorkOS invitation for the email.
- Print the tenant slug,
/t/:slug/URL, and invitation status for copy-paste into Slack.
- Create WorkOS Organization via
- Writes:
docs/runbooks/provision-eval-tenant.mdwith usage examples.
3.2 Tenant config editor (super-admin only)
Extend AdminTenantDetailPage.tsx:
- Form for
tenant_configsfields: Jira base URL, Jira project key, feature flags (JSON editor), connector credential refs (JSON editor). - Save button hits
PATCH /api/v1/tenants/:slug/config.
Create sv0-platform/src/api/routes/tenants.ts:
PATCH /api/v1/tenants/:slug/config— gated byTENANT_WRITE_CONFIG.
3.3 Jira integration hookup
Update the remediation service (sv0-platform/src/services/remediation.ts or wherever Jira deep links are built) to read tenant_configs.jira_base_url and jira_project_key instead of hardcoded or env-var config. This is the first consumer of tenant_configs and validates the pattern.
Verification
- Run
npx tsx scripts/provision-eval-tenant.ts --name "Test Co" --invite test@example.com --seedon staging. - Check WorkOS dashboard: new Organization visible.
- Check sv0: new tenant visible in
/admin/tenants, with seeded data at/t/test-co/. - Edit Jira config on the admin page, save, verify a Jira deep link in the remediation UI uses the configured base URL.
- Integration test
test/integration/scripts/provision-eval-tenant.test.ts(against a WorkOS staging sandbox).
Files touched
New:
sv0-platform/scripts/provision-eval-tenant.tssv0-platform/src/api/routes/tenants.tssv0-documentation/docs/runbooks/provision-eval-tenant.md
Modified:
sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsxsv0-platform/src/services/remediation.ts(or equivalent)
Phase 4 — Enterprise SSO on-ramp
Goal: Convert an evaluation tenant to a paid enterprise customer with self-serve SAML onboarding.
4.1 Admin Portal link generation
Extend AdminTenantDetailPage.tsx:
- "Generate Admin Portal link" button.
- Calls
POST /api/v1/admin/tenants/:slug/admin-portal-link. - Response is a one-time WorkOS URL with expiry.
- UI shows the link and a copy-paste email template: "Forward this to your IT admin. They will configure SAML for your Okta (or equivalent) in about 5 minutes. The link expires in 24 hours."
Add POST /api/v1/admin/tenants/:slug/admin-portal-link to admin.ts:
- Requires
TENANT_GENERATE_ADMIN_PORTAL_LINK. - Calls
workosClient.generateAdminPortalLink(provider_org_id, "sso"). - Returns
{ link, expires_at }.
4.2 Convert-to-paid action
Extend AdminTenantDetailPage.tsx:
- "Convert to paid" button (visible only when
status: "evaluation"). - Updates
tenants.status = "active"viaPATCH /api/v1/admin/tenants/:slug.
Add the route and permission.
4.3 SSO enforcement — per-tenant, per-connection
The naive "if sso_enforced and session.method !== 'sso' then reject" check is not sufficient. A staff member who authenticated via SSO into a different customer tenant yesterday would still be holding a session with auth_method = "sso", and the naive check would let them reach the enforced tenant without going through the enforced tenant's IdP. Enforcement must be tied to the specific WorkOS connection.id for the current tenant.
Per §4.4 of the architecture doc, the enforcement rule is:
if authContext.tenant.sso_enforced AND NOT authContext.user.is_super_admin:
allowed_connection_ids := workos.listActiveConnections(authContext.tenant.provider_org_id)
if authContext.session.auth_method != "sso"
OR authContext.session.auth_connection_id NOT IN allowed_connection_ids:
redirect to /login?return_to=<url>&reason=sso_required&org=<tenant.slug>
Super-admins are exempt because they do not hold a direct membership in enforced customer tenants — their access is derived from securityv0-internal membership — and they already authenticated via the internal org's Google Workspace connection.
Webhook handlers
Extend connection.activated:
- Look up the tenant by
provider_org_id. - Set
tenants.sso_enforced = true. - No need to store the connection IDs locally — middleware resolves them live from WorkOS (with a short TTL cache) so we never serve stale allow-lists.
Extend connection.deactivated:
- Look up the tenant by
provider_org_id. - Query WorkOS for remaining active connections on that Organization. If zero remain, set
tenants.sso_enforced = false(customer is back to non-enforced state). If any remain, leavesso_enforced = true.
Middleware implementation
Extend workos-auth.ts:
- Add
ssoEnforcementCheckto the middleware pipeline, aftertenantMiddlewareandmembershipMiddleware. - Skip the check if
authContext.user.is_super_administrue. - Otherwise, call
workosClient.listActiveConnections(authContext.tenant.provider_org_id)(cached 60s) and compare againstauthContext.session.auth_connection_id. - On mismatch, redirect to
/login?return_to=<url>&reason=sso_required&org=<tenant.slug>. - The
/loginhandler, seeingreason=sso_required&org=..., passes the org hint through to AuthKit so the user is routed into the correct IdP on the next attempt rather than being shown a generic picker.
Test coverage for §4.3
- Session with
auth_method: "password"→ blocked on enforced tenant. - Session with
auth_method: "sso"butauth_connection_idbelonging to a different tenant → blocked. - Session with
auth_method: "sso"andauth_connection_idin the enforced tenant's active connection list → allowed. - Super-admin with
auth_method: "oauth_google"(internal org) → allowed on any enforced tenant. - Per-service M2M (
auth_method: "m2m") → not rejected by SSO enforcement, because M2M Applications are not human users and cannot perform SSO. They can only access a customer tenant if a future phase grants them cross-tenant access (Phase 1 restricts M2M tosecurityv0-internal).
4.4 Staging dry run
- Configure a WorkOS staging SAML connection against a test IdP (Okta Developer Edition or WorkOS's test IdP).
- Convert a staging tenant, send yourself the Admin Portal link, complete SAML setup.
- Verify
connection.activatedwebhook fires andsso_enforcedflips. - Verify magic link is blocked for that tenant; SAML login works.
- Document the dry run in
docs/runbooks/customer-enterprise-onboarding.md.
Verification
- End-to-end dry run on staging: evaluation → Admin Portal link → SAML config →
sso_enforced=true→ magic link blocked → SAML login succeeds. - Integration test: webhook fixture for
connection.activatedupdatessso_enforced.
Files touched
New:
sv0-documentation/docs/runbooks/customer-enterprise-onboarding.md
Modified:
sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsxsv0-platform/src/api/routes/admin.tssv0-platform/src/api/routes/webhooks/workos.tssv0-platform/src/api/middleware/workos-auth.ts(session method enforcement)
Phase 5 — Housekeeping and legacy removal
Goal: Remove the legacy auth code (the old REQUIRE_AUTH path). SaaS deployments run AUTH_PROVIDER=workos; local dev uses AUTH_PROVIDER=dev; partner deployments use AUTH_PROVIDER=oidc. Migrate existing tenants to the new model.
5.1 Existing tenant backfill
Today, "tenants" exist only as string values denormalized on entity/finding/etc. documents. To make them first-class, we need a backfill:
Create sv0-platform/scripts/backfill-tenants.ts:
- Scans all collections for distinct
tenant_idvalues. - For each distinct value, creates a
tenantsrow withslug = <value>,status = "active",display_name = <prompted or derived>, and a corresponding WorkOS Organization in production. - Prints a report.
- Idempotent — safe to re-run.
Run it once against production after WorkOS prod is wired up, under manual supervision.
5.2 Migrate internal scripts from legacy keys to the Phase 1.7 credential split
Phase 1 already shipped apiKeyMiddleware (personal staff keys) and m2mMiddleware (per-service M2M Applications). In Phase 5 we migrate the remaining non-interactive internal clients onto whichever of the two is appropriate:
- Interactive developer scripts (ad-hoc curl, one-off
tsxscripts run from a laptop, Claude Code): use the running engineer's personal API key (§1.8.a). Document indocs/runbooks/staff-programmatic-access.mdhow to put the key in a local env var thatscripts/lib/api-client.tsreads. Personal keys are audit-attributable to the individual engineer. - Seed scripts (
scripts/seed-demo-w1.tsand friends) when run manually by an engineer: use that engineer's personal API key, same as any other interactive script. - Seed scripts when run from CI: use the
github-actions-ciper-service M2M Application credentials injected as GitHub Actions secrets. The same script supports both modes becauseapi-client.tsreads whichever ofSV0_M2M_CLIENT_IDorSV0_API_KEYis set. - Connector workers: mostly upstream writers, so typically a no-op. If any reach into the platform API, register a new per-service M2M Application (e.g.
connector-sync-worker) via a PR againstm2m-applications.ts. - Reconciliation worker (
workos-reconcile): uses its own per-service M2M Applicationworkos-reconcile-{staging,production}already provisioned in Phase 0. - CI/CD (GitHub Actions): every workflow that hits the API uses
github-actions-ci-{staging,production}, which is scoped to internal rolemember(read-only) by default. Any workflow that needs writes requires a PR to promote a separate M2M Application toadminwith justification — we do not loosen the blanket role. - Deploy runner: uses
deploy-runner-{staging,production}, which is grantedadminper its registry entry.
After this migration, the only legitimate X-API-Key usage is removed from the codebase. There is no shared bot credential under any name.
5.3 Remove legacy auth path
- Delete the
AUTH_BACKEND=legacybranch fromauth.ts. - Remove env vars:
REQUIRE_AUTH,ALLOWED_API_KEYS,JWT_JWKS_URI,JWT_ISSUER,JWT_AUDIENCE,JWT_ALGORITHMS,JWT_CLOCK_TOLERANCE_SEC. - Remove
X-API-KeyandX-Tenant-Idheader handling (except where they are explicitly the service-token mechanism). - Update
.env.exampleand all runbooks. - Delete the old
SettingsPagetenant/api-key form code if not already removed.
5.4 Reconciliation job
Create sv0-platform/src/workers/workos-reconcile.ts:
- Daily job.
- Fetches all Organizations from WorkOS, diffs against local
tenants, logs mismatches, auto-repairs obvious drift. - For each Organization, fetches memberships, diffs against local
memberships, logs/repairs. - Reports go to structured logs and a simple internal status endpoint.
5.5 Audit logging
Emit structured logs from the auth middleware and webhook handlers:
- Every request:
{ user_id, tenant_id, method, path, status, session_method, ts }. - Every webhook event:
{ event_type, event_id, mutation, ts }. - Every super-admin action (generate Admin Portal link, provision eval, edit tenant config):
{ actor_user_id, action, target_tenant_id, ts }.
Forward to existing log pipeline. Deferred: dedicated UI.
Verification
grep -r "X-API-Key\|REQUIRE_AUTH" sv0-platform/src sv0-platform/ui sv0-platform/scriptsreturns only service-token and documentation references.- All tests pass.
- Production deployment with
AUTH_BACKEND=workos, existing data intact, all users can log in. - Reconciliation job reports clean state on the first run.
Files touched
New:
sv0-platform/scripts/backfill-tenants.tssv0-platform/src/workers/workos-reconcile.ts
Modified / deleted: many across sv0-platform/src/api/, sv0-platform/src/shared/config/, sv0-platform/scripts/seed-*.ts. Delete the old X-API-Key handling code entirely.
Phase 6 — Customer-facing programmatic access (API Keys widget + MCP)
Goal: Give customer users the ability to generate their own API keys and to authorize MCP clients / AI agents to access their sv0 data with scoped, revocable delegation.
When to ship: After Phase 5. Not a pilot blocker, but important for the first enterprise customer who asks "how do my engineers script against your API?" or "can Claude read my findings?".
6.1 API Keys widget integration
WorkOS dashboard configuration:
- Enable the API Keys product in both staging and production WorkOS environments.
- Configure key naming, prefix (e.g.,
sv0_live_for prod,sv0_test_for staging), expiration policy defaults, and scope registry. - Define the scope registry to match sv0's
Permissionenum (e.g.,findings:read,findings:write,entities:read,evidence_packs:read).
Backend verification:
- Extend
sv0-platform/src/api/middleware/workos-auth.tswithapiKeyMiddleware:- Detects
Authorization: Bearer sv0_...(prefix match). - Calls WorkOS API Keys verification endpoint with the bearer token.
- Caches the result (user ID, org ID, scopes) for 60 seconds to avoid per-request latency.
- On cache miss or expiry, re-verifies.
- On revocation, cache expiry ensures effective revocation within 60 seconds.
- Detects
- Builds an
authContextwithsession.method = "api_key", the user from the local mirror, and permissions intersected from both the user's membership role AND the API key's declared scopes. - The intersection rule is important: an API key can never grant more access than the owning user has. If a
member-role user generates a key withfindings:deletescope, the scope is silently dropped because the user's role doesn't allow delete.
Frontend widget embed:
- Add a new Settings tab: "API Keys".
- Embed the WorkOS API Keys widget (per WorkOS React SDK docs).
- Document the feature in
docs/runbooks/api-keys-for-customers.md. - Add a "Create API Key" button; clicking it opens the widget.
Permission gating:
- Only tenant members with the
adminorownerrole (or super-admins) can create keys. Regularmemberusers see a read-only view of their own existing keys.
6.2 MCP / OAuth Applications support
WorkOS dashboard configuration:
- Enable AuthKit OAuth Applications in both environments.
- Enable Client ID Metadata Document (CIMD) support per WorkOS docs.
- Configure the consent screen: logo, app name, scope descriptions.
- Define the scope registry (same as API Keys §6.1).
Backend changes:
- Extend
sessionMiddlewareto also accept WorkOS OAuth Application access tokens. Since these are just AuthKit tokens scoped to a user, the existing session verification path already handles them — we only need to:- Detect the
scopeclaim in the token and enforce scope restrictions on each route. - Log the
client_idof the requesting OAuth Application for audit attribution.
- Detect the
Frontend "Authorized Applications" page:
- New Settings tab: "Authorized Apps".
- Calls a WorkOS API to list applications the user has authorized to access their account.
- Shows name, requested scopes, last used, and a Revoke button.
- Revoking triggers WorkOS to invalidate all tokens issued to that application for that user.
MCP metadata endpoint:
- Implement
/.well-known/oauth-authorization-serverper OAuth 2.0 Authorization Server Metadata spec (RFC 8414). WorkOS AuthKit is the actual AS; this endpoint just points MCP clients to the WorkOS discovery document. - Implement
/.well-known/openid-configurationsimilarly (for MCP clients that use OIDC discovery instead).
Dry run with a real MCP client:
- Set up a local Claude Desktop MCP server that connects to staging sv0.
- Walk through the OAuth consent flow.
- Verify the access token is issued and the client can make API calls on behalf of the user.
- Document the full flow in
docs/runbooks/mcp-integration.mdfor customers.
6.3 Documentation and customer enablement
docs/runbooks/api-keys-for-customers.md— how to generate, use, rotate, and revoke API keys.docs/runbooks/mcp-integration.md— how to connect Claude Desktop, Cursor, or any MCP-compatible client to sv0.docs/runbooks/programmatic-access-overview.md— decision tree for customers ("should I use an API key or an MCP integration?").- Example snippets in Python, TypeScript, and curl for both flows.
Verification
- Customer user logs in, opens Settings → API Keys, generates a key, copies it, uses it in a curl command, gets data back.
- Customer user revokes the key; within 60 seconds, the same curl returns 401.
- Customer user's
memberrole cannot create keys with write scopes — UI hides the option and backend rejects it. - A Claude Desktop MCP client completes the OAuth consent flow against staging sv0, obtains a token, makes API calls on behalf of the user, and the user can revoke the authorization from the "Authorized Apps" page.
- End-to-end CIMD test: an MCP client with only a metadata URL (no prior registration) successfully completes OAuth discovery and authorization.
Files touched
New:
sv0-platform/src/api/middleware/workos-auth.ts(extended withapiKeyMiddleware+ OAuth scope enforcement)sv0-platform/src/api/routes/well-known.ts(OAuth metadata endpoints)sv0-platform/ui/src/pages/settings/ApiKeysTab.tsxsv0-platform/ui/src/pages/settings/AuthorizedAppsTab.tsxsv0-documentation/docs/runbooks/api-keys-for-customers.mdsv0-documentation/docs/runbooks/mcp-integration.mdsv0-documentation/docs/runbooks/programmatic-access-overview.md
Modified:
sv0-platform/ui/src/pages/SettingsPage.tsx(add the new tabs)sv0-platform/src/api/middleware/workos-auth.tssv0-platform/src/api/auth/permissions.ts(map scopes ↔ permissions)
Open questions specific to Phase 6
- API Keys widget styling — can the widget be themed to match sv0 branding? If not, document it as a known cosmetic deviation.
- CIMD production-readiness — WorkOS CIMD is listed in the docs but we should verify it works end-to-end with at least two real MCP clients (Claude Desktop and one other) before publicizing MCP support.
- Scope granularity — how fine-grained should scopes be? Suggested starting set:
findings:read,findings:write,entities:read,evidence_packs:read,remediation:read,connectors:read. Refinement based on real customer asks. - Rate limits per programmatic caller — defaults (600 req/min M2M, 300 req/min API key) need load testing before going public.
- Scope-vs-role intersection UX — when a
membertries to create a key with write scopes, do we silently drop the scope or show an error? Recommendation: show an error explaining which scopes are unavailable and why.
Critical files reference
For quick navigation during implementation, the critical files by responsibility:
| Responsibility | File |
|---|---|
| AuthProvider interface | sv0-platform/src/api/auth/auth-provider.ts (new) |
| Provider factory | sv0-platform/src/api/auth/provider-factory.ts (new) |
| WorkOS implementation | sv0-platform/src/api/auth/providers/workos-provider.ts (new) |
| OIDC implementation | sv0-platform/src/api/auth/providers/oidc-provider.ts (new, stub) |
| Dev implementation | sv0-platform/src/api/auth/providers/dev-provider.ts (new) |
| Session encryption | sv0-platform/src/api/auth/session.ts (new) |
| Permission definitions | sv0-platform/src/api/auth/permissions.ts (new) |
| Auth middleware (provider-agnostic) | sv0-platform/src/api/middleware/auth-middleware.ts (new) |
| Main auth entry point | sv0-platform/src/api/middleware/auth.ts (modified) |
| Auth routes | sv0-platform/src/api/routes/auth.ts (new) |
| Admin routes | sv0-platform/src/api/routes/admin.ts (new) |
| Tenant config routes | sv0-platform/src/api/routes/tenants.ts (new) |
| Webhook receiver | sv0-platform/src/api/routes/webhooks/workos.ts (new) |
| Storage schema | sv0-platform/src/storage/mongo/collections.ts (modified) |
| Storage adapter | sv0-platform/src/storage/storage-adapter.ts (modified) |
| Tenant types | sv0-platform/src/domain/tenants/types.ts (new) |
| User types | sv0-platform/src/domain/users/types.ts (new) |
| Frontend tenant context | sv0-platform/ui/src/context/tenant-context.tsx (new) |
| Frontend router | sv0-platform/ui/src/App.tsx (modified) |
| Frontend API client | sv0-platform/ui/src/api/client.ts (modified) |
| Tenant switcher | sv0-platform/ui/src/components/TenantSwitcher.tsx (new) |
| Login page | sv0-platform/ui/src/pages/LoginPage.tsx (new) |
| Admin tenants page | sv0-platform/ui/src/pages/admin/AdminTenantsPage.tsx (new) |
| Admin tenant detail | sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx (new) |
| Settings page (shrunken) | sv0-platform/ui/src/pages/SettingsPage.tsx (modified) |
| Provisioning script | sv0-platform/scripts/provision-eval-tenant.ts (new) |
| Backfill script | sv0-platform/scripts/backfill-tenants.ts (new) |
| Reconciliation worker | sv0-platform/src/workers/workos-reconcile.ts (new) |
Open questions (must be answered before Phase 1 starts)
- WorkOS pricing confirmation. Does the 1M MAU AuthKit free tier include SSO connections, or are SSO connections billed separately from $125/month each regardless of MAU? Confirm with WorkOS sales with the exact scenario from ADR-017.
- WorkOS startup program terms. Does sv0 qualify? If yes, what is waived and for how long?
- Connection deactivation billing. Does disabling an SSO connection mid-cycle actually stop the charge for that connection?
- M2M Applications and API Keys widget pricing. Confirm these are bundled with AuthKit at no additional cost, or priced per-application / per-key. Not documented on the public pricing page.
- Session cookie domain. Single-domain (
app.securityv0.com) or include staging? The decision affects cookiedomainattribute. Recommendation: per-env cookies, no sharing. - Super-admin session duration. 7 days (same as regular users) or 24 hours (tighter)? Recommendation: 24 hours with refresh on activity.
- Jira deep link location. Confirm where the current remediation code reads Jira config. If it's hardcoded, Phase 3 includes the migration; if it's already per-tenant but in a different collection, adjust the plan.
- Existing tenant backfill. How many distinct tenant strings exist in production today? If few (likely <5), manual backfill is fine. If more, we need a review step before Phase 5 runs.
- API Keys widget theming. Can the WorkOS API Keys widget be themed to match sv0 branding? If not, acceptable as a cosmetic deviation for Phase 6.
- MCP CIMD end-to-end test. Before public MCP support in Phase 6, dry-run CIMD discovery with at least two real MCP clients (Claude Desktop + one other) on staging.
Verification checklist (overall)
By the time Phase 5 is complete, all of these must be true (Phase 6 adds customer-facing programmatic access on top):
Interactive user flows:
- A SecurityV0 team member can log in via
@securityv0.comGoogle Workspace through AuthKit. - A SecurityV0 team member sees every tenant in the header dropdown.
- A SecurityV0 team member can paste
/t/acme/findings/abc123in Slack and another member can click it and land on the same view after logging in. - A non-super-admin user sees only their own tenants in the dropdown and receives 404 (not 403) when navigating to unauthorized tenants.
- A prospect can click a magic-link invitation, log in, and see their evaluation tenant.
Enterprise SSO flow:
- A super-admin can generate an Admin Portal link for a paying customer.
- A customer IT admin walks through the Admin Portal and configures SAML against a test IdP successfully.
- After SAML is activated,
tenants.sso_enforced = trueand magic link is blocked for that tenant.
Provisioning and tooling:
-
scripts/provision-eval-tenant.tscreates a new WorkOS Organization, tenant row, invitation, and seeded data in one command. - Daily reconciliation job runs cleanly.
SecurityV0 team programmatic access:
- A staff member can generate a personal API key via Settings → API Keys (WorkOS hosted widget) and use it from their own Claude Code / scripts. Audit logs attribute calls to that individual user.
- Deleting the staff user (or revoking their personal key) invalidates subsequent calls within 60 seconds.
- Each long-running service (
github-actions-ci,workos-reconcile,deploy-runner) has its own M2M Application and its own 1Password entry. There is no single sharedsecurityv0-claude-codecredential. - An M2M Application with declared
internal_role: "member"can read across tenants but is rejected with 403 on destructive writes. NoALL_SUPERADMIN_PERMISSIONSconstant exists in the code. - An M2M JWT whose
client_idis not registered inm2m-applications.tsis rejected with 401. - Rotating a per-service M2M credential in WorkOS dashboard + the corresponding 1Password entry propagates on next service restart; no other service is affected.
Customer programmatic access (Phase 6):
- Customer users can generate, list, rotate, and revoke API keys via the embedded WorkOS widget in Settings.
- API keys respect scope × role intersection (cannot grant more than the user has).
- Revoking an API key invalidates it within 60 seconds.
- A Claude Desktop MCP client can authorize against staging sv0 via OAuth consent and make API calls.
- Users can view and revoke authorized OAuth applications from Settings → Authorized Apps.
- An MCP client with only a CIMD metadata URL can successfully complete OAuth discovery.
Cleanup:
-
REQUIRE_AUTH=false,X-API-Keyheader auth (user path),X-Tenant-Idheader auth are all removed from the code. - All internal scripts go through
scripts/lib/api-client.tsusing either a personal API key (interactive) or a per-service M2M credential (services/CI); no legacy key paths remain. -
npm run ciandcd ui && npm run ciboth pass.
Risks and mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| WorkOS pricing turns out materially different from ADR-017 reading | Medium | High | Confirm with sales before Phase 1 starts. If worse, revisit Clerk fallback in ADR-017 §Alternatives. |
| WorkOS outage blocks new logins during rollout | Low | Medium | Staged rollout (staging → dev → prod). Existing sessions continue working during outages. Monitor status.workos.com. |
| Webhook event loss causes mirror drift | Low | Medium | Reconciliation job catches drift within 24h. Idempotency prevents duplicate harm. |
| URL refactor breaks external deep links shared in Slack history | Medium | Low | No external customer deep links exist yet. Internal Slack links get a migration-aware redirect for 30 days. |
| Backfilling existing tenants to WorkOS Organizations mis-maps slugs | Low | High | Manual review before running backfill-tenants.ts in production. Dry-run mode first. |
| Service-token migration breaks seed scripts | Medium | Low | api-client.ts transparently accepts either a personal API key or a per-service M2M credential pair; migrate scripts in the same PR as Phase 5. |
| Staff use personal API keys as shared bot credentials anyway | Medium | High | Runbook explicitly forbids sharing keys. Audit logs attribute every call to an individual; reconciliation worker flags suspicious usage patterns. Rotation is per-person, not coordinated. |
| A per-service M2M Application is added directly in WorkOS without a PR | Medium | Medium | m2mMiddleware rejects any client_id not registered in m2m-applications.ts. Reconciliation worker in §5.4 diffs the WorkOS dashboard against the static registry and alerts. |
| Local dev bootstrap drifts from production auth path | Medium | Medium | Same middleware pipeline runs in both modes; only session minting differs. Integration tests cover both paths. |