diff --git a/src/anthropic/client.ts b/src/anthropic/client.ts new file mode 100644 index 000000000..21277da4b --- /dev/null +++ b/src/anthropic/client.ts @@ -0,0 +1,134 @@ +/** + * OAuth usage endpoint — returns per-bucket utilization percentages and reset times + * for the authenticated subscription. + */ +const ANTHROPIC_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const FETCH_TIMEOUT_MS = 10_000; // 10 seconds + +/** A single rate-limit bucket from the usage API. */ +export interface UsageBucket { + /** Human-readable label (e.g. "5-Hour Window", "Sonnet 7-Day") */ + label: string; + /** Utilization percentage 0–100 */ + utilization: number; + /** ISO-8601 reset timestamp */ + resetsAt: string; +} + +export interface ClaudeSubscriptionLimits { + tokenMasked: string; + buckets: UsageBucket[]; + extraUsage: { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + } | null; +} + +interface CacheEntry { + data: ClaudeSubscriptionLimits; + timestamp: number; +} + +/** + * Per-token cache. Keyed by full token for lookup; only the masked value is + * surfaced in returned data. + */ +const cacheByToken = new Map(); + +/** + * Masks a token, showing only the last 4 characters. + */ +function maskToken(token: string): string { + return `****${token.slice(-4)}`; +} + +/** Maps API response keys to human-readable labels. */ +const BUCKET_LABELS: Record = { + five_hour: '5-Hour Window', + seven_day: '7-Day Overall', + seven_day_oauth_apps: '7-Day OAuth Apps', + seven_day_opus: '7-Day Opus', + seven_day_sonnet: '7-Day Sonnet', + seven_day_cowork: '7-Day Cowork', + iguana_necktie: 'Iguana Necktie', +}; + +/** Parse usage buckets from the API response JSON. */ +function parseBuckets(json: Record): UsageBucket[] { + const buckets: UsageBucket[] = []; + for (const [key, label] of Object.entries(BUCKET_LABELS)) { + const raw = json[key] as { utilization?: number; resets_at?: string } | null | undefined; + if (raw && typeof raw.utilization === 'number' && typeof raw.resets_at === 'string') { + buckets.push({ label, utilization: raw.utilization, resetsAt: raw.resets_at }); + } + } + return buckets; +} + +/** Parse the extra_usage block from the API response JSON. */ +function parseExtraUsage(json: Record): ClaudeSubscriptionLimits['extraUsage'] { + const rawExtra = json.extra_usage as Record | null | undefined; + if (!rawExtra || typeof rawExtra.is_enabled !== 'boolean') { + return null; + } + return { + isEnabled: rawExtra.is_enabled, + monthlyLimit: typeof rawExtra.monthly_limit === 'number' ? rawExtra.monthly_limit : null, + usedCredits: typeof rawExtra.used_credits === 'number' ? rawExtra.used_credits : null, + utilization: typeof rawExtra.utilization === 'number' ? rawExtra.utilization : null, + }; +} + +/** + * Fetch Claude subscription usage for the given OAuth token via the /api/oauth/usage endpoint. + * Returns null on any error (network, auth, unexpected shape, etc.). + * Results are cached in memory for 5 minutes per unique token. + */ +export async function fetchClaudeSubscriptionLimits( + oauthToken: string, +): Promise { + // Return cached result if still valid + const cached = cacheByToken.get(oauthToken); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + try { + const response = await fetch(ANTHROPIC_USAGE_URL, { + headers: { + Authorization: `Bearer ${oauthToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + 'Content-Type': 'application/json', + 'User-Agent': 'claude-code/2.1.87', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + return null; + } + + const json = (await response.json()) as Record; + const result: ClaudeSubscriptionLimits = { + tokenMasked: maskToken(oauthToken), + buckets: parseBuckets(json), + extraUsage: parseExtraUsage(json), + }; + + cacheByToken.set(oauthToken, { data: result, timestamp: Date.now() }); + return result; + } catch { + // Return null on any failure (network error, timeout, parse error, etc.) + return null; + } +} + +/** + * Clear the in-memory limits cache (useful for testing). + */ +export function clearAnthropicLimitsCache(): void { + cacheByToken.clear(); +} diff --git a/src/api/router.ts b/src/api/router.ts index 9982ebd60..5d7171ae5 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -2,6 +2,7 @@ import { agentConfigsRouter } from './routers/agentConfigs.js'; import { agentDefinitionsRouter } from './routers/agentDefinitions.js'; import { agentTriggerConfigsRouter } from './routers/agentTriggerConfigs.js'; import { authRouter } from './routers/auth.js'; +import { claudeCodeLimitsRouter } from './routers/claudeCodeLimits.js'; import { integrationsDiscoveryRouter } from './routers/integrationsDiscovery.js'; import { organizationRouter } from './routers/organization.js'; import { pmDiscoveryRouter } from './routers/pm-discovery.js'; @@ -35,6 +36,7 @@ export const appRouter = router({ workItems: workItemsRouter, users: usersRouter, workflowStatuses: workflowStatusesRouter, + claudeCodeLimits: claudeCodeLimitsRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/_shared/maskCredential.ts b/src/api/routers/_shared/maskCredential.ts new file mode 100644 index 000000000..a23c3dc4b --- /dev/null +++ b/src/api/routers/_shared/maskCredential.ts @@ -0,0 +1,8 @@ +/** + * Mask a credential value for list previews: short values collapse to '****', + * longer ones keep the last 4 characters ('****abcd'). Shared by the project + * and organization credential list endpoints so the masking rule cannot drift. + */ +export function maskCredentialValue(value: string): string { + return value.length <= 12 ? '****' : `****${value.slice(-4)}`; +} diff --git a/src/api/routers/_shared/orgRole.ts b/src/api/routers/_shared/orgRole.ts new file mode 100644 index 000000000..005cce4f4 --- /dev/null +++ b/src/api/routers/_shared/orgRole.ts @@ -0,0 +1,29 @@ +import { TRPCError } from '@trpc/server'; +import { resolveActorRoleInOrg } from '../../context.js'; + +type Role = 'member' | 'admin' | 'superadmin'; + +/** + * Resolve the caller's role *in the effective org*. The `adminProcedure` + * middleware is a coarse global-role gate; this refines it with the per-org + * membership role (users.ts pattern) so an admin who has switched into an org + * where they are only a member cannot perform admin actions there. + */ +export function resolveActorRole(ctx: { + user: { id: string; role: Role; orgId: string }; + effectiveOrgId: string; +}): Promise { + return resolveActorRoleInOrg({ + userId: ctx.user.id, + globalRole: ctx.user.role, + homeOrgId: ctx.user.orgId, + orgId: ctx.effectiveOrgId, + }); +} + +/** Require the caller to be an admin (or superadmin) in the effective org. */ +export function assertOrgAdmin(actorRole: Role): void { + if (actorRole !== 'admin' && actorRole !== 'superadmin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } +} diff --git a/src/api/routers/claudeCodeLimits.ts b/src/api/routers/claudeCodeLimits.ts new file mode 100644 index 000000000..869d6506e --- /dev/null +++ b/src/api/routers/claudeCodeLimits.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { fetchClaudeSubscriptionLimits } from '../../anthropic/client.js'; +import { + getProjectOwnCredential, + listAllClaudeCodeCredentials, +} from '../../db/repositories/credentialsRepository.js'; +import { resolveOrgCredential } from '../../db/repositories/orgCredentialsRepository.js'; +import { logger } from '../../utils/logging.js'; +import { adminProcedure, protectedProcedure, router } from '../trpc.js'; +import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; +import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; + +const CLAUDE_CODE_TOKEN_KEY = 'CLAUDE_CODE_OAUTH_TOKEN'; + +// The server env token (process.env.CLAUDE_CODE_OAUTH_TOKEN) is deliberately +// NOT surfaced here: (a) this tRPC handler runs in the dashboard service while +// workers receive the ROUTER service's env — the dashboard's view of it can be +// wrong in both directions; (b) it is a host-level operator secret, and its +// usage/billing data must not be exposed to tenant org members. Operators who +// want its usage visible should store the token as an org credential instead — +// which is exactly what this feature is for. + +export type ClaudeCodeLimitsScope = 'org' | 'project'; + +export interface LimitsSource { + scope: ClaudeCodeLimitsScope; + projectId?: string; + projectName?: string; + token: string; +} + +/** Resolve the org token, treating decrypt failures as absent (never 500). */ +async function safeResolveOrgToken(orgId: string): Promise { + try { + return await resolveOrgCredential(orgId, CLAUDE_CODE_TOKEN_KEY); + } catch (err) { + logger.warn('Failed to resolve org CLAUDE_CODE_OAUTH_TOKEN', { + orgId, + error: String(err), + }); + return null; + } +} + +/** + * Fetch usage limits for each source, preserving source attribution. + * Sources whose token yields no limits data (API error, revoked token) come + * back with `limits: null` so the UI can distinguish "no data" from "not + * configured". The Anthropic client caches per token for 5 minutes, so + * duplicate tokens across sources cost one HTTP call. Raw tokens never leave + * the server — only the masked preview inside `limits`. + */ +async function fetchLimitsForSources(sources: T[]) { + return Promise.all( + sources.map(async ({ token, ...source }) => ({ + ...source, + limits: await fetchClaudeSubscriptionLimits(token), + })), + ); +} + +export const claudeCodeLimitsRouter = router({ + /** + * Claude Code subscription usage for every credential source in the + * effective org: the org-level shared token and each project-level + * override. Org-admin gated — same audience as the organization + * credentials settings page. + */ + forOrg: adminProcedure.query(async ({ ctx }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + + const sources: LimitsSource[] = []; + + const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId); + if (orgToken) sources.push({ scope: 'org', token: orgToken }); + + const projectCredentials = await listAllClaudeCodeCredentials(ctx.effectiveOrgId); + for (const cred of projectCredentials) { + sources.push({ + scope: 'project', + projectId: cred.projectId, + projectName: cred.projectName, + token: cred.value, + }); + } + + return fetchLimitsForSources(sources); + }), + + /** + * Claude Code subscription usage for the credential candidates visible to + * one project: its own override (if set) and the inherited org token (if + * set). `active` marks the credential-system winner (project override + * beats org). Used by the project settings engine tab as a picker preview. + */ + forProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + await verifyProjectOrgAccess(input.projectId, ctx.effectiveOrgId); + + // Project tier only, deliberately NOT the inheriting resolver: the + // point is contrasting the override with the org value. + const projectToken = await getProjectOwnCredential(input.projectId, CLAUDE_CODE_TOKEN_KEY); + const orgToken = await safeResolveOrgToken(ctx.effectiveOrgId); + + const sources: (LimitsSource & { active: boolean })[] = []; + if (projectToken) { + sources.push({ + scope: 'project', + projectId: input.projectId, + token: projectToken, + active: true, + }); + } + if (orgToken) { + sources.push({ scope: 'org', token: orgToken, active: !projectToken }); + } + + return fetchLimitsForSources(sources); + }), +}); diff --git a/src/api/routers/organization.ts b/src/api/routers/organization.ts index 86e92866e..7edd15d4c 100644 --- a/src/api/routers/organization.ts +++ b/src/api/routers/organization.ts @@ -1,11 +1,20 @@ import { z } from 'zod'; +import { + deleteOrgCredential, + listOrgCredentials, + listOrgCredentialsMeta, + writeOrgCredential, +} from '../../db/repositories/orgCredentialsRepository.js'; import { createOrganization, getOrganization, listAllOrganizations, updateOrganization, } from '../../db/repositories/settingsRepository.js'; +import { captureException } from '../../sentry.js'; import { adminProcedure, protectedProcedure, router, superAdminProcedure } from '../trpc.js'; +import { maskCredentialValue } from './_shared/maskCredential.js'; +import { assertOrgAdmin, resolveActorRole } from './_shared/orgRole.js'; export const organizationRouter = router({ get: protectedProcedure.query(async ({ ctx }) => { @@ -41,4 +50,71 @@ export const organizationRouter = router({ .mutation(async ({ input }) => { await updateOrganization(input.id, { name: input.name }); }), + + // Organization-scoped shared credentials (org_credentials table). Projects + // inherit these; a project_credentials row with the same env var key + // overrides the org value for that project. + credentials: router({ + /** + * List masked metadata for all org-scoped credentials. + * Never returns plaintext values — only masked last-4-chars preview. + */ + list: adminProcedure.query(async ({ ctx }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + try { + const rows = await listOrgCredentials(ctx.effectiveOrgId); + return rows.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + })); + } catch (err) { + // Decryption key missing/wrong — return metadata without value preview + captureException(err, { + tags: { source: 'org_credentials_list' }, + extra: { orgId: ctx.effectiveOrgId }, + level: 'warning', + }); + const meta = await listOrgCredentialsMeta(ctx.effectiveOrgId); + return meta.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + })); + } + }), + + /** + * Upsert an org-scoped credential (write-only — never exposes plaintext). + */ + set: adminProcedure + .input( + z.object({ + envVarKey: z.string().regex(/^[A-Z_][A-Z0-9_]*$/), + value: z.string().min(1), + name: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + await writeOrgCredential( + ctx.effectiveOrgId, + input.envVarKey, + input.value, + input.name ?? null, + ); + }), + + /** + * Delete an org-scoped credential. + */ + delete: adminProcedure + .input(z.object({ envVarKey: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + assertOrgAdmin(await resolveActorRole(ctx)); + await deleteOrgCredential(ctx.effectiveOrgId, input.envVarKey); + }), + }), }); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index b066024fa..e5e115354 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -16,6 +16,10 @@ import { listProjectCredentialsMeta, writeProjectCredential, } from '../../db/repositories/credentialsRepository.js'; +import { + listOrgCredentials, + listOrgCredentialsMeta, +} from '../../db/repositories/orgCredentialsRepository.js'; import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; import { createProject, @@ -36,6 +40,7 @@ import { computeContentHash } from '../../router/worker-dockerfile-compose.js'; import { captureException } from '../../sentry.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, publicProcedure, router, superAdminProcedure } from '../trpc.js'; +import { maskCredentialValue } from './_shared/maskCredential.js'; /** * The current worker-image/dockerfile state read alongside the ownership check. @@ -790,11 +795,15 @@ export const projectsRouter = router({ }), }), - // Project-scoped credentials (project_credentials table) + // Project-scoped credentials (project_credentials table), merged with the + // inherited org_credentials tier (project rows override org rows per key) credentials: router({ /** - * List masked metadata for all project-scoped credentials. - * Never returns plaintext values — only masked last-4-chars preview. + * List masked metadata for all credentials visible to the project: + * project-scoped rows plus inherited org-scoped rows not overridden by + * a project row. Never returns plaintext values — only masked + * last-4-chars preview. `source` marks the tier a row comes from; + * `hasOrgFallback` marks project rows that shadow an org value. */ list: protectedProcedure .input(z.object({ projectId: z.string() })) @@ -802,12 +811,29 @@ export const projectsRouter = router({ await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); try { const rows = await listProjectCredentials(input.projectId); - return rows.map((row) => ({ - envVarKey: row.envVarKey, - name: row.name, - isConfigured: true, - maskedValue: row.value.length <= 12 ? '****' : `****${row.value.slice(-4)}`, - })); + const orgRows = await listOrgCredentials(ctx.effectiveOrgId); + const orgKeys = new Set(orgRows.map((row) => row.envVarKey)); + const projectKeys = new Set(rows.map((row) => row.envVarKey)); + return [ + ...rows.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + source: 'project' as const, + hasOrgFallback: orgKeys.has(row.envVarKey), + })), + ...orgRows + .filter((row) => !projectKeys.has(row.envVarKey)) + .map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: maskCredentialValue(row.value), + source: 'org' as const, + hasOrgFallback: false, + })), + ]; } catch (err) { // Decryption key missing/wrong — return metadata without value preview captureException(err, { @@ -816,12 +842,29 @@ export const projectsRouter = router({ level: 'warning', }); const meta = await listProjectCredentialsMeta(input.projectId); - return meta.map((row) => ({ - envVarKey: row.envVarKey, - name: row.name, - isConfigured: true, - maskedValue: '****', - })); + const orgMeta = await listOrgCredentialsMeta(ctx.effectiveOrgId); + const orgKeys = new Set(orgMeta.map((row) => row.envVarKey)); + const projectKeys = new Set(meta.map((row) => row.envVarKey)); + return [ + ...meta.map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + source: 'project' as const, + hasOrgFallback: orgKeys.has(row.envVarKey), + })), + ...orgMeta + .filter((row) => !projectKeys.has(row.envVarKey)) + .map((row) => ({ + envVarKey: row.envVarKey, + name: row.name, + isConfigured: true, + maskedValue: '****', + source: 'org' as const, + hasOrgFallback: false, + })), + ]; } }), diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index 9004b3cbb..666d7b285 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -738,6 +738,9 @@ async function captureRefreshedToken( if (newJson === originalJson) return; try { + // Intentionally project-scoped: when the seed value was inherited from an + // org-level CODEX_AUTH_JSON credential, this creates a project override + // (the org value goes stale for this project). Accepted v1 behavior. await writeProjectCredential(projectId, 'CODEX_AUTH_JSON', newJson); logWriter('INFO', 'Captured refreshed Codex auth token and updated project credential', {}); } catch (error) { diff --git a/src/cli/dashboard/org/credentials-delete.ts b/src/cli/dashboard/org/credentials-delete.ts new file mode 100644 index 000000000..4401e6044 --- /dev/null +++ b/src/cli/dashboard/org/credentials-delete.ts @@ -0,0 +1,39 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; + +export default class OrgCredentialsDelete extends DashboardCommand { + static override description = 'Delete an organization-scoped credential.'; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Environment variable key to delete', + required: true, + }), + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsDelete); + + await confirm(`Delete organization credential ${flags.key}?`, flags.yes); + + try { + await this.withSpinner('Deleting credential...', () => + this.client.organization.credentials.delete.mutate({ + envVarKey: flags.key, + }), + ); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.success(`Deleted organization credential ${flags.key}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/org/credentials-list.ts b/src/cli/dashboard/org/credentials-list.ts new file mode 100644 index 000000000..d7ef7f614 --- /dev/null +++ b/src/cli/dashboard/org/credentials-list.ts @@ -0,0 +1,35 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgCredentialsList extends DashboardCommand { + static override description = 'List organization-scoped credentials (values masked).'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsList); + + try { + const creds = await this.client.organization.credentials.list.query(); + + if (flags.json) { + this.outputJson(creds); + return; + } + + if (creds.length === 0) { + this.log('No organization credentials configured.'); + return; + } + + this.outputTable(creds as unknown as Record[], [ + { key: 'envVarKey', header: 'Key' }, + { key: 'name', header: 'Name' }, + { key: 'maskedValue', header: 'Value (masked)' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/org/credentials-set.ts b/src/cli/dashboard/org/credentials-set.ts new file mode 100644 index 000000000..d9fd16949 --- /dev/null +++ b/src/cli/dashboard/org/credentials-set.ts @@ -0,0 +1,40 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgCredentialsSet extends DashboardCommand { + static override description = + 'Set an organization-scoped credential (upsert by env var key). Inherited by all projects; project credentials with the same key override it.'; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Environment variable key (e.g. GITHUB_TOKEN_IMPLEMENTER)', + required: true, + }), + value: Flags.string({ description: 'Credential value', required: true }), + name: Flags.string({ description: 'Human-readable name for the credential' }), + }; + + async run(): Promise { + const { flags } = await this.parse(OrgCredentialsSet); + + try { + await this.withSpinner('Setting credential...', () => + this.client.organization.credentials.set.mutate({ + envVarKey: flags.key, + value: flags.value, + name: flags.name, + }), + ); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.success(`Set organization credential ${flags.key}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/db/crypto.ts b/src/db/crypto.ts index e1375ea7d..de0d5d934 100644 --- a/src/db/crypto.ts +++ b/src/db/crypto.ts @@ -54,7 +54,8 @@ export function isEncryptedValue(value: string): boolean { * Encrypt a credential value using AES-256-GCM. * Returns `enc:v1:::`. * If no master key is configured, returns the plaintext unchanged. - * @param aad - Additional Authenticated Data (orgId) to bind the ciphertext to the org. + * @param aad - Additional Authenticated Data binding the ciphertext to its owner + * (orgId for org_credentials, projectId for project_credentials). */ export function encryptCredential(plaintext: string, aad: string): string { const key = getMasterKey(); diff --git a/src/db/migrations/0062_org_credentials.sql b/src/db/migrations/0062_org_credentials.sql new file mode 100644 index 000000000..ed7759d93 --- /dev/null +++ b/src/db/migrations/0062_org_credentials.sql @@ -0,0 +1,21 @@ +-- 0062_org_credentials.sql +-- Organization-scoped shared credentials. Projects inherit these at +-- resolution time; a project_credentials row with the same env_var_key +-- overrides the org value for that project. Values are encrypted with +-- AAD = org_id (project credentials use AAD = project_id). +BEGIN; + +CREATE TABLE IF NOT EXISTS org_credentials ( + id SERIAL PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + env_var_key TEXT NOT NULL, + value TEXT NOT NULL, + name TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_org_credentials_org_env_var_key + ON org_credentials(org_id, env_var_key); + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 5871c7a4b..8e3b63dd1 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1795000000000, "tag": "0060_agent_config_review_event_policy", "breakpoints": false + }, + { + "idx": 62, + "version": "7", + "when": 1797000000000, + "tag": "0062_org_credentials", + "breakpoints": false } ] } diff --git a/src/db/repositories/credentialsRepository.ts b/src/db/repositories/credentialsRepository.ts index 75079b98e..6536e688f 100644 --- a/src/db/repositories/credentialsRepository.ts +++ b/src/db/repositories/credentialsRepository.ts @@ -1,15 +1,24 @@ import { and, eq } from 'drizzle-orm'; +import { logger } from '../../utils/logging.js'; import { getDb } from '../client.js'; import { decryptCredential, encryptCredential } from '../crypto.js'; -import { projectCredentials, projectIntegrations, projects } from '../schema/index.js'; +import { + orgCredentials, + projectCredentials, + projectIntegrations, + projects, +} from '../schema/index.js'; // ============================================================================ -// Project-scoped credential resolution (reads from project_credentials table) +// Project-scoped credential resolution (reads from project_credentials table, +// falling back to the org_credentials tier — project values override org) // ============================================================================ /** * Resolve a single credential for a project by env var key. - * Reads from the project_credentials table using projectId as AAD for decryption. + * Reads from the project_credentials table using projectId as AAD for + * decryption. When the project has no row for the key, falls back to the + * owning organization's org_credentials tier (AAD = orgId). */ export async function resolveProjectCredential( projectId: string, @@ -24,13 +33,23 @@ export async function resolveProjectCredential( and(eq(projectCredentials.projectId, projectId), eq(projectCredentials.envVarKey, envVarKey)), ); - if (!row) return null; - return decryptCredential(row.value, projectId); + if (row) return decryptCredential(row.value, projectId); + + const [orgRow] = await db + .select({ value: orgCredentials.value, orgId: orgCredentials.orgId }) + .from(orgCredentials) + .innerJoin(projects, eq(projects.orgId, orgCredentials.orgId)) + .where(and(eq(projects.id, projectId), eq(orgCredentials.envVarKey, envVarKey))); + + if (!orgRow) return null; + return decryptCredential(orgRow.value, orgRow.orgId); } /** * Resolve all credentials for a project as a flat env-var-key → value map. - * Single query against project_credentials, using projectId as AAD. + * Merges the owning organization's org_credentials tier first, then overlays + * project_credentials rows so project values win on key collisions. Each tier + * decrypts with its own AAD (orgId vs projectId). * Throws if the project does not exist. */ export async function resolveAllProjectCredentials( @@ -39,19 +58,27 @@ export async function resolveAllProjectCredentials( const db = getDb(); const [project] = await db - .select({ id: projects.id }) + .select({ id: projects.id, orgId: projects.orgId }) .from(projects) .where(eq(projects.id, projectId)); if (!project) { throw new Error(`Project not found: ${projectId}`); } + const orgRows = await db + .select({ envVarKey: orgCredentials.envVarKey, value: orgCredentials.value }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, project.orgId)); + const rows = await db .select({ envVarKey: projectCredentials.envVarKey, value: projectCredentials.value }) .from(projectCredentials) .where(eq(projectCredentials.projectId, projectId)); const result: Record = {}; + for (const row of orgRows) { + result[row.envVarKey] = decryptCredential(row.value, project.orgId); + } for (const row of rows) { result[row.envVarKey] = decryptCredential(row.value, projectId); } @@ -159,6 +186,87 @@ export async function listProjectCredentialsMeta( .where(eq(projectCredentials.projectId, projectId)); } +// ============================================================================ +// Cross-project credential queries +// ============================================================================ + +/** + * List all project-level CLAUDE_CODE_OAUTH_TOKEN credentials across an org, + * with the owning project's name for display attribution. + * Returns decrypted values for use in server-side API calls only. + * Never expose raw tokens to the client. + * + * Rows that fail to decrypt (master-key rotation, AAD mismatch) are skipped + * with a warning instead of failing the whole query — one corrupted row must + * not hide usage data for the healthy tokens. + */ +export async function listAllClaudeCodeCredentials( + orgId: string, +): Promise<{ projectId: string; projectName: string; value: string }[]> { + const db = getDb(); + + const rows = await db + .select({ + projectId: projectCredentials.projectId, + projectName: projects.name, + value: projectCredentials.value, + }) + .from(projectCredentials) + .innerJoin(projects, eq(projectCredentials.projectId, projects.id)) + .where( + and(eq(projects.orgId, orgId), eq(projectCredentials.envVarKey, 'CLAUDE_CODE_OAUTH_TOKEN')), + ); + + const result: { projectId: string; projectName: string; value: string }[] = []; + for (const row of rows) { + try { + result.push({ + projectId: row.projectId, + projectName: row.projectName, + value: decryptCredential(row.value, row.projectId), + }); + } catch (err) { + logger.warn('Skipping undecryptable CLAUDE_CODE_OAUTH_TOKEN credential', { + projectId: row.projectId, + error: String(err), + }); + } + } + return result; +} + +/** + * Read a single credential from the project tier ONLY — no org fallback. + * Used where the project-vs-org distinction is the point (e.g. contrasting a + * project override with the inherited org value). Returns null when the row + * is absent or cannot be decrypted. + */ +export async function getProjectOwnCredential( + projectId: string, + envVarKey: string, +): Promise { + const db = getDb(); + + const [row] = await db + .select({ value: projectCredentials.value }) + .from(projectCredentials) + .where( + and(eq(projectCredentials.projectId, projectId), eq(projectCredentials.envVarKey, envVarKey)), + ); + + if (!row) return null; + try { + return decryptCredential(row.value, projectId); + } catch (err) { + logger.warn('Failed to decrypt project credential', { + projectId, + envVarKey, + error: String(err), + }); + return null; + } +} + // ============================================================================ // Integration metadata queries // ============================================================================ diff --git a/src/db/repositories/orgCredentialsRepository.ts b/src/db/repositories/orgCredentialsRepository.ts new file mode 100644 index 000000000..2e9a7b099 --- /dev/null +++ b/src/db/repositories/orgCredentialsRepository.ts @@ -0,0 +1,119 @@ +import { and, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { decryptCredential, encryptCredential } from '../crypto.js'; +import { orgCredentials } from '../schema/index.js'; + +// ============================================================================ +// Organization-scoped credential storage (org_credentials table) +// +// Org credentials are the shared tier below project_credentials: projects +// inherit them at resolution time, and a project_credentials row with the +// same env_var_key overrides the org value. Values are encrypted with +// AAD = orgId (project credentials use AAD = projectId). +// ============================================================================ + +/** + * Resolve a single org credential by env var key. + * Returns the decrypted plaintext value, or null if not found. + */ +export async function resolveOrgCredential( + orgId: string, + envVarKey: string, +): Promise { + const db = getDb(); + + const [row] = await db + .select({ value: orgCredentials.value }) + .from(orgCredentials) + .where(and(eq(orgCredentials.orgId, orgId), eq(orgCredentials.envVarKey, envVarKey))); + + if (!row) return null; + return decryptCredential(row.value, orgId); +} + +/** + * Resolve all org credentials as a flat env-var-key → value map. + */ +export async function resolveAllOrgCredentials(orgId: string): Promise> { + const db = getDb(); + + const rows = await db + .select({ envVarKey: orgCredentials.envVarKey, value: orgCredentials.value }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); + + const result: Record = {}; + for (const row of rows) { + result[row.envVarKey] = decryptCredential(row.value, orgId); + } + return result; +} + +/** + * Write (upsert) an org credential with automatic encryption. + * The plaintext value is encrypted using orgId as AAD before storage. + */ +export async function writeOrgCredential( + orgId: string, + envVarKey: string, + value: string, + name?: string | null, +): Promise { + const db = getDb(); + const encryptedValue = encryptCredential(value, orgId); + await db + .insert(orgCredentials) + .values({ orgId, envVarKey, value: encryptedValue, name: name ?? null }) + .onConflictDoUpdate({ + target: [orgCredentials.orgId, orgCredentials.envVarKey], + set: { value: encryptedValue, name: name ?? null, updatedAt: new Date() }, + }); +} + +/** + * List all org credentials as an array of decrypted key-value records. + */ +export async function listOrgCredentials( + orgId: string, +): Promise<{ envVarKey: string; value: string; name: string | null }[]> { + const db = getDb(); + + const rows = await db + .select({ + envVarKey: orgCredentials.envVarKey, + value: orgCredentials.value, + name: orgCredentials.name, + }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); + + return rows.map((row) => ({ + envVarKey: row.envVarKey, + value: decryptCredential(row.value, orgId), + name: row.name, + })); +} + +/** + * List org credential metadata (key + name) without reading or decrypting values. + * Used as a fallback when decryption fails (missing/wrong master key). + */ +export async function listOrgCredentialsMeta( + orgId: string, +): Promise<{ envVarKey: string; name: string | null }[]> { + const db = getDb(); + return db + .select({ envVarKey: orgCredentials.envVarKey, name: orgCredentials.name }) + .from(orgCredentials) + .where(eq(orgCredentials.orgId, orgId)); +} + +/** + * Delete a row from org_credentials. + */ +export async function deleteOrgCredential(orgId: string, envVarKey: string): Promise { + const db = getDb(); + await db + .delete(orgCredentials) + .where(and(eq(orgCredentials.orgId, orgId), eq(orgCredentials.envVarKey, envVarKey))); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index a52d3db52..2425726d4 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -3,6 +3,7 @@ export { agentDefinitions } from './agentDefinitions.js'; export { agentTriggerConfigs } from './agentTriggerConfigs.js'; export { projectIntegrations } from './integrations.js'; export { organizations } from './organizations.js'; +export { orgCredentials } from './orgCredentials.js'; export { orgMemberships } from './orgMemberships.js'; export { projectCredentials } from './projectCredentials.js'; export { projects } from './projects.js'; diff --git a/src/db/schema/orgCredentials.ts b/src/db/schema/orgCredentials.ts new file mode 100644 index 000000000..d985bc0bb --- /dev/null +++ b/src/db/schema/orgCredentials.ts @@ -0,0 +1,26 @@ +import { pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; + +/** + * Organization-scoped shared credentials. Projects inherit these at + * resolution time; a project_credentials row with the same env_var_key + * overrides the org value for that project. Values are encrypted with + * AAD = org_id (project credentials use AAD = project_id). + */ +export const orgCredentials = pgTable( + 'org_credentials', + { + id: serial('id').primaryKey(), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + envVarKey: text('env_var_key').notNull(), + value: text('value').notNull(), + name: text('name'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [uniqueIndex('uq_org_credentials_org_env_var_key').on(table.orgId, table.envVarKey)], +); diff --git a/tests/integration/db/credentialsRepository.test.ts b/tests/integration/db/credentialsRepository.test.ts index da43bb466..8c602dfaf 100644 --- a/tests/integration/db/credentialsRepository.test.ts +++ b/tests/integration/db/credentialsRepository.test.ts @@ -6,6 +6,11 @@ import { resolveProjectCredential, writeProjectCredential, } from '../../../src/db/repositories/credentialsRepository.js'; +import { + deleteOrgCredential, + listOrgCredentials, + writeOrgCredential, +} from '../../../src/db/repositories/orgCredentialsRepository.js'; import { truncateAll } from '../helpers/db.js'; import { seedOrg, seedProject } from '../helpers/seed.js'; @@ -111,4 +116,60 @@ describe('credentialsRepository (integration)', () => { expect(cred?.value).toBe('plaintext-secret'); // decrypted on read }); }); + + // ========================================================================= + // Org-tier inheritance (org_credentials) + // ========================================================================= + + describe('org credential inheritance', () => { + it('org credential CRUD round-trips', async () => { + await writeOrgCredential('test-org', 'ORG_KEY', 'org-value', 'Org Key'); + + const creds = await listOrgCredentials('test-org'); + expect(creds).toHaveLength(1); + expect(creds[0]).toEqual({ envVarKey: 'ORG_KEY', value: 'org-value', name: 'Org Key' }); + + await deleteOrgCredential('test-org', 'ORG_KEY'); + expect(await listOrgCredentials('test-org')).toEqual([]); + }); + + it('project inherits an org-only credential', async () => { + await writeOrgCredential('test-org', 'GITHUB_TOKEN_IMPLEMENTER', 'org-shared-token'); + + const single = await resolveProjectCredential('test-project', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(single).toBe('org-shared-token'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.GITHUB_TOKEN_IMPLEMENTER).toBe('org-shared-token'); + }); + + it('project credential with the same key overrides the org value', async () => { + await writeOrgCredential('test-org', 'SHARED_KEY', 'org-value'); + await writeProjectCredential('test-project', 'SHARED_KEY', 'project-value'); + + expect(await resolveProjectCredential('test-project', 'SHARED_KEY')).toBe('project-value'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.SHARED_KEY).toBe('project-value'); + }); + + it('deleting the project override reverts to the org value', async () => { + await writeOrgCredential('test-org', 'SHARED_KEY', 'org-value'); + await writeProjectCredential('test-project', 'SHARED_KEY', 'project-value'); + await deleteProjectCredential('test-project', 'SHARED_KEY'); + + expect(await resolveProjectCredential('test-project', 'SHARED_KEY')).toBe('org-value'); + }); + + it('inheritance decrypts each tier with its own AAD under encryption', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', 'a'.repeat(64)); + + await writeOrgCredential('test-org', 'ORG_ENC_KEY', 'org-secret'); + await writeProjectCredential('test-project', 'PROJECT_ENC_KEY', 'project-secret'); + + const all = await resolveAllProjectCredentials('test-project'); + expect(all.ORG_ENC_KEY).toBe('org-secret'); + expect(all.PROJECT_ENC_KEY).toBe('project-secret'); + }); + }); }); diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index 3e1745090..2405bf6fa 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -117,6 +117,7 @@ export async function truncateAll() { agent_runs, pr_work_items, project_credentials, + org_credentials, project_integrations, agent_trigger_configs, agent_configs, diff --git a/tests/unit/anthropic/client.test.ts b/tests/unit/anthropic/client.test.ts new file mode 100644 index 000000000..d83fd3ede --- /dev/null +++ b/tests/unit/anthropic/client.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearAnthropicLimitsCache, + fetchClaudeSubscriptionLimits, +} from '../../../src/anthropic/client.js'; + +describe('fetchClaudeSubscriptionLimits', () => { + beforeEach(() => { + clearAnthropicLimitsCache(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearAnthropicLimitsCache(); + }); + + function makeFetchResponse(data: unknown, ok = true, status = 200) { + return Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + json: () => Promise.resolve(data), + }); + } + + // Reflects the actual /api/oauth/usage response shape + const sampleResponse = { + five_hour: { utilization: 33, resets_at: '2026-04-10T19:00:00.772723+00:00' }, + seven_day: { utilization: 3, resets_at: '2026-04-17T09:59:59.772747+00:00' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: { utilization: 44, resets_at: '2026-04-10T16:59:59.772755+00:00' }, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + }, + }; + + it('returns usage buckets on success', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-oauth-token'); + + expect(result).not.toBeNull(); + expect(result?.buckets).toHaveLength(3); + expect(result?.buckets[0]).toEqual({ + label: '5-Hour Window', + utilization: 33, + resetsAt: '2026-04-10T19:00:00.772723+00:00', + }); + expect(result?.buckets[1]).toEqual({ + label: '7-Day Overall', + utilization: 3, + resetsAt: '2026-04-17T09:59:59.772747+00:00', + }); + expect(result?.buckets[2]).toEqual({ + label: '7-Day Sonnet', + utilization: 44, + resetsAt: '2026-04-10T16:59:59.772755+00:00', + }); + }); + + it('parses extra_usage when enabled with values', async () => { + const responseWithExtra = { + ...sampleResponse, + extra_usage: { + is_enabled: true, + monthly_limit: 100, + used_credits: 42.5, + utilization: 42, + }, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(responseWithExtra) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: true, + monthlyLimit: 100, + usedCredits: 42.5, + utilization: 42, + }); + }); + + it('returns extra_usage as non-enabled when disabled', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.extraUsage).toEqual({ + isEnabled: false, + monthlyLimit: null, + usedCredits: null, + utilization: null, + }); + }); + + it('skips null buckets', async () => { + const sparseResponse = { + five_hour: null, + seven_day: { utilization: 10, resets_at: '2026-04-17T00:00:00Z' }, + seven_day_oauth_apps: null, + seven_day_opus: null, + seven_day_sonnet: null, + seven_day_cowork: null, + iguana_necktie: null, + extra_usage: null, + }; + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sparseResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('test-token'); + + expect(result?.buckets).toHaveLength(1); + expect(result?.buckets[0]?.label).toBe('7-Day Overall'); + }); + + it('masks the token showing only last 4 chars', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('sk-ant-oauth-abcd1234'); + + expect(result?.tokenMasked).toBe('****1234'); + }); + + it('sends Authorization header with Bearer token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-oauth-token', + }), + }), + ); + }); + + it('calls the oauth/usage endpoint', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-oauth-token'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.anthropic.com/api/oauth/usage', + expect.any(Object), + ); + }); + + it('returns null on 4xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 401) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('bad-token'); + + expect(result).toBeNull(); + }); + + it('returns null on 5xx response', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({}, false, 500) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns null on timeout', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new DOMException('Timeout', 'AbortError')); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).toBeNull(); + }); + + it('returns empty buckets when response has no recognized fields', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse({ something_unexpected: true }) as ReturnType, + ); + + const result = await fetchClaudeSubscriptionLimits('some-token'); + + expect(result).not.toBeNull(); + expect(result?.buckets).toEqual([]); + expect(result?.extraUsage).toBeNull(); + }); + + it('caches results for subsequent calls with the same token', async () => { + vi.mocked(fetch).mockReturnValueOnce( + makeFetchResponse(sampleResponse) as ReturnType, + ); + + await fetchClaudeSubscriptionLimits('my-token'); + // Second call should use cache (fetch called only once) + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not share cache between different tokens', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('token-a'); + await fetchClaudeSubscriptionLimits('token-b'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('clearAnthropicLimitsCache allows re-fetching', async () => { + vi.mocked(fetch) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType) + .mockReturnValueOnce(makeFetchResponse(sampleResponse) as ReturnType); + + await fetchClaudeSubscriptionLimits('my-token'); + clearAnthropicLimitsCache(); + await fetchClaudeSubscriptionLimits('my-token'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/api/routers/claudeCodeLimits.test.ts b/tests/unit/api/routers/claudeCodeLimits.test.ts new file mode 100644 index 000000000..7fcf506d2 --- /dev/null +++ b/tests/unit/api/routers/claudeCodeLimits.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockContext, createMockUser } from '../../../helpers/factories.js'; +import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; + +const { + mockListAllClaudeCodeCredentials, + mockGetProjectOwnCredential, + mockResolveOrgCredential, + mockFetchClaudeSubscriptionLimits, + mockVerifyProjectOrgAccess, + mockGetOrgMembership, +} = vi.hoisted(() => ({ + mockListAllClaudeCodeCredentials: vi.fn(), + mockGetProjectOwnCredential: vi.fn(), + mockResolveOrgCredential: vi.fn(), + mockFetchClaudeSubscriptionLimits: vi.fn(), + mockVerifyProjectOrgAccess: vi.fn(), + mockGetOrgMembership: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + listAllClaudeCodeCredentials: mockListAllClaudeCodeCredentials, + getProjectOwnCredential: mockGetProjectOwnCredential, +})); + +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + resolveOrgCredential: mockResolveOrgCredential, +})); + +vi.mock('../../../../src/anthropic/client.js', () => ({ + fetchClaudeSubscriptionLimits: mockFetchClaudeSubscriptionLimits, +})); + +vi.mock('../../../../src/api/routers/_shared/projectAccess.js', () => ({ + verifyProjectOrgAccess: mockVerifyProjectOrgAccess, +})); + +// Per-org actor-role refinement reads memberships through this repository. +// Default (no membership row) falls back to the global role in the home org. +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, +})); + +import { claudeCodeLimitsRouter } from '../../../../src/api/routers/claudeCodeLimits.js'; + +const createCaller = createCallerFor(claudeCodeLimitsRouter); + +const mockAdmin = createMockUser({ role: 'admin' }); + +const sampleLimits = { + tokenMasked: '****abcd', + buckets: [ + { label: '5-Hour Window', utilization: 33, resetsAt: '2026-04-10T19:00:00Z' }, + { label: '7-Day Overall', utilization: 3, resetsAt: '2026-04-17T10:00:00Z' }, + ], + extraUsage: { isEnabled: false, monthlyLimit: null, usedCredits: null, utilization: null }, +}; + +describe('claudeCodeLimitsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetOrgMembership.mockResolvedValue(null); + mockResolveOrgCredential.mockResolvedValue(null); + mockListAllClaudeCodeCredentials.mockResolvedValue([]); + mockGetProjectOwnCredential.mockResolvedValue(null); + mockVerifyProjectOrgAccess.mockResolvedValue(undefined); + }); + + describe('forOrg', () => { + it('rejects members (global role)', async () => { + const caller = createCaller(createMockContext({ role: 'member' })); + await expectTRPCError(caller.forOrg(), 'FORBIDDEN'); + }); + + it('rejects a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockAdmin, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.forOrg(), 'FORBIDDEN'); + }); + + it('returns empty array when no token source is configured', async () => { + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + expect(await caller.forOrg()).toEqual([]); + }); + + it('labels org and project sources with attribution', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockListAllClaudeCodeCredentials.mockResolvedValue([ + { projectId: 'proj-1', projectName: 'Project One', value: 'proj-token' }, + ]); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); + + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + const result = await caller.forOrg(); + + expect(result).toEqual([ + { scope: 'org', limits: sampleLimits }, + { + scope: 'project', + projectId: 'proj-1', + projectName: 'Project One', + limits: sampleLimits, + }, + ]); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('org-token'); + expect(mockFetchClaudeSubscriptionLimits).toHaveBeenCalledWith('proj-token'); + }); + + it('keeps a failed source with limits: null instead of dropping it', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(null); + + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + const result = await caller.forOrg(); + + expect(result).toEqual([{ scope: 'org', limits: null }]); + }); + + it('treats an undecryptable org token as absent instead of erroring', async () => { + mockResolveOrgCredential.mockRejectedValue(new Error('decrypt failed')); + + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + expect(await caller.forOrg()).toEqual([]); + }); + + it('scopes lookups to the effective org', async () => { + const caller = createCaller({ user: mockAdmin, effectiveOrgId: mockAdmin.orgId }); + await caller.forOrg(); + + expect(mockResolveOrgCredential).toHaveBeenCalledWith('org-1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(mockListAllClaudeCodeCredentials).toHaveBeenCalledWith('org-1'); + }); + }); + + describe('forProject', () => { + const member = createMockUser({ role: 'member' }); + + it('verifies project org access', async () => { + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + await caller.forProject({ projectId: 'p1' }); + expect(mockVerifyProjectOrgAccess).toHaveBeenCalledWith('p1', 'org-1'); + }); + + it('marks the project override active over the org token', async () => { + mockGetProjectOwnCredential.mockResolvedValue('proj-token'); + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); + + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + const result = await caller.forProject({ projectId: 'p1' }); + + expect(result).toEqual([ + { scope: 'project', projectId: 'p1', active: true, limits: sampleLimits }, + { scope: 'org', active: false, limits: sampleLimits }, + ]); + expect(mockGetProjectOwnCredential).toHaveBeenCalledWith('p1', 'CLAUDE_CODE_OAUTH_TOKEN'); + }); + + it('marks the org token active when no project override exists', async () => { + mockResolveOrgCredential.mockResolvedValue('org-token'); + mockFetchClaudeSubscriptionLimits.mockResolvedValue(sampleLimits); + + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + const result = await caller.forProject({ projectId: 'p1' }); + + expect(result).toEqual([{ scope: 'org', active: true, limits: sampleLimits }]); + }); + + it('returns empty array when nothing is configured', async () => { + const caller = createCaller({ user: member, effectiveOrgId: member.orgId }); + expect(await caller.forProject({ projectId: 'p1' })).toEqual([]); + }); + }); +}); diff --git a/tests/unit/api/routers/organization.test.ts b/tests/unit/api/routers/organization.test.ts index 69a4f0ca1..bd02cf4f5 100644 --- a/tests/unit/api/routers/organization.test.ts +++ b/tests/unit/api/routers/organization.test.ts @@ -1,14 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockGetOrganization, mockUpdateOrganization, mockListAllOrganizations } = vi.hoisted( - () => ({ - mockGetOrganization: vi.fn(), - mockUpdateOrganization: vi.fn(), - mockListAllOrganizations: vi.fn(), - }), -); +const { + mockGetOrganization, + mockUpdateOrganization, + mockListAllOrganizations, + mockListOrgCredentials, + mockListOrgCredentialsMeta, + mockWriteOrgCredential, + mockDeleteOrgCredential, + mockGetOrgMembership, +} = vi.hoisted(() => ({ + mockGetOrganization: vi.fn(), + mockUpdateOrganization: vi.fn(), + mockListAllOrganizations: vi.fn(), + mockListOrgCredentials: vi.fn(), + mockListOrgCredentialsMeta: vi.fn(), + mockWriteOrgCredential: vi.fn(), + mockDeleteOrgCredential: vi.fn(), + mockGetOrgMembership: vi.fn(), +})); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ getOrganization: mockGetOrganization, @@ -16,6 +28,24 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listAllOrganizations: mockListAllOrganizations, })); +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + listOrgCredentials: mockListOrgCredentials, + listOrgCredentialsMeta: mockListOrgCredentialsMeta, + writeOrgCredential: mockWriteOrgCredential, + deleteOrgCredential: mockDeleteOrgCredential, +})); + +// Per-org actor-role refinement reads memberships through this repository. +// Default (no membership row) falls back to the global role in the home org, +// so admin/member fixtures acting in their home org behave as their global role. +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, +})); + +vi.mock('../../../../src/sentry.js', () => ({ + captureException: vi.fn(), +})); + import { organizationRouter } from '../../../../src/api/routers/organization.js'; const createCaller = createCallerFor(organizationRouter); @@ -117,4 +147,114 @@ describe('organizationRouter', () => { await expect(caller.list()).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); }); + + describe('credentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + // No membership row → resolveActorRoleInOrg falls back to the global + // role in the home org. + mockGetOrgMembership.mockResolvedValue(null); + }); + + describe('list', () => { + it('returns masked values scoped to the effective org', async () => { + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_1234567890abcd', name: 'GH' }, + { envVarKey: 'SHORT_KEY', value: 'short', name: null }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list(); + + expect(mockListOrgCredentials).toHaveBeenCalledWith('org-1'); + expect(result).toEqual([ + { + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + name: 'GH', + isConfigured: true, + maskedValue: '****abcd', + }, + { envVarKey: 'SHORT_KEY', name: null, isConfigured: true, maskedValue: '****' }, + ]); + }); + + it('falls back to metadata when decryption fails', async () => { + mockListOrgCredentials.mockRejectedValue(new Error('decrypt failed')); + mockListOrgCredentialsMeta.mockResolvedValue([{ envVarKey: 'KEY_A', name: 'A' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list(); + + expect(result).toEqual([ + { envVarKey: 'KEY_A', name: 'A', isConfigured: true, maskedValue: '****' }, + ]); + }); + + it('throws FORBIDDEN for a global member', async () => { + const memberUser = createMockUser({ role: 'member' }); + const caller = createCaller({ user: memberUser, effectiveOrgId: memberUser.orgId }); + await expectTRPCError(caller.credentials.list(), 'FORBIDDEN'); + }); + + it('throws FORBIDDEN for a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.credentials.list(), 'FORBIDDEN'); + }); + }); + + describe('set', () => { + it('writes to the effective org', async () => { + mockWriteOrgCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.credentials.set({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + value: 'ghp_new', + name: 'GH', + }); + + expect(mockWriteOrgCredential).toHaveBeenCalledWith( + 'org-1', + 'GITHUB_TOKEN_IMPLEMENTER', + 'ghp_new', + 'GH', + ); + }); + + it('rejects invalid env var keys', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.credentials.set({ envVarKey: 'lower_case', value: 'x' }), + ).rejects.toThrow(); + expect(mockWriteOrgCredential).not.toHaveBeenCalled(); + }); + + it('throws FORBIDDEN for a global member', async () => { + const memberUser = createMockUser({ role: 'member' }); + const caller = createCaller({ user: memberUser, effectiveOrgId: memberUser.orgId }); + await expectTRPCError( + caller.credentials.set({ envVarKey: 'SOME_KEY', value: 'x' }), + 'FORBIDDEN', + ); + }); + }); + + describe('delete', () => { + it('deletes from the effective org', async () => { + mockDeleteOrgCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.credentials.delete({ envVarKey: 'SOME_KEY' }); + + expect(mockDeleteOrgCredential).toHaveBeenCalledWith('org-1', 'SOME_KEY'); + }); + + it('throws FORBIDDEN for a global admin who is only a member in the effective org', async () => { + mockGetOrgMembership.mockResolvedValue({ role: 'member' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: 'other-org' }); + await expectTRPCError(caller.credentials.delete({ envVarKey: 'SOME_KEY' }), 'FORBIDDEN'); + }); + }); + }); }); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 8bffd60d9..377113a36 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -25,6 +25,8 @@ const { mockListProjectCredentialsMeta, mockWriteProjectCredential, mockDeleteProjectCredential, + mockListOrgCredentials, + mockListOrgCredentialsMeta, mockCaptureException, } = vi.hoisted(() => ({ mockListProjectsForOrg: vi.fn(), @@ -40,6 +42,8 @@ const { mockListProjectCredentialsMeta: vi.fn(), mockWriteProjectCredential: vi.fn(), mockDeleteProjectCredential: vi.fn(), + mockListOrgCredentials: vi.fn(), + mockListOrgCredentialsMeta: vi.fn(), mockCaptureException: vi.fn(), })); @@ -65,6 +69,11 @@ vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ deleteProjectCredential: mockDeleteProjectCredential, })); +vi.mock('../../../../src/db/repositories/orgCredentialsRepository.js', () => ({ + listOrgCredentials: mockListOrgCredentials, + listOrgCredentialsMeta: mockListOrgCredentialsMeta, +})); + vi.mock('../../../../src/sentry.js', () => ({ captureException: mockCaptureException, })); @@ -593,6 +602,11 @@ describe('projectsRouter', () => { // ============================================================================ describe('credentials', () => { + beforeEach(() => { + mockListOrgCredentials.mockResolvedValue([]); + mockListOrgCredentialsMeta.mockResolvedValue([]); + }); + describe('list', () => { it('throws UNAUTHORIZED when not authenticated', async () => { const caller = createCaller({ user: null, effectiveOrgId: null }); @@ -615,16 +629,73 @@ describe('projectsRouter', () => { name: 'OpenRouter Key', isConfigured: true, maskedValue: '****5678', + source: 'project', + hasOrgFallback: false, }, { envVarKey: 'SHORT', name: null, isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, ]); }); + it('appends inherited org rows not overridden by a project row', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'OPENROUTER_API_KEY', name: null, value: 'sk-or-project-1234567' }, + ]); + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: 'GH', value: 'ghp_org_1234567890' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result).toEqual([ + { + envVarKey: 'OPENROUTER_API_KEY', + name: null, + isConfigured: true, + maskedValue: '****4567', + source: 'project', + hasOrgFallback: false, + }, + { + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + name: 'GH', + isConfigured: true, + maskedValue: '****7890', + source: 'org', + hasOrgFallback: false, + }, + ]); + }); + + it('marks a project row that shadows an org value with hasOrgFallback', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: null, value: 'ghp_project_override1' }, + ]); + mockListOrgCredentials.mockResolvedValue([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', name: 'GH', value: 'ghp_org_1234567890' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + // Single row per key: project wins, org row filtered out + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', + source: 'project', + hasOrgFallback: true, + }); + }); + it('calls listProjectCredentials with projectId', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockListProjectCredentials.mockResolvedValue([]); @@ -663,17 +734,55 @@ describe('projectsRouter', () => { name: 'GH Implementer', isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, { envVarKey: 'OPENROUTER_API_KEY', name: null, isConfigured: true, maskedValue: '****', + source: 'project', + hasOrgFallback: false, }, ]); expect(mockListProjectCredentialsMeta).toHaveBeenCalledWith('p1'); }); + it('meta fallback also merges org-tier metadata', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListProjectCredentials.mockRejectedValueOnce(new Error('bad key')); + mockListProjectCredentialsMeta.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', name: null }, + ]); + mockListOrgCredentialsMeta.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', name: 'Org shadow' }, + { envVarKey: 'ORG_ONLY_KEY', name: 'Org only' }, + ]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.credentials.list({ projectId: 'p1' }); + + expect(result).toEqual([ + { + envVarKey: 'PROJECT_KEY', + name: null, + isConfigured: true, + maskedValue: '****', + source: 'project', + hasOrgFallback: true, + }, + { + envVarKey: 'ORG_ONLY_KEY', + name: 'Org only', + isConfigured: true, + maskedValue: '****', + source: 'org', + hasOrgFallback: false, + }, + ]); + }); + it('reports decryption failure to Sentry', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); const decryptionError = new Error('bad key'); diff --git a/tests/unit/db/repositories/credentialsRepository.test.ts b/tests/unit/db/repositories/credentialsRepository.test.ts index d850fc69e..7b4d33e2c 100644 --- a/tests/unit/db/repositories/credentialsRepository.test.ts +++ b/tests/unit/db/repositories/credentialsRepository.test.ts @@ -7,6 +7,8 @@ vi.mock('../../../../src/db/client.js', () => mockDbClientModule); import { getIntegrationProvider, + getProjectOwnCredential, + listAllClaudeCodeCredentials, listProjectCredentialsMeta, resolveAllProjectCredentials, resolveProjectCredential, @@ -27,7 +29,25 @@ describe('credentialsRepository', () => { expect(result).toBe('ghp_impl_token'); }); - it('returns null when not found', async () => { + it('does not query the org tier when the project row exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'ghp_impl_token' }]); + + await resolveProjectCredential('proj1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('falls back to the org credential when the project row is missing', async () => { + // Project miss, then org join hit + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-shared-token', orgId: 'org-1' }]); + + const result = await resolveProjectCredential('proj1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(result).toBe('org-shared-token'); + expect(mockDb.db.select).toHaveBeenCalledTimes(2); + }); + + it('returns null when neither project nor org row exists', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); mockDb.chain.where.mockResolvedValueOnce([]); const result = await resolveProjectCredential('proj1', 'MISSING_KEY'); @@ -46,13 +66,28 @@ describe('credentialsRepository', () => { const result = await resolveProjectCredential('proj1', 'SOME_KEY'); expect(result).toBe('my-secret'); }); + + it('uses orgId as AAD when decrypting an org-tier fallback value', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'org-1'); + mockDb.chain.where.mockResolvedValueOnce([]); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue, orgId: 'org-1' }]); + + const result = await resolveProjectCredential('proj1', 'SOME_KEY'); + expect(result).toBe('org-secret'); + }); }); describe('resolveAllProjectCredentials', () => { it('returns all project credentials as key-value map', async () => { - // First select: project existence check - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); - // Second select: project_credentials rows + // First select: project existence check (now includes orgId) + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + // Second select: org_credentials rows (none) + mockDb.chain.where.mockResolvedValueOnce([]); + // Third select: project_credentials rows mockDb.chain.where.mockResolvedValueOnce([ { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_impl' }, { envVarKey: 'TRELLO_API_KEY', value: 'trello-key' }, @@ -67,10 +102,49 @@ describe('credentialsRepository', () => { }); }); + it('merges org credentials underneath project credentials (project wins)', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'org-shared' }, + { envVarKey: 'SENTRY_API_TOKEN', value: 'org-sentry' }, + ]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'project-override' }, + ]); + + const result = await resolveAllProjectCredentials('proj1'); + expect(result).toEqual({ + GITHUB_TOKEN_IMPLEMENTER: 'project-override', + SENTRY_API_TOKEN: 'org-sentry', + }); + }); + + it('decrypts each tier with its own AAD (orgId vs projectId)', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'ORG_ONLY_KEY', value: encryptCredential('org-value', 'org-1') }, + ]); + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'PROJECT_KEY', value: encryptCredential('project-value', 'proj1') }, + ]); + + const result = await resolveAllProjectCredentials('proj1'); + expect(result).toEqual({ + ORG_ONLY_KEY: 'org-value', + PROJECT_KEY: 'project-value', + }); + }); + it('returns empty object when no credentials', async () => { // Project exists - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); - // No credentials + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + // No org credentials + mockDb.chain.where.mockResolvedValueOnce([]); + // No project credentials mockDb.chain.where.mockResolvedValueOnce([]); const result = await resolveAllProjectCredentials('proj1'); @@ -86,14 +160,14 @@ describe('credentialsRepository', () => { ); }); - it('issues two queries: project existence check then project_credentials', async () => { - mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1' }]); + it('issues three queries: project check, org_credentials, project_credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ id: 'proj1', orgId: 'org-1' }]); + mockDb.chain.where.mockResolvedValueOnce([]); mockDb.chain.where.mockResolvedValueOnce([{ envVarKey: 'KEY1', value: 'val1' }]); await resolveAllProjectCredentials('proj1'); - // One select for project existence, one for project_credentials - expect(mockDb.db.select).toHaveBeenCalledTimes(2); + expect(mockDb.db.select).toHaveBeenCalledTimes(3); }); }); @@ -121,6 +195,65 @@ describe('credentialsRepository', () => { }); }); + describe('getProjectOwnCredential', () => { + it('returns the project-tier value without org fallback', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'proj-token' }]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBe('proj-token'); + // Single query — never touches the org tier + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns null when the project row is absent (no org fallback)', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBeNull(); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns null instead of throwing when decryption fails', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + // Encrypted with a different AAD — decryption with proj1 fails + const foreign = encryptCredential('secret', 'other-project'); + mockDb.chain.where.mockResolvedValueOnce([{ value: foreign }]); + + const result = await getProjectOwnCredential('proj1', 'CLAUDE_CODE_OAUTH_TOKEN'); + expect(result).toBeNull(); + }); + }); + + describe('listAllClaudeCodeCredentials', () => { + it('skips undecryptable rows instead of failing the whole query', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + mockDb.chain.where.mockResolvedValueOnce([ + { + projectId: 'proj-good', + projectName: 'Good', + value: encryptCredential('good-token', 'proj-good'), + }, + { + projectId: 'proj-bad', + projectName: 'Bad', + // Encrypted with the wrong AAD — decryption throws for proj-bad + value: encryptCredential('bad-token', 'some-other-project'), + }, + ]); + + const result = await listAllClaudeCodeCredentials('org-1'); + expect(result).toEqual([ + { projectId: 'proj-good', projectName: 'Good', value: 'good-token' }, + ]); + }); + }); + describe('getIntegrationProvider', () => { it('returns provider when integration is found', async () => { mockDb.chain.where.mockResolvedValueOnce([{ provider: 'trello' }]); diff --git a/tests/unit/db/repositories/orgCredentialsRepository.test.ts b/tests/unit/db/repositories/orgCredentialsRepository.test.ts new file mode 100644 index 000000000..1ceb7e5b3 --- /dev/null +++ b/tests/unit/db/repositories/orgCredentialsRepository.test.ts @@ -0,0 +1,154 @@ +import { randomBytes } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +import { + deleteOrgCredential, + listOrgCredentials, + listOrgCredentialsMeta, + resolveAllOrgCredentials, + resolveOrgCredential, + writeOrgCredential, +} from '../../../../src/db/repositories/orgCredentialsRepository.js'; + +describe('orgCredentialsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb({ withUpsert: true }); + }); + + describe('resolveOrgCredential', () => { + it('returns decrypted value when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ value: 'org-shared-token' }]); + + const result = await resolveOrgCredential('org-1', 'GITHUB_TOKEN_IMPLEMENTER'); + expect(result).toBe('org-shared-token'); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveOrgCredential('org-1', 'MISSING_KEY'); + expect(result).toBeNull(); + }); + + it('round-trips encryption with orgId as AAD when CREDENTIAL_MASTER_KEY is set', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'org-1'); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue }]); + + const result = await resolveOrgCredential('org-1', 'SOME_KEY'); + expect(result).toBe('org-secret'); + }); + + it('fails to decrypt a value encrypted with a different org AAD', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + const { encryptCredential } = await import('../../../../src/db/crypto.js'); + const encryptedValue = encryptCredential('org-secret', 'other-org'); + mockDb.chain.where.mockResolvedValueOnce([{ value: encryptedValue }]); + + await expect(resolveOrgCredential('org-1', 'SOME_KEY')).rejects.toThrow(); + }); + }); + + describe('resolveAllOrgCredentials', () => { + it('returns all org credentials as key-value map', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'ghp_shared' }, + { envVarKey: 'CLAUDE_CODE_OAUTH_TOKEN', value: 'oat-token' }, + ]); + + const result = await resolveAllOrgCredentials('org-1'); + expect(result).toEqual({ + GITHUB_TOKEN_IMPLEMENTER: 'ghp_shared', + CLAUDE_CODE_OAUTH_TOKEN: 'oat-token', + }); + }); + + it('returns empty object when no credentials', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await resolveAllOrgCredentials('org-1'); + expect(result).toEqual({}); + }); + }); + + describe('writeOrgCredential', () => { + it('encrypts with orgId as AAD before upserting', async () => { + const key = randomBytes(32).toString('hex'); + vi.stubEnv('CREDENTIAL_MASTER_KEY', key); + + await writeOrgCredential('org-1', 'SOME_KEY', 'plaintext-secret', 'Label'); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + const inserted = mockDb.chain.values.mock.calls[0][0] as { + orgId: string; + envVarKey: string; + value: string; + name: string | null; + }; + expect(inserted.orgId).toBe('org-1'); + expect(inserted.envVarKey).toBe('SOME_KEY'); + expect(inserted.name).toBe('Label'); + expect(inserted.value).not.toBe('plaintext-secret'); + + const { decryptCredential } = await import('../../../../src/db/crypto.js'); + expect(decryptCredential(inserted.value, 'org-1')).toBe('plaintext-secret'); + }); + + it('stores plaintext when no master key is configured', async () => { + vi.stubEnv('CREDENTIAL_MASTER_KEY', ''); + + await writeOrgCredential('org-1', 'SOME_KEY', 'plaintext-secret'); + + const inserted = mockDb.chain.values.mock.calls[0][0] as { value: string }; + expect(inserted.value).toBe('plaintext-secret'); + }); + }); + + describe('listOrgCredentials', () => { + it('returns decrypted rows', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'KEY_A', value: 'value-a', name: 'A' }, + { envVarKey: 'KEY_B', value: 'value-b', name: null }, + ]); + + const result = await listOrgCredentials('org-1'); + expect(result).toEqual([ + { envVarKey: 'KEY_A', value: 'value-a', name: 'A' }, + { envVarKey: 'KEY_B', value: 'value-b', name: null }, + ]); + }); + }); + + describe('listOrgCredentialsMeta', () => { + it('returns envVarKey and name without values', async () => { + mockDb.chain.where.mockResolvedValueOnce([ + { envVarKey: 'KEY_A', name: 'A' }, + { envVarKey: 'KEY_B', name: null }, + ]); + + const result = await listOrgCredentialsMeta('org-1'); + expect(result).toEqual([ + { envVarKey: 'KEY_A', name: 'A' }, + { envVarKey: 'KEY_B', name: null }, + ]); + }); + }); + + describe('deleteOrgCredential', () => { + it('issues a delete', async () => { + await deleteOrgCredential('org-1', 'KEY_A'); + expect(mockDb.db.delete).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/integrations/auth-header-provenance.test.ts b/tests/unit/integrations/auth-header-provenance.test.ts index 07730bc6d..e9f0f7877 100644 --- a/tests/unit/integrations/auth-header-provenance.test.ts +++ b/tests/unit/integrations/auth-header-provenance.test.ts @@ -49,6 +49,11 @@ const ACCEPT_LIST: Array<{ path: string; reason: string }> = [ path: 'src/sentry/client.ts', reason: 'Sentry client auth — alerting integration is out of spec 009 scope.', }, + { + path: 'src/anthropic/client.ts', + reason: + 'Anthropic OAuth usage endpoint — LLM subscription API, not a PM/SCM/alerting integration; outside spec 009 scope.', + }, ]; // Patterns that suggest manual auth-header assembly: diff --git a/vitest.config.ts b/vitest.config.ts index 44cef87da..ce606e496 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -159,6 +159,7 @@ export default defineConfig({ 'tests/unit/openrouter/**/*.test.ts', 'tests/unit/sentry/**/*.test.ts', 'tests/unit/docker/**/*.test.ts', + 'tests/unit/anthropic/**/*.test.ts', 'tests/unit/*.test.ts', ], ...sharedTest, diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 7ade0e166..ae411c3f3 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight, FolderGit2, + KeyRound, Plus, Settings, Users, @@ -44,6 +45,7 @@ const globalNav = [ const settingsNav = [ { to: '/settings/general' as const, label: 'General', icon: Settings }, + { to: '/settings/credentials' as const, label: 'Credentials', icon: KeyRound }, { to: '/settings/users' as const, label: 'Users', icon: Users }, ]; diff --git a/web/src/components/projects/claude-code-limits-preview.tsx b/web/src/components/projects/claude-code-limits-preview.tsx new file mode 100644 index 000000000..f37321f4a --- /dev/null +++ b/web/src/components/projects/claude-code-limits-preview.tsx @@ -0,0 +1,33 @@ +/** + * Picker preview for the project engine tab: shows Claude Code subscription + * usage for each credential candidate this project can use — its own override, + * the inherited org token, and the server env token — with the active one + * marked, so the operator can compare limits before choosing which token the + * project should run on. Renders nothing when no candidate is configured. + */ + +import { useQuery } from '@tanstack/react-query'; +import { ClaudeUsageCard } from '@/components/shared/claude-usage-card.js'; +import { trpc } from '@/lib/trpc.js'; + +export function ClaudeCodeLimitsPreview({ projectId }: { projectId: string }) { + const limitsQuery = useQuery({ + ...trpc.claudeCodeLimits.forProject.queryOptions({ projectId }), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const sources = limitsQuery.data ?? []; + if (limitsQuery.isError || sources.length === 0) return null; + + return ( +
+

+ Subscription usage per available token — the active one is what this project runs on. +

+ {sources.map((source) => ( + + ))} +
+ ); +} diff --git a/web/src/components/projects/project-harness-form.tsx b/web/src/components/projects/project-harness-form.tsx index abf65152c..20d842552 100644 --- a/web/src/components/projects/project-harness-form.tsx +++ b/web/src/components/projects/project-harness-form.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { HelpCircle } from 'lucide-react'; import { useState } from 'react'; +import { ClaudeCodeLimitsPreview } from '@/components/projects/claude-code-limits-preview.js'; import { ENGINE_SECRETS } from '@/components/projects/engine-secrets.js'; import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; @@ -294,17 +295,21 @@ export function ProjectHarnessForm({ project }: { project: Project }) { const description = secret.description + (sharedNote ? ` · ${sharedNote}` : ''); return ( - c.envVarKey === secret.envVarKey, +
+ c.envVarKey === secret.envVarKey, + )} + /> + {secret.envVarKey === 'CLAUDE_CODE_OAUTH_TOKEN' && ( + )} - /> +
); })} diff --git a/web/src/components/projects/project-secret-field.tsx b/web/src/components/projects/project-secret-field.tsx index cd1c58f55..a32d89070 100644 --- a/web/src/components/projects/project-secret-field.tsx +++ b/web/src/components/projects/project-secret-field.tsx @@ -16,6 +16,10 @@ export interface ProjectCredentialMeta { name: string | null; isConfigured: boolean; maskedValue: string; + /** Which tier the value comes from; 'org' = inherited, no project row exists. */ + source?: 'project' | 'org'; + /** True when a project row shadows an org-level value with the same key. */ + hasOrgFallback?: boolean; } /** @@ -92,9 +96,15 @@ export function ProjectSecretField({
{credential?.isConfigured ? ( - - {credential.maskedValue} - + credential.source === 'org' ? ( + + Inherited from org {credential.maskedValue} + + ) : ( + + {credential.maskedValue} + + ) ) : ( not configured @@ -108,7 +118,13 @@ export function ProjectSecretField({ type="password" value={value} onChange={(e) => setValue(e.target.value)} - placeholder={credential?.isConfigured ? 'Enter new value to update...' : placeholder} + placeholder={ + credential?.isConfigured + ? credential.source === 'org' + ? 'Override organization value...' + : 'Enter new value to update...' + : placeholder + } autoComplete="off" className="flex-1" /> @@ -130,13 +146,17 @@ export function ProjectSecretField({ {isVerifying ? : 'Verify'} )} - {credential?.isConfigured && ( + {credential?.isConfigured && credential.source !== 'org' && ( + {credential?.isConfigured && ( + + )} +
+ {saveMutation.isError && ( +

{saveMutation.error.message}

+ )} + {deleteMutation.isError && ( +

{deleteMutation.error.message}

+ )} + {savedFeedback && ( +
+ + Saved +
+ )} + + ); +} diff --git a/web/src/components/shared/claude-usage-card.tsx b/web/src/components/shared/claude-usage-card.tsx new file mode 100644 index 000000000..d7f033db8 --- /dev/null +++ b/web/src/components/shared/claude-usage-card.tsx @@ -0,0 +1,116 @@ +/** + * Shared display for Claude Code subscription usage of one credential source. + * Consumed by the org credentials settings page and the project engine tab + * picker preview. Pure display — data comes from claudeCodeLimits.* queries. + */ + +import { Badge } from '@/components/ui/badge.js'; + +export interface ClaudeUsageBucket { + label: string; + utilization: number; + resetsAt: string; +} + +export interface ClaudeUsageLimits { + tokenMasked: string; + buckets: ClaudeUsageBucket[]; + extraUsage: { + isEnabled: boolean; + monthlyLimit: number | null; + usedCredits: number | null; + utilization: number | null; + } | null; +} + +export interface ClaudeUsageSource { + scope: 'org' | 'project'; + projectId?: string; + projectName?: string; + active?: boolean; + limits: ClaudeUsageLimits | null; +} + +function formatResetDate(resetsAt: string): string { + if (!resetsAt) return ''; + try { + const date = new Date(resetsAt); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return resetsAt; + } +} + +function utilizationColor(pct: number): string { + if (pct >= 90) return 'bg-red-500'; + if (pct >= 70) return 'bg-yellow-500'; + return 'bg-emerald-500'; +} + +export function sourceLabel(source: ClaudeUsageSource): string { + if (source.scope === 'org') return 'Organization'; + return source.projectName ? `Project: ${source.projectName}` : 'This project'; +} + +export function ClaudeUsageCard({ source }: { source: ClaudeUsageSource }) { + return ( +
+
+ {sourceLabel(source)} + {source.limits && ( + + {source.limits.tokenMasked} + + )} + {source.active && ( + + active + + )} +
+ {!source.limits && ( +

+ Usage unavailable — token may be invalid or the usage API unreachable. +

+ )} + {source.limits?.buckets.length === 0 && ( +

No usage data

+ )} + {source.limits?.buckets.map((bucket) => ( +
+
+ {bucket.label} + {bucket.utilization}% +
+
+
+
+
+ Resets {formatResetDate(bucket.resetsAt)} +
+
+ ))} + {source.limits?.extraUsage?.isEnabled && ( +
+ Extra usage enabled + {source.limits.extraUsage.usedCredits != null && + source.limits.extraUsage.monthlyLimit != null && ( + + {' '} + — ${source.limits.extraUsage.usedCredits.toFixed(2)} / $ + {source.limits.extraUsage.monthlyLimit.toFixed(2)} + + )} +
+ )} +
+ ); +} diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index 509514a29..fe73a646a 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -16,6 +16,7 @@ import { projectWorkRoute } from './projects/$projectId.work.js'; import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; +import { settingsCredentialsRoute } from './settings/credentials.js'; import { settingsGeneralRoute } from './settings/general.js'; import { settingsProfileRoute } from './settings/profile.js'; import { settingsUsersRoute } from './settings/users.js'; @@ -36,6 +37,7 @@ export const routeTree = rootRoute.addChildren([ projectLifecycleRoute, ]), settingsGeneralRoute, + settingsCredentialsRoute, settingsProfileRoute, settingsUsersRoute, globalDefinitionsRoute, diff --git a/web/src/routes/settings/credentials.tsx b/web/src/routes/settings/credentials.tsx new file mode 100644 index 000000000..23921b3dd --- /dev/null +++ b/web/src/routes/settings/credentials.tsx @@ -0,0 +1,177 @@ +/** + * Organization credentials page. Credentials set here are inherited by every + * project in the organization; a project-level credential with the same env + * var key overrides the org value for that project. + */ + +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useMemo, useState } from 'react'; +import { buildOrgCredentialCatalog } from '@/components/settings/org-credential-catalog.js'; +import { OrgSecretField } from '@/components/settings/org-secret-field.js'; +import { ClaudeUsageCard } from '@/components/shared/claude-usage-card.js'; +import { Input } from '@/components/ui/input.js'; +import { trpc } from '@/lib/trpc.js'; +import { rootRoute } from '../__root.js'; + +/** + * Claude Code subscription usage for every token source in the org: the + * shared org credential, per-project overrides, and the server env token. + * Hidden entirely when no source is configured. + */ +function ClaudeCodeUsageSection() { + const limitsQuery = useQuery({ + ...trpc.claudeCodeLimits.forOrg.queryOptions(), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const sources = limitsQuery.data ?? []; + if (limitsQuery.isError || sources.length === 0) return null; + + return ( +
+
+

Claude Code Usage

+

+ Subscription limits for each configured Claude Code OAuth token. +

+
+
+ {sources.map((source) => ( + + ))} +
+
+ ); +} + +const ENV_VAR_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +function CustomCredentialSection({ + credentials, + knownKeys, +}: { + credentials: { + envVarKey: string; + name: string | null; + isConfigured: boolean; + maskedValue: string; + }[]; + knownKeys: Set; +}) { + const [newKey, setNewKey] = useState(''); + const customCredentials = credentials.filter((c) => !knownKeys.has(c.envVarKey)); + const [pendingKeys, setPendingKeys] = useState([]); + + const visiblePending = pendingKeys.filter( + (key) => !customCredentials.some((c) => c.envVarKey === key), + ); + const keyValid = ENV_VAR_KEY_PATTERN.test(newKey); + + return ( +
+
+

Custom

+

+ Any other environment variable to share with every project in this organization. +

+
+ {customCredentials.map((credential) => ( + + ))} + {visiblePending.map((key) => ( + + ))} +
+ setNewKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_NAME" + className="flex-1 font-mono" + /> + +
+ {newKey && !keyValid && ( +

+ Key must be UPPER_SNAKE_CASE (letters, digits, underscores; not starting with a digit). +

+ )} +
+ ); +} + +function OrgCredentialsPage() { + const credentialsQuery = useQuery(trpc.organization.credentials.list.queryOptions()); + const { sections, knownKeys } = useMemo(buildOrgCredentialCatalog, []); + const credentials = credentialsQuery.data ?? []; + const byKey = new Map(credentials.map((c) => [c.envVarKey, c])); + + return ( +
+
+

Organization Credentials

+

+ Shared by every project in this organization. A credential set on a project with the same + key overrides the organization value for that project. +

+
+ + {credentialsQuery.isError && ( +

+ {credentialsQuery.error.message.includes('FORBIDDEN') || + credentialsQuery.error.message.includes('Admin') + ? 'Organization admin access is required to manage shared credentials.' + : credentialsQuery.error.message} +

+ )} + + {!credentialsQuery.isError && + sections.map((section) => ( +
+

{section.title}

+ {section.entries.map((entry) => ( + + ))} +
+ ))} + + {!credentialsQuery.isError && ( + + )} + + {!credentialsQuery.isError && } +
+ ); +} + +export const settingsCredentialsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings/credentials', + component: OrgCredentialsPage, +});