diff --git a/packages/db/src/migrations/0111_user_subscription_credentials.sql b/packages/db/src/migrations/0111_user_subscription_credentials.sql new file mode 100644 index 00000000000..f3f08178280 --- /dev/null +++ b/packages/db/src/migrations/0111_user_subscription_credentials.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "user_subscription_credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "provider" text NOT NULL, + "credential_kind" text NOT NULL, + "secret_provider" text DEFAULT 'local_encrypted' NOT NULL, + "material" jsonb NOT NULL, + "value_sha256" text NOT NULL, + "redacted_metadata" jsonb, + "status" text DEFAULT 'active' NOT NULL, + "last_tested_at" timestamp with time zone, + "last_test_status" text, + "last_resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_subscription_credentials" ADD CONSTRAINT "user_subscription_credentials_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_subscription_credentials" ADD CONSTRAINT "user_subscription_credentials_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_subscription_credentials_company_idx" + ON "user_subscription_credentials" USING btree ("company_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_subscription_credentials_company_user_idx" + ON "user_subscription_credentials" USING btree ("company_id","user_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_subscription_credentials_company_user_provider_uq" + ON "user_subscription_credentials" USING btree ("company_id","user_id","provider"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 16ba529fecf..2cfbda57157 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -778,6 +778,13 @@ "when": 1781902400000, "tag": "0110_document_company_cascade", "breakpoints": true + }, + { + "idx": 111, + "version": "7", + "when": 1782168000000, + "tag": "0111_user_subscription_credentials", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index b4e61c35fb5..3718ef43f01 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -76,6 +76,7 @@ export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; export { secretAccessEvents } from "./secret_access_events.js"; +export { userSubscriptionCredentials } from "./user_subscription_credentials.js"; export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; diff --git a/packages/db/src/schema/user_subscription_credentials.ts b/packages/db/src/schema/user_subscription_credentials.ts new file mode 100644 index 00000000000..7d928dec757 --- /dev/null +++ b/packages/db/src/schema/user_subscription_credentials.ts @@ -0,0 +1,53 @@ +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { authUsers } from "./auth.js"; + +/** + * Per-user "bring your own" subscription credential storage for the official + * Claude / Codex CLI binaries. Scoped by company + user + provider so a single + * employee holds at most one credential record per provider per company. + * + * The encrypted credential payload lives in `material` (the same encrypted + * envelope shape produced by the secret provider modules, e.g. + * `{ scheme, iv, tag, ciphertext }` for `local_encrypted`). Plaintext credential + * material is never stored here and is never returned through the API. + */ +export const userSubscriptionCredentials = pgTable( + "user_subscription_credentials", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), + // "claude" | "codex" + provider: text("provider").notNull(), + // Provider-specific credential shape, e.g. "claude_oauth_token", + // "claude_credentials_json", "codex_auth_json". + credentialKind: text("credential_kind").notNull(), + // Encryption provider that produced `material` (e.g. "local_encrypted"). + secretProvider: text("secret_provider").notNull().default("local_encrypted"), + // Encrypted-at-rest credential envelope. Never plaintext. + material: jsonb("material").$type>().notNull(), + // SHA-256 of the plaintext credential, for change detection / audit only. + valueSha256: text("value_sha256").notNull(), + // Non-sensitive redacted preview metadata (e.g. token length, suffix hint). + redactedMetadata: jsonb("redacted_metadata").$type>(), + status: text("status").notNull().default("active"), + lastTestedAt: timestamp("last_tested_at", { withTimezone: true }), + lastTestStatus: text("last_test_status"), + lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("user_subscription_credentials_company_idx").on(table.companyId), + companyUserIdx: index("user_subscription_credentials_company_user_idx").on( + table.companyId, + table.userId, + ), + companyUserProviderUq: uniqueIndex("user_subscription_credentials_company_user_provider_uq").on( + table.companyId, + table.userId, + table.provider, + ), + }), +); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 948c752d1ec..11c74264012 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -24,4 +24,5 @@ export const API = { joinRequests: `${API_PREFIX}/join-requests`, members: `${API_PREFIX}/members`, admin: `${API_PREFIX}/admin`, + subscriptionCredentials: `${API_PREFIX}/companies/:companyId/subscription-credentials`, } as const; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 040dc348f56..ed6c24c6712 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -602,6 +602,37 @@ export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[numbe export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const; export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number]; +// Per-user "bring your own" subscription credentials for the official +// Claude / Codex CLI binaries. Only these two providers are supported. +export const SUBSCRIPTION_CREDENTIAL_PROVIDERS = ["claude", "codex"] as const; +export type SubscriptionCredentialProvider = (typeof SUBSCRIPTION_CREDENTIAL_PROVIDERS)[number]; + +// Provider-specific credential shapes that can be captured. +// - claude_oauth_token: a raw CLAUDE_CODE_OAUTH_TOKEN string +// - claude_credentials_json: a ~/.claude/.credentials.json-equivalent JSON document +// - codex_auth_json: a ~/.codex/auth.json-equivalent JSON document +export const SUBSCRIPTION_CREDENTIAL_KINDS = [ + "claude_oauth_token", + "claude_credentials_json", + "codex_auth_json", +] as const; +export type SubscriptionCredentialKind = (typeof SUBSCRIPTION_CREDENTIAL_KINDS)[number]; + +// Which credential kinds are valid for each provider. +export const SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER: Record< + SubscriptionCredentialProvider, + readonly SubscriptionCredentialKind[] +> = { + claude: ["claude_oauth_token", "claude_credentials_json"], + codex: ["codex_auth_json"], +}; + +export const SUBSCRIPTION_CREDENTIAL_STATUSES = ["active", "disabled"] as const; +export type SubscriptionCredentialStatus = (typeof SUBSCRIPTION_CREDENTIAL_STATUSES)[number]; + +export const SUBSCRIPTION_CREDENTIAL_TEST_STATUSES = ["untested", "ready", "failed"] as const; +export type SubscriptionCredentialTestStatus = (typeof SUBSCRIPTION_CREDENTIAL_TEST_STATUSES)[number]; + export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const; export type StorageProvider = (typeof STORAGE_PROVIDERS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0de59487b46..69288aec949 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -129,6 +129,11 @@ export { SECRET_PROVIDERS, SECRET_PROVIDER_CONFIG_STATUSES, SECRET_PROVIDER_CONFIG_HEALTH_STATUSES, + SUBSCRIPTION_CREDENTIAL_PROVIDERS, + SUBSCRIPTION_CREDENTIAL_KINDS, + SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER, + SUBSCRIPTION_CREDENTIAL_STATUSES, + SUBSCRIPTION_CREDENTIAL_TEST_STATUSES, STORAGE_PROVIDERS, BILLING_TYPES, FINANCE_EVENT_KINDS, @@ -258,6 +263,10 @@ export { type SecretProvider, type SecretProviderConfigStatus, type SecretProviderConfigHealthStatus, + type SubscriptionCredentialProvider, + type SubscriptionCredentialKind, + type SubscriptionCredentialStatus, + type SubscriptionCredentialTestStatus, type StorageProvider, type BillingType, type FinanceEventKind, @@ -1203,6 +1212,12 @@ export { rotateSecretSchema, secretBindingTargetSchema, updateSecretSchema, + subscriptionCredentialProviderSchema, + subscriptionCredentialKindSchema, + subscriptionCredentialStatusSchema, + subscriptionCredentialTestStatusSchema, + upsertSubscriptionCredentialSchema, + subscriptionCredentialReadModelSchema, createRoutineSchema, updateRoutineSchema, createRoutineTriggerSchema, @@ -1223,6 +1238,8 @@ export { type RemoteSecretImportSelection, type RotateSecret, type UpdateSecret, + type UpsertSubscriptionCredential, + type SubscriptionCredentialReadModel, type CreateRoutine, type UpdateRoutine, type CreateRoutineTrigger, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index b04e1533d78..79c9add1e61 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -467,6 +467,17 @@ export { type UpdateSecret, } from "./secret.js"; +export { + subscriptionCredentialProviderSchema, + subscriptionCredentialKindSchema, + subscriptionCredentialStatusSchema, + subscriptionCredentialTestStatusSchema, + upsertSubscriptionCredentialSchema, + subscriptionCredentialReadModelSchema, + type UpsertSubscriptionCredential, + type SubscriptionCredentialReadModel, +} from "./subscription-credential.js"; + export { createRoutineSchema, updateRoutineSchema, diff --git a/packages/shared/src/validators/subscription-credential.test.ts b/packages/shared/src/validators/subscription-credential.test.ts new file mode 100644 index 00000000000..cadd6d8c610 --- /dev/null +++ b/packages/shared/src/validators/subscription-credential.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { + subscriptionCredentialProviderSchema, + subscriptionCredentialKindSchema, + upsertSubscriptionCredentialSchema, +} from "./subscription-credential.js"; + +describe("subscription credential validators", () => { + describe("provider schema", () => { + it("accepts valid providers", () => { + expect(subscriptionCredentialProviderSchema.parse("claude")).toBe("claude"); + expect(subscriptionCredentialProviderSchema.parse("codex")).toBe("codex"); + }); + + it("rejects unknown providers", () => { + expect(() => subscriptionCredentialProviderSchema.parse("openai")).toThrow(); + expect(() => subscriptionCredentialProviderSchema.parse("")).toThrow(); + }); + }); + + describe("kind schema", () => { + it("accepts all valid credential kinds", () => { + expect(subscriptionCredentialKindSchema.parse("claude_oauth_token")).toBe("claude_oauth_token"); + expect(subscriptionCredentialKindSchema.parse("claude_credentials_json")).toBe("claude_credentials_json"); + expect(subscriptionCredentialKindSchema.parse("codex_auth_json")).toBe("codex_auth_json"); + }); + + it("rejects unknown kinds", () => { + expect(() => subscriptionCredentialKindSchema.parse("openai_api_key")).toThrow(); + }); + }); + + describe("upsertSubscriptionCredentialSchema", () => { + it("accepts a valid claude oauth token", () => { + const input = { + provider: "claude", + credentialKind: "claude_oauth_token", + material: "sk-ant-token-placeholder", + }; + expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow(); + const parsed = upsertSubscriptionCredentialSchema.parse(input); + expect(parsed.provider).toBe("claude"); + expect(parsed.credentialKind).toBe("claude_oauth_token"); + }); + + it("accepts a valid claude_credentials_json with well-formed JSON", () => { + const creds = JSON.stringify({ + claudeAiOauth: { + accessToken: "tok", + refreshToken: "ref", + expiresAt: Date.now() + 3600000, + }, + }); + const input = { + provider: "claude", + credentialKind: "claude_credentials_json", + material: creds, + }; + expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow(); + }); + + it("rejects claude_credentials_json with non-JSON material", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "claude", + credentialKind: "claude_credentials_json", + material: "not-json", + }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("material"); + } + }); + + it("accepts a valid codex_auth_json with well-formed JSON", () => { + const auth = JSON.stringify({ accessToken: "tok", refreshToken: "ref" }); + const input = { + provider: "codex", + credentialKind: "codex_auth_json", + material: auth, + }; + expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow(); + }); + + it("rejects codex_auth_json with non-JSON material", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "codex", + credentialKind: "codex_auth_json", + material: "not-valid-json", + }); + expect(result.success).toBe(false); + }); + + it("rejects a codex credential kind for the claude provider", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "claude", + credentialKind: "codex_auth_json", + material: "{}", + }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("credentialKind"); + } + }); + + it("rejects a claude credential kind for the codex provider", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "codex", + credentialKind: "claude_oauth_token", + material: "some-token", + }); + expect(result.success).toBe(false); + }); + + it("rejects empty material", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "", + }); + expect(result.success).toBe(false); + }); + + it("accepts an optional disabled status", () => { + const parsed = upsertSubscriptionCredentialSchema.parse({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "some-token", + status: "disabled", + }); + expect(parsed.status).toBe("disabled"); + }); + + it("rejects an invalid status value", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "some-token", + status: "pending", + }); + expect(result.success).toBe(false); + }); + + it("rejects material that exceeds the maximum length", () => { + const result = upsertSubscriptionCredentialSchema.safeParse({ + provider: "claude", + credentialKind: "claude_oauth_token", + // 32769 chars > MAX_CREDENTIAL_MATERIAL_LENGTH + material: "x".repeat(32_769), + }); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/validators/subscription-credential.ts b/packages/shared/src/validators/subscription-credential.ts new file mode 100644 index 00000000000..853924fd9df --- /dev/null +++ b/packages/shared/src/validators/subscription-credential.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { + SUBSCRIPTION_CREDENTIAL_KINDS, + SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER, + SUBSCRIPTION_CREDENTIAL_PROVIDERS, + SUBSCRIPTION_CREDENTIAL_STATUSES, + SUBSCRIPTION_CREDENTIAL_TEST_STATUSES, + type SubscriptionCredentialProvider, +} from "../constants.js"; + +export const subscriptionCredentialProviderSchema = z.enum(SUBSCRIPTION_CREDENTIAL_PROVIDERS); +export const subscriptionCredentialKindSchema = z.enum(SUBSCRIPTION_CREDENTIAL_KINDS); +export const subscriptionCredentialStatusSchema = z.enum(SUBSCRIPTION_CREDENTIAL_STATUSES); +export const subscriptionCredentialTestStatusSchema = z.enum(SUBSCRIPTION_CREDENTIAL_TEST_STATUSES); + +// Generous upper bound: OAuth tokens are short, but JSON credential documents +// (claude/codex) can carry refresh tokens and account metadata. +const MAX_CREDENTIAL_MATERIAL_LENGTH = 32_768; + +const credentialMaterialSchema = z.string().min(1).max(MAX_CREDENTIAL_MATERIAL_LENGTH); + +function assertKindMatchesProvider( + provider: SubscriptionCredentialProvider, + kind: (typeof SUBSCRIPTION_CREDENTIAL_KINDS)[number], + ctx: z.RefinementCtx, +) { + const allowed = SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER[provider]; + if (!allowed.includes(kind)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentialKind"], + message: `Credential kind "${kind}" is not valid for provider "${provider}"`, + }); + } +} + +// Link (create) or update a credential. Mutations are upserts keyed by +// (company, user, provider): one credential record per provider per user. +export const upsertSubscriptionCredentialSchema = z + .object({ + provider: subscriptionCredentialProviderSchema, + credentialKind: subscriptionCredentialKindSchema, + // Raw credential material (token string or JSON document). Encrypted at + // rest server-side; never echoed back in any API response. + material: credentialMaterialSchema, + status: subscriptionCredentialStatusSchema.optional(), + }) + .superRefine((value, ctx) => { + assertKindMatchesProvider(value.provider, value.credentialKind, ctx); + if (value.credentialKind === "claude_credentials_json" || value.credentialKind === "codex_auth_json") { + try { + JSON.parse(value.material); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["material"], + message: `Credential kind "${value.credentialKind}" requires valid JSON material`, + }); + } + } + }); + +export type UpsertSubscriptionCredential = z.infer; + +// Redacted read model returned by the API. Never includes credential material. +export const subscriptionCredentialReadModelSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + userId: z.string(), + provider: subscriptionCredentialProviderSchema, + credentialKind: subscriptionCredentialKindSchema, + status: subscriptionCredentialStatusSchema, + testStatus: subscriptionCredentialTestStatusSchema, + // Non-sensitive hints only (e.g. material length, last 4 chars of a token). + redactedMetadata: z.record(z.string(), z.unknown()).nullable(), + lastTestedAt: z.string().datetime().nullable(), + lastResolvedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export type SubscriptionCredentialReadModel = z.infer; diff --git a/server/src/__tests__/subscription-credentials-routes.test.ts b/server/src/__tests__/subscription-credentials-routes.test.ts new file mode 100644 index 00000000000..ad4b0d9b77d --- /dev/null +++ b/server/src/__tests__/subscription-credentials-routes.test.ts @@ -0,0 +1,305 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { subscriptionCredentialRoutes } from "../routes/subscription-credentials.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { HttpError } from "../errors.js"; + +const mockSvc = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + recordTestResult: vi.fn(), + resolveDecryptedMaterial: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + subscriptionCredentialService: () => mockSvc, + logActivity: mockLogActivity, +})); + +const BOARD_ACTOR = { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }], +}; + +const AGENT_ACTOR = { + type: "agent", + agentId: "agent-1", + companyId: "company-1", +}; + +function createApp(actor: Record = BOARD_ACTOR) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", subscriptionCredentialRoutes({} as any)); + app.use(errorHandler); + return app; +} + +const REDACTED_CREDENTIAL = { + id: "cred-uuid-1", + companyId: "company-1", + userId: "user-1", + provider: "claude", + credentialKind: "claude_oauth_token", + status: "active", + testStatus: "untested", + redactedMetadata: { kind: "claude_oauth_token", materialLength: 40, tokenSuffix: "abcd" }, + lastTestedAt: null, + lastResolvedAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("subscription credential routes", () => { + beforeEach(() => { + for (const mock of Object.values(mockSvc)) { + mock.mockReset(); + } + mockLogActivity.mockReset(); + mockLogActivity.mockResolvedValue(undefined); + }); + + // ─── LIST ──────────────────────────────────────────────────────────────── + + it("lists redacted credentials for a board user", async () => { + mockSvc.list.mockResolvedValue([REDACTED_CREDENTIAL]); + + const res = await request(createApp()) + .get("/api/companies/company-1/subscription-credentials"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([REDACTED_CREDENTIAL]); + expect(mockSvc.list).toHaveBeenCalledWith("company-1", "user-1"); + }); + + it("rejects list for agent actors (no board user context)", async () => { + const res = await request(createApp(AGENT_ACTOR)) + .get("/api/companies/company-1/subscription-credentials"); + + expect(res.status).toBe(403); + expect(mockSvc.list).not.toHaveBeenCalled(); + }); + + it("rejects list for cross-company board actors", async () => { + const res = await request(createApp({ + ...BOARD_ACTOR, + companyIds: ["company-2"], + memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }], + })).get("/api/companies/company-1/subscription-credentials"); + + expect(res.status).toBe(403); + expect(mockSvc.list).not.toHaveBeenCalled(); + }); + + // ─── GET BY ID ─────────────────────────────────────────────────────────── + + it("returns a single redacted credential", async () => { + mockSvc.getById.mockResolvedValue(REDACTED_CREDENTIAL); + + const res = await request(createApp()) + .get("/api/companies/company-1/subscription-credentials/cred-uuid-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual(REDACTED_CREDENTIAL); + expect(mockSvc.getById).toHaveBeenCalledWith("company-1", "user-1", "cred-uuid-1"); + }); + + it("returns 404 for unknown credentials", async () => { + mockSvc.getById.mockRejectedValue( + new HttpError(404, "Subscription credential not found"), + ); + + const res = await request(createApp()) + .get("/api/companies/company-1/subscription-credentials/nonexistent"); + + expect(res.status).toBe(404); + expect(mockSvc.getById).toHaveBeenCalled(); + }); + + // ─── UPSERT (PUT) ──────────────────────────────────────────────────────── + + it("links a claude oauth token credential", async () => { + mockSvc.upsert.mockResolvedValue(REDACTED_CREDENTIAL); + + const res = await request(createApp()) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "sk-ant-oauthtoken-very-long-placeholder-value-here", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual(REDACTED_CREDENTIAL); + expect(mockSvc.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: "company-1", + userId: "user-1", + provider: "claude", + credentialKind: "claude_oauth_token", + material: "sk-ant-oauthtoken-very-long-placeholder-value-here", + }), + ); + // Audit log must be called with no credential material + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "subscription_credential.linked", + entityType: "subscription_credential", + details: expect.not.objectContaining({ material: expect.anything() }), + }), + ); + }); + + it("links a codex_auth_json credential with valid JSON material", async () => { + const codexCred = { + ...REDACTED_CREDENTIAL, + provider: "codex", + credentialKind: "codex_auth_json", + }; + mockSvc.upsert.mockResolvedValue(codexCred); + + const codexJson = JSON.stringify({ accessToken: "tok", refreshToken: "ref", expiresAt: 9999 }); + + const res = await request(createApp()) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "codex", + credentialKind: "codex_auth_json", + material: codexJson, + }); + + expect(res.status).toBe(200); + expect(mockSvc.upsert).toHaveBeenCalledWith( + expect.objectContaining({ provider: "codex", credentialKind: "codex_auth_json" }), + ); + }); + + it("rejects a credentials_json kind with non-JSON material", async () => { + const res = await request(createApp()) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "claude", + credentialKind: "claude_credentials_json", + material: "this-is-not-json", + }); + + expect(res.status).toBe(400); + expect(mockSvc.upsert).not.toHaveBeenCalled(); + }); + + it("rejects mismatched provider/kind (claude provider + codex kind)", async () => { + const res = await request(createApp()) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "claude", + credentialKind: "codex_auth_json", + material: "{}", + }); + + expect(res.status).toBe(400); + expect(mockSvc.upsert).not.toHaveBeenCalled(); + }); + + it("rejects upsert for agent actors", async () => { + const res = await request(createApp(AGENT_ACTOR)) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "sometoken", + }); + + expect(res.status).toBe(403); + expect(mockSvc.upsert).not.toHaveBeenCalled(); + }); + + it("rejects upsert for unknown providers", async () => { + const res = await request(createApp()) + .put("/api/companies/company-1/subscription-credentials") + .send({ + provider: "openai", + credentialKind: "claude_oauth_token", + material: "sometoken", + }); + + expect(res.status).toBe(400); + expect(mockSvc.upsert).not.toHaveBeenCalled(); + }); + + // ─── TEST RESULT ───────────────────────────────────────────────────────── + + it("records a test result for a credential", async () => { + const tested = { ...REDACTED_CREDENTIAL, testStatus: "ready", lastTestedAt: "2026-01-02T00:00:00.000Z" }; + mockSvc.recordTestResult.mockResolvedValue(tested); + + const res = await request(createApp()) + .post("/api/companies/company-1/subscription-credentials/cred-uuid-1/test-result") + .send({ testStatus: "ready" }); + + expect(res.status).toBe(200); + expect(res.body.testStatus).toBe("ready"); + expect(mockSvc.recordTestResult).toHaveBeenCalledWith( + "company-1", + "user-1", + "cred-uuid-1", + "ready", + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "subscription_credential.tested" }), + ); + }); + + it("rejects an invalid test status value", async () => { + const res = await request(createApp()) + .post("/api/companies/company-1/subscription-credentials/cred-uuid-1/test-result") + .send({ testStatus: "passing" }); + + expect(res.status).toBe(400); + expect(mockSvc.recordTestResult).not.toHaveBeenCalled(); + }); + + // ─── DELETE ─────────────────────────────────────────────────────────────── + + it("deletes a credential and emits a redacted audit log entry", async () => { + mockSvc.getById.mockResolvedValue(REDACTED_CREDENTIAL); + mockSvc.delete.mockResolvedValue(undefined); + + const res = await request(createApp()) + .delete("/api/companies/company-1/subscription-credentials/cred-uuid-1"); + + expect(res.status).toBe(204); + expect(mockSvc.delete).toHaveBeenCalledWith("company-1", "user-1", "cred-uuid-1"); + // Audit log must be called; must not contain material + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "subscription_credential.deleted", + entityId: "cred-uuid-1", + details: expect.objectContaining({ provider: "claude" }), + }), + ); + expect(JSON.stringify(mockLogActivity.mock.calls)).not.toMatch(/material/); + }); + + it("rejects delete for agent actors", async () => { + const res = await request(createApp(AGENT_ACTOR)) + .delete("/api/companies/company-1/subscription-credentials/cred-uuid-1"); + + expect(res.status).toBe(403); + expect(mockSvc.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index efd9a74c9ad..3af7154720e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -32,6 +32,7 @@ import { dashboardRoutes } from "./routes/dashboard.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; +import { subscriptionCredentialRoutes } from "./routes/subscription-credentials.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; @@ -244,6 +245,7 @@ export async function createApp( api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); + api.use(subscriptionCredentialRoutes(db)); api.use(resourceMembershipRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 940047f29f9..a01268fc62d 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -16,6 +16,7 @@ export { activityRoutes } from "./activity.js"; export { dashboardRoutes } from "./dashboard.js"; export { sidebarBadgeRoutes } from "./sidebar-badges.js"; export { sidebarPreferenceRoutes } from "./sidebar-preferences.js"; +export { subscriptionCredentialRoutes } from "./subscription-credentials.js"; export { resourceMembershipRoutes } from "./resource-memberships.js"; export { inboxDismissalRoutes } from "./inbox-dismissals.js"; export { llmRoutes } from "./llms.js"; diff --git a/server/src/routes/subscription-credentials.ts b/server/src/routes/subscription-credentials.ts new file mode 100644 index 00000000000..c8e256e9d5f --- /dev/null +++ b/server/src/routes/subscription-credentials.ts @@ -0,0 +1,164 @@ +import { Router, type Request, type Response } from "express"; +import type { Db } from "@paperclipai/db"; +import { + subscriptionCredentialTestStatusSchema, + upsertSubscriptionCredentialSchema, +} from "@paperclipai/shared"; +import { z } from "zod"; +import { validate } from "../middleware/validate.js"; +import { logActivity, subscriptionCredentialService } from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; + +// BYO subscription credentials are strictly per-user: every route requires a +// board user context and scopes all reads/mutations to that user within the +// company. Agents cannot manage another user's subscription seats. +function requireBoardUserId(req: Request, res: Response): string | null { + assertBoard(req); + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + +const recordTestResultSchema = z.object({ + testStatus: subscriptionCredentialTestStatusSchema, +}); + +export function subscriptionCredentialRoutes(db: Db) { + const router = Router(); + const svc = subscriptionCredentialService(db); + + // List the current user's credential records (redacted). + router.get("/companies/:companyId/subscription-credentials", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.list(companyId, userId)); + }); + + // Read a single credential record (redacted). + router.get( + "/companies/:companyId/subscription-credentials/:credentialId", + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + res.json(await svc.getById(companyId, userId, req.params.credentialId as string)); + }, + ); + + // Link or update a credential (upsert keyed by company + user + provider). + router.put( + "/companies/:companyId/subscription-credentials", + validate(upsertSubscriptionCredentialSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + + const body = req.body as z.infer; + const result = await svc.upsert({ + companyId, + userId, + provider: body.provider, + credentialKind: body.credentialKind, + material: body.material, + status: body.status, + }); + + const actor = getActorInfo(req); + // Audit only redacted, non-sensitive fields; never the credential material. + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "subscription_credential.linked", + entityType: "subscription_credential", + entityId: result.id, + details: { + userId, + provider: result.provider, + credentialKind: result.credentialKind, + status: result.status, + }, + }); + res.status(200).json(result); + }, + ); + + // Record the outcome of a downstream readiness/validity test. + router.post( + "/companies/:companyId/subscription-credentials/:credentialId/test-result", + validate(recordTestResultSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + + const credentialId = req.params.credentialId as string; + const body = req.body as z.infer; + const result = await svc.recordTestResult(companyId, userId, credentialId, body.testStatus); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "subscription_credential.tested", + entityType: "subscription_credential", + entityId: result.id, + details: { + userId, + provider: result.provider, + testStatus: result.testStatus, + }, + }); + res.json(result); + }, + ); + + // Delete a credential record. + router.delete( + "/companies/:companyId/subscription-credentials/:credentialId", + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireBoardUserId(req, res); + if (!userId) return; + + const credentialId = req.params.credentialId as string; + // Resolve first so we can attach redacted provider info to the audit log. + const existing = await svc.getById(companyId, userId, credentialId); + await svc.delete(companyId, userId, credentialId); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "subscription_credential.deleted", + entityType: "subscription_credential", + entityId: credentialId, + details: { + userId, + provider: existing.provider, + credentialKind: existing.credentialKind, + }, + }); + res.status(204).end(); + }, + ); + + return router; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index e968098d727..f40a0278819 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -47,6 +47,11 @@ export { activityService, type ActivityFilters } from "./activity.js"; export { approvalService } from "./approvals.js"; export { budgetService } from "./budgets.js"; export { secretService } from "./secrets.js"; +export { + subscriptionCredentialService, + type UpsertSubscriptionCredentialInput, + type DecryptedSubscriptionCredential, +} from "./subscription-credentials.js"; export { routineService } from "./routines.js"; export { costService } from "./costs.js"; export { financeService } from "./finance.js"; diff --git a/server/src/services/subscription-credentials.ts b/server/src/services/subscription-credentials.ts new file mode 100644 index 00000000000..85e11afccd2 --- /dev/null +++ b/server/src/services/subscription-credentials.ts @@ -0,0 +1,275 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { userSubscriptionCredentials } from "@paperclipai/db"; +import { + SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER, + type SubscriptionCredentialKind, + type SubscriptionCredentialProvider, + type SubscriptionCredentialReadModel, + type SubscriptionCredentialStatus, + type SubscriptionCredentialTestStatus, +} from "@paperclipai/shared"; +import { badRequest, notFound, unprocessable } from "../errors.js"; +import { getSecretProvider } from "../secrets/provider-registry.js"; + +// The BYO subscription credential layer reuses the existing managed encryption +// provider so credential material is encrypted at rest with the same key +// management as company secrets. Only the local_encrypted (managed) scheme is +// used here; external-reference providers are not applicable to pasted seats. +const ENCRYPTION_PROVIDER = "local_encrypted" as const; + +type CredentialRow = typeof userSubscriptionCredentials.$inferSelect; + +export interface UpsertSubscriptionCredentialInput { + companyId: string; + userId: string; + provider: SubscriptionCredentialProvider; + credentialKind: SubscriptionCredentialKind; + material: string; + status?: SubscriptionCredentialStatus; +} + +export interface DecryptedSubscriptionCredential { + id: string; + companyId: string; + userId: string; + provider: SubscriptionCredentialProvider; + credentialKind: SubscriptionCredentialKind; + // Decrypted credential material (token string or JSON document). This is the + // narrow server-side surface for downstream runtime injection. Callers must + // never persist or log this value. + material: string; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function assertKindMatchesProvider( + provider: SubscriptionCredentialProvider, + kind: SubscriptionCredentialKind, +) { + const allowed = SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER[provider]; + if (!allowed.includes(kind)) { + throw badRequest(`Credential kind "${kind}" is not valid for provider "${provider}"`); + } +} + +// Build non-sensitive redacted metadata for read models. Only safe hints (no +// secret material) are stored or returned. +function buildRedactedMetadata( + kind: SubscriptionCredentialKind, + material: string, +): Record { + const length = material.length; + if (kind === "claude_oauth_token") { + const suffix = material.length >= 4 ? material.slice(-4) : null; + return { kind, materialLength: length, tokenSuffix: suffix }; + } + // JSON credential documents: surface only the top-level key names so the UI + // can hint at shape without exposing values. + let jsonKeys: string[] | null = null; + try { + const parsed = JSON.parse(material) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + jsonKeys = Object.keys(parsed as Record).sort(); + } + } catch { + jsonKeys = null; + } + return { kind, materialLength: length, jsonKeys }; +} + +function toReadModel(row: CredentialRow): SubscriptionCredentialReadModel { + return { + id: row.id, + companyId: row.companyId, + userId: row.userId, + provider: row.provider as SubscriptionCredentialProvider, + credentialKind: row.credentialKind as SubscriptionCredentialKind, + status: row.status as SubscriptionCredentialStatus, + testStatus: (row.lastTestStatus as SubscriptionCredentialTestStatus | null) ?? "untested", + redactedMetadata: row.redactedMetadata ?? null, + lastTestedAt: row.lastTestedAt ? row.lastTestedAt.toISOString() : null, + lastResolvedAt: row.lastResolvedAt ? row.lastResolvedAt.toISOString() : null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export function subscriptionCredentialService(db: Db) { + async function findRow( + companyId: string, + userId: string, + provider: SubscriptionCredentialProvider, + ): Promise { + return db.query.userSubscriptionCredentials.findFirst({ + where: and( + eq(userSubscriptionCredentials.companyId, companyId), + eq(userSubscriptionCredentials.userId, userId), + eq(userSubscriptionCredentials.provider, provider), + ), + }); + } + + async function findRowById(companyId: string, userId: string, id: string) { + const row = await db.query.userSubscriptionCredentials.findFirst({ + where: eq(userSubscriptionCredentials.id, id), + }); + if (!row) return undefined; + // Enforce company + user isolation: one user/company can never read or + // mutate another's credential, even with a guessed id. + if (row.companyId !== companyId || row.userId !== userId) return undefined; + return row; + } + + return { + async list(companyId: string, userId: string): Promise { + const rows = await db.query.userSubscriptionCredentials.findMany({ + where: and( + eq(userSubscriptionCredentials.companyId, companyId), + eq(userSubscriptionCredentials.userId, userId), + ), + }); + return rows.map(toReadModel); + }, + + async getById( + companyId: string, + userId: string, + id: string, + ): Promise { + const row = await findRowById(companyId, userId, id); + if (!row) throw notFound("Subscription credential not found"); + return toReadModel(row); + }, + + // Link or update a credential. Keyed by (company, user, provider) so each + // employee holds at most one record per provider. Material is encrypted at + // rest before persistence; plaintext is never stored. + async upsert( + input: UpsertSubscriptionCredentialInput, + ): Promise { + assertKindMatchesProvider(input.provider, input.credentialKind); + + const provider = getSecretProvider(ENCRYPTION_PROVIDER); + const prepared = await provider.createSecret({ value: input.material }); + const now = new Date(); + const redactedMetadata = buildRedactedMetadata(input.credentialKind, input.material); + + const [row] = await db + .insert(userSubscriptionCredentials) + .values({ + companyId: input.companyId, + userId: input.userId, + provider: input.provider, + credentialKind: input.credentialKind, + secretProvider: ENCRYPTION_PROVIDER, + material: prepared.material as Record, + valueSha256: prepared.valueSha256, + redactedMetadata, + status: input.status ?? "active", + // A freshly linked/updated credential is untested until verified. + lastTestStatus: "untested", + lastTestedAt: null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + userSubscriptionCredentials.companyId, + userSubscriptionCredentials.userId, + userSubscriptionCredentials.provider, + ], + set: { + credentialKind: input.credentialKind, + secretProvider: ENCRYPTION_PROVIDER, + material: prepared.material as Record, + valueSha256: prepared.valueSha256, + redactedMetadata, + status: input.status ?? "active", + lastTestStatus: "untested", + lastTestedAt: null, + updatedAt: now, + }, + }) + .returning(); + + return toReadModel(row); + }, + + async delete(companyId: string, userId: string, id: string): Promise { + const row = await findRowById(companyId, userId, id); + if (!row) throw notFound("Subscription credential not found"); + await db.delete(userSubscriptionCredentials).where(eq(userSubscriptionCredentials.id, row.id)); + }, + + // Record the outcome of a downstream readiness/validity test. The actual + // test (running the official CLI) is a downstream task; this just persists + // redacted test metadata so the UI can surface readiness. + async recordTestResult( + companyId: string, + userId: string, + id: string, + testStatus: SubscriptionCredentialTestStatus, + ): Promise { + const row = await findRowById(companyId, userId, id); + if (!row) throw notFound("Subscription credential not found"); + const now = new Date(); + const [updated] = await db + .update(userSubscriptionCredentials) + .set({ lastTestStatus: testStatus, lastTestedAt: now, updatedAt: now }) + .where(eq(userSubscriptionCredentials.id, row.id)) + .returning(); + return toReadModel(updated); + }, + + // Narrow server-side interface for downstream runtime injection. Returns + // decrypted credential material. This must only be called by trusted + // server-side runtime code, never exposed through an HTTP response. + async resolveDecryptedMaterial( + companyId: string, + userId: string, + provider: SubscriptionCredentialProvider, + ): Promise { + const row = await findRow(companyId, userId, provider); + if (!row) throw notFound("Subscription credential not found"); + if (row.status !== "active") { + throw unprocessable("Subscription credential is not active", { code: "credential_inactive" }); + } + const secretProvider = getSecretProvider(row.secretProvider as "local_encrypted"); + const material = await secretProvider.resolveVersion({ + material: row.material as Record, + externalRef: null, + context: { + companyId: row.companyId, + secretId: row.id, + secretKey: `subscription_credential:${row.provider}`, + version: 1, + }, + }); + + await db + .update(userSubscriptionCredentials) + .set({ lastResolvedAt: new Date() }) + .where(eq(userSubscriptionCredentials.id, row.id)) + .catch(() => undefined); + + // Integrity check against the stored plaintext hash. + if (sha256Hex(material) !== row.valueSha256) { + throw unprocessable("Subscription credential failed integrity check", { + code: "credential_integrity_failed", + }); + } + + return { + id: row.id, + companyId: row.companyId, + userId: row.userId, + provider: row.provider as SubscriptionCredentialProvider, + credentialKind: row.credentialKind as SubscriptionCredentialKind, + material, + }; + }, + }; +}