diff --git a/cloud/apps/billing/catalog/catalog.json b/cloud/apps/billing/catalog/catalog.json index 29c52b6af11..a4d157c622d 100644 --- a/cloud/apps/billing/catalog/catalog.json +++ b/cloud/apps/billing/catalog/catalog.json @@ -73,7 +73,7 @@ "name": "Enterprise", "sort": 20, "public": true, - "currentVersion": 1, + "currentVersion": 2, "contactUrl": "https://orca.dev/enterprise", "priceLabel": "Custom", "versions": [ @@ -87,6 +87,25 @@ { "id": "egress_controls", "params": {}, "text": "Network egress controls" }, { "id": "custom_limits", "params": {}, "text": "Custom limits and pricing" } ] + }, + { + "version": 2, + "note": "Enterprise features as entitlements: team audit log, SSO, SCIM, required MFA. Sandbox hours and limits still come from the team's subscription.", + "entitlements": { + "audit_log": { "value": true }, + "sso": { "value": true }, + "scim": { "value": true }, + "mfa_enforcement": { "value": true } + }, + "policy": {}, + "features": [ + { "id": "byoc", "params": {}, "text": "Run sandboxes in your own cloud" }, + { "id": "sso_scim", "params": {}, "text": "SSO and SCIM" }, + { "id": "mfa_enforcement", "params": {}, "text": "Require multi-factor authentication" }, + { "id": "audit_log", "params": {}, "text": "Team audit log" }, + { "id": "egress_controls", "params": {}, "text": "Network egress controls" }, + { "id": "custom_limits", "params": {}, "text": "Custom limits and pricing" } + ] } ] }, diff --git a/cloud/apps/billing/src/entitlement-keys.ts b/cloud/apps/billing/src/entitlement-keys.ts index f2d946bce10..a1e7447eb8d 100644 --- a/cloud/apps/billing/src/entitlement-keys.ts +++ b/cloud/apps/billing/src/entitlement-keys.ts @@ -12,11 +12,19 @@ export const ENTITLEMENT_KEYS = [ 'max_concurrent_sandboxes', 'max_concurrent_sandboxes_per_member', 'machine_sizes', - 'overage_allowed' + 'overage_allowed', + // Enterprise features (boolean). Each gates a surface in the service that owns it: + // viewing the team audit log (recording never depends on it), SAML single sign-on, SCIM + // directory sync, and requiring multi-factor authentication for a team. + 'audit_log', + 'sso', + 'scim', + 'mfa_enforcement' ] as const export type EntitlementKey = (typeof ENTITLEMENT_KEYS)[number] -type Aggregation = 'sum' | 'max' | 'union' | 'or' +// `feature`: a boolean plan feature; on when any grant holds it as true, never per seat. +type Aggregation = 'sum' | 'max' | 'union' | 'or' | 'feature' const NonNegative = z.number().finite().nonnegative().max(1_000_000) const NonNegativeInt = z.number().int().nonnegative().max(1_000_000) @@ -27,9 +35,21 @@ export const ENTITLEMENT_REGISTRY: Record + export function isEntitlementKey(value: string): value is EntitlementKey { return (ENTITLEMENT_KEYS as readonly string[]).includes(value) } @@ -62,6 +82,7 @@ export type EffectiveValues = { maxConcurrentSandboxesPerMember: number machineSizes: MachineSize[] overageAllowed: boolean + features: FeatureFlags } /** Combines grant values by each key's rule. Per-seat values multiply by max(1, seats). */ @@ -70,6 +91,7 @@ export function aggregateGrants(grants: readonly GrantValue[]): EffectiveValues const maxes: Record = {} const sizes = new Set() let overage = false + const features = Object.fromEntries(FEATURE_KEYS.map((key) => [key, false])) as FeatureFlags for (const grant of grants) { const multiplier = grant.perSeat ? Math.max(1, grant.seats ?? 1) : 1 const definition = ENTITLEMENT_REGISTRY[grant.key] @@ -88,6 +110,11 @@ export function aggregateGrants(grants: readonly GrantValue[]): EffectiveValues case 'or': overage = overage || grant.value === true break + case 'feature': + if (grant.value === true && (FEATURE_KEYS as readonly string[]).includes(grant.key)) { + features[grant.key as FeatureKey] = true + } + break } } return { @@ -98,6 +125,7 @@ export function aggregateGrants(grants: readonly GrantValue[]): EffectiveValues maxConcurrentSandboxesPerMember: maxes.max_concurrent_sandboxes_per_member ?? 0, // Registry order, so the list is stable whatever order grants came in. machineSizes: MACHINE_SIZES.filter((size) => sizes.has(size)), - overageAllowed: overage + overageAllowed: overage, + features } } diff --git a/cloud/apps/billing/src/features.ts b/cloud/apps/billing/src/features.ts new file mode 100644 index 00000000000..5b85e515088 --- /dev/null +++ b/cloud/apps/billing/src/features.ts @@ -0,0 +1,37 @@ +// Feature entitlements: boolean switches a plan turns on (Enterprise: the team audit log, SSO, +// SCIM, required MFA). +// They gate a surface and nothing else, so they stay out of the Entitlement object: the start +// check, usage metering and the shadow comparison never see them. Resolved from the account's +// live grants in every mode but 'off' (where nothing projects grants, so nothing is on). The +// legacy computation has no features, so there is nothing for shadow mode to compare. +// +// POST /v1/internal/entitlements/features {accountId} -> { features: { audit_log, sso, scim, mfa_enforcement } } +// Callers: the control plane (CONTROL_PLANE_TOKEN) and auth (MEMBERSHIP_SYNC_TOKEN). Both fail closed. +import { z } from 'zod' +import { FEATURE_KEYS, type FeatureFlags } from './entitlement-keys' +import { entitlementsMode } from './entitlements-mode' +import type { Env } from './env' +import { grantsForSubject, liveAt } from './grants' + +export const FeaturesRequestSchema = z.object({ accountId: z.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/) }).strict() + +const NONE = Object.fromEntries(FEATURE_KEYS.map((key) => [key, false])) as FeatureFlags + +/** A feature is on while any active, in-window grant holds it as true. Held (unpaid) grants do not count. */ +export async function featuresFor(env: Pick, accountId: string, now: number): Promise { + if (entitlementsMode(env) === 'off') { + return { ...NONE } + } + const flags = { ...NONE } + for (const row of await grantsForSubject(env.BILLING_DB, accountId)) { + if (row.state !== 'active' || !liveAt(row, now)) { + continue + } + for (const key of FEATURE_KEYS) { + if (row.entitlement_key === key && row.value_json === 'true') { + flags[key] = true + } + } + } + return flags +} diff --git a/cloud/apps/billing/src/grants.ts b/cloud/apps/billing/src/grants.ts index ea56cbb8a28..4f9b55b2637 100644 --- a/cloud/apps/billing/src/grants.ts +++ b/cloud/apps/billing/src/grants.ts @@ -395,6 +395,49 @@ export async function putManualGrant( }) } +/** + * Ops: put a team on a contact-sales plan (Enterprise). The plan's current version is projected + * as a manual grant that names the real plan and version, so the audit row and /v1/entitlements + * show e.g. enterprise@2. `sourceId` is `plan::`. Sandbox hours and limits still + * come from the team's subscription: manual grants add to one, they do not replace it. + */ +export async function grantContactSalesPlan( + db: D1Database, + catalog: Catalog, + args: { subjectId: string; planId: string; sourceId: string; startsAt: number; endsAt: number | null; actor: string; now: number } +): Promise<{ changed: boolean; planVersion: number }> { + const plan = catalog.plans.get(args.planId) + if (!plan || plan.kind !== 'contact_sales') { + throw new Error(`not_a_contact_sales_plan:${args.planId}`) + } + const entitlements: Partial> = {} + for (const [key, entry] of Object.entries(planVersion(plan, plan.currentVersion).entitlements)) { + if (entry) { + entitlements[key as EntitlementKey] = parseEntitlementValue(key, entry) + } + } + const { changed } = await applyProjection(db, { + subjectId: args.subjectId, + source: 'manual', + sourceId: args.sourceId, + projection: { + state: 'active', + holdReason: null, + heldSince: null, + planId: plan.id, + planVersion: plan.currentVersion, + entitlements, + seatQuantity: null, + startsAt: args.startsAt, + endsAt: args.endsAt + }, + syncedAt: args.now, + now: args.now, + actor: args.actor + }) + return { changed, planVersion: plan.currentVersion } +} + export async function revokeManualGrant( db: D1Database, args: { subjectId: string; source: 'manual' | 'promo'; sourceId: string; reason: string; actor: string; now: number } diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index d674ea84316..24cfc27063e 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -15,6 +15,7 @@ // POST /v1/stripe/webhook signed events // Sandbox control plane (CONTROL_PLANE_TOKEN): // POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes} +// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}} plan feature flags (control plane or auth) // POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt} // GET /v1/internal/suspensions accounts whose payment grace expired (suspend running) // POST /v1/internal/trial/github {accountId, githubUserId?, githubOwnerId?} GitHub binding -> trial abuse keys @@ -57,6 +58,7 @@ import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './pla import { OverageRequestSchema, updateOverage } from './overage' import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe' import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage' +import { FeaturesRequestSchema, featuresFor } from './features' import { handleStripeWebhook } from './webhook' import { UsageHistoryQuerySchema, usageHistory } from './usage-history' import { renderReturnPage, returnOutcome } from './return-page' @@ -515,6 +517,18 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo }) } + case 'POST /v1/internal/entitlements/features': { + // Plan features a service gates a surface on: the control plane (audit log) and auth (SSO, SCIM, MFA). + if (!(await authenticateControlPlane(env, request)) && !(await authenticateMembershipSync(env, request))) { + return json({ error: 'unauthorized' }, 401) + } + const body = FeaturesRequestSchema.safeParse(await readJson(request)) + if (!body.success) { + return json({ error: 'invalid_request' }, 400) + } + return json({ features: await featuresFor(env, body.data.accountId, now) }) + } + case 'POST /v1/internal/usage': { if (!(await authenticateControlPlane(env, request))) { return json({ error: 'unauthorized' }, 401) diff --git a/cloud/apps/billing/src/plans-api.ts b/cloud/apps/billing/src/plans-api.ts index ac0e6e160a5..b845ad648f0 100644 --- a/cloud/apps/billing/src/plans-api.ts +++ b/cloud/apps/billing/src/plans-api.ts @@ -7,6 +7,7 @@ import { checkoutPrice } from './catalog-sync' import { defaultSelfServePlan, loadCatalog, planVersion, policyFor, type Catalog, type CatalogPlan } from './catalog' import { catalogOverageCentsPerHour, computeEntitlement, entitlementsMode, type Entitlement } from './entitlements' import type { Env } from './env' +import { featuresFor } from './features' import { grantsForSubject, liveAt } from './grants' import { MACHINE_SIZE_WEIGHT, planCatalog, publicOffers, type BillingInterval } from './plans' @@ -152,7 +153,9 @@ export async function entitlementsView(env: Env, accountId: string, canManageBil machineSizes: entitlement.limits.machineSizes, overage: entitlement.overage, overageSeconds: entitlement.overageSeconds, - usageWarningPercent: entitlement.usageWarningPercent + usageWarningPercent: entitlement.usageWarningPercent, + // Additive: plan features (Enterprise). Who may use each one is the owning service's call. + features: await featuresFor(env, accountId, now) }, access: { canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason, suspendRunning: entitlement.suspendRunning }, payment: { pastDueSince: entitlement.payment.pastDueSince, graceEndsAt: entitlement.payment.graceEndsAt }, @@ -164,7 +167,8 @@ export async function entitlementsView(env: Env, accountId: string, canManageBil source: row.source_type, state: row.state, ...(row.hold_reason ? { holdReason: row.hold_reason } : {}), - plan: row.plan_id && row.source_type !== 'manual' && row.source_type !== 'promo' ? { id: row.plan_id, version: row.plan_version } : null, + // Ad hoc manual and promo grants carry the placeholder plan 'manual'; a contact-sales plan granted by ops names itself. + plan: row.plan_id && row.plan_id !== 'manual' ? { id: row.plan_id, version: row.plan_version } : null, startsAt: row.starts_at, endsAt: row.ends_at })), diff --git a/cloud/apps/billing/test/features.test.ts b/cloud/apps/billing/test/features.test.ts new file mode 100644 index 00000000000..60e760d6bc2 --- /dev/null +++ b/cloud/apps/billing/test/features.test.ts @@ -0,0 +1,123 @@ +// Enterprise feature entitlements (audit_log, sso, scim, mfa_enforcement): the catalog version +// that carries them, how a team gets them, and the internal route services gate on. +import { describe, expect, it } from 'vitest' +import { loadCatalog, parseCatalogFile } from '../src/catalog' +import { FEATURE_KEYS, parseEntitlementValue } from '../src/entitlement-keys' +import { featuresFor } from '../src/features' +import { grantContactSalesPlan, grantsForSource, revokeManualGrant } from '../src/grants' +import { repoCatalog } from './catalog-fixture' +import { createHarness } from './harness' + +const ALL_OFF = { audit_log: false, sso: false, scim: false, mfa_enforcement: false } +const ALL_ON = { audit_log: true, sso: true, scim: true, mfa_enforcement: true } +const SOURCE = 'plan:enterprise:ticket-1' + +async function enterprise(h: Awaited>, accountId = 'org:o1', endsAt: number | null = null) { + const now = Date.now() + const catalog = await loadCatalog(h.env.BILLING_DB, now) + return grantContactSalesPlan(h.env.BILLING_DB, catalog, { subjectId: accountId, planId: 'enterprise', sourceId: SOURCE, startsAt: now - 1000, endsAt, actor: 'ops:test', now }) +} + +const features = (h: Awaited>, accountId = 'org:o1') => h.controlPlane('/v1/internal/entitlements/features', { accountId }) + +describe('catalog', () => { + it('puts every Enterprise feature on Enterprise v2 and on no other plan; v1 is unchanged', () => { + const file = parseCatalogFile(repoCatalog()) + const plan = file.plans.find((entry) => entry.id === 'enterprise')! + expect(plan.currentVersion).toBe(2) + expect(plan.versions.find((entry) => entry.version === 1)!.entitlements).toEqual({}) + const v2 = plan.versions.find((entry) => entry.version === 2)! + expect(v2.entitlements).toEqual(Object.fromEntries(FEATURE_KEYS.map((key) => [key, { value: true }]))) + expect(v2.features.map((feature) => feature.id)).toContain('audit_log') + for (const other of file.plans.filter((entry) => entry.id !== 'enterprise')) { + for (const version of other.versions) { + for (const key of FEATURE_KEYS) { + expect(version.entitlements[key], `${other.id} v${version.version} ${key}`).toBeUndefined() + } + } + } + }) + + it('a feature is a boolean and never per seat', () => { + for (const key of FEATURE_KEYS) { + expect(parseEntitlementValue(key, { value: true })).toEqual({ value: true, perSeat: false }) + expect(() => parseEntitlementValue(key, { value: 1 })).toThrow(/invalid value/) + expect(() => parseEntitlementValue(key, { value: true, perSeat: true })).toThrow(/cannot be per seat/) + } + }) +}) + +describe.each(['shadow', 'on'])('features (ENTITLEMENTS_MODE=%s)', (mode) => { + it('are off for a team without Enterprise and on for one granted the plan', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: mode }) + expect((await features(h)).body).toEqual({ features: ALL_OFF }) + expect(await enterprise(h)).toEqual({ changed: true, planVersion: 2 }) + expect((await features(h)).body).toEqual({ features: ALL_ON }) + // Another team is unaffected. + expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF }) + }) + + it('the grant names the real plan and version, and stops when it ends or is revoked', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: mode }) + const now = Date.now() + await enterprise(h, 'org:o1', now + 3600_000) + const rows = await grantsForSource(h.env.BILLING_DB, 'manual', SOURCE) + expect(rows.map((row) => row.entitlement_key).sort()).toEqual([...FEATURE_KEYS].sort()) + expect(rows.every((row) => row.plan_id === 'enterprise' && row.plan_version === 2 && row.state === 'active')).toBe(true) + expect(await featuresFor(h.env, 'org:o1', now)).toEqual(ALL_ON) + expect(await featuresFor(h.env, 'org:o1', now + 3600_000)).toEqual(ALL_OFF) + await revokeManualGrant(h.env.BILLING_DB, { subjectId: 'org:o1', source: 'manual', sourceId: SOURCE, reason: 'contract_ended', actor: 'ops:test', now }) + expect(await featuresFor(h.env, 'org:o1', now)).toEqual(ALL_OFF) + }) +}) + +describe('features', () => { + it('are all off when ENTITLEMENTS_MODE is off, whatever grants exist', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'shadow' }) + await enterprise(h) + h.env.ENTITLEMENTS_MODE = 'off' + expect((await features(h)).body).toEqual({ features: ALL_OFF }) + }) + + it('only a contact-sales plan can be granted this way', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'on' }) + const now = Date.now() + const catalog = await loadCatalog(h.env.BILLING_DB, now) + await expect( + grantContactSalesPlan(h.env.BILLING_DB, catalog, { subjectId: 'org:o1', planId: 'team', sourceId: 'plan:team:x', startsAt: now, endsAt: null, actor: 'ops', now }) + ).rejects.toThrow(/not_a_contact_sales_plan/) + }) + + it('the route takes the control plane or auth token and nothing else', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'on', MEMBERSHIP_SYNC_TOKEN: 'm'.repeat(40) }) + expect((await features(h)).status).toBe(200) + expect((await h.controlPlane('/v1/internal/entitlements/features', { accountId: 'org:o1' }, 'm'.repeat(40))).status).toBe(200) + expect((await h.controlPlane('/v1/internal/entitlements/features', { accountId: 'org:o1' }, 'x'.repeat(40))).status).toBe(401) + const desktop = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + expect((await h.controlPlane('/v1/internal/entitlements/features', { accountId: 'org:o1' }, desktop)).status).toBe(401) + expect((await h.controlPlane('/v1/internal/entitlements/features', { accountId: 'org:o1', extra: 1 })).status).toBe(400) + expect((await h.controlPlane('/v1/internal/entitlements/features', {})).status).toBe(400) + }) + + it('never change the start check or the entitlement object (no shadow diff)', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'shadow' }) + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + const before = (await check()).body + await enterprise(h) + const after = (await check()).body + expect(after).toEqual(before) + expect(JSON.stringify(after)).not.toMatch(/audit_log|auditLog|mfa_enforcement/) + expect(h.raw.prepare('SELECT COUNT(*) AS n FROM entitlement_shadow_diffs').get()).toEqual({ n: 0 }) + }) + + it('show on GET /v1/entitlements for the desktop', async () => { + const h = await createHarness({ ENTITLEMENTS_MODE: 'on' }) + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const read = () => h.call('GET', '/v1/entitlements', { headers: { authorization: `Bearer ${token}` } }) + expect((await read()).body.effective.features).toEqual(ALL_OFF) + await enterprise(h) + const body = (await read()).body + expect(body.effective.features).toEqual(ALL_ON) + expect(body.grants.find((grant: { key: string }) => grant.key === 'audit_log')).toMatchObject({ value: true, source: 'manual', plan: { id: 'enterprise', version: 2 }, state: 'active' }) + }) +}) diff --git a/cloud/apps/billing/test/grants.test.ts b/cloud/apps/billing/test/grants.test.ts index c9c025d449d..d6e0a7ce69b 100644 --- a/cloud/apps/billing/test/grants.test.ts +++ b/cloud/apps/billing/test/grants.test.ts @@ -126,7 +126,14 @@ describe('aggregation', () => { maxConcurrentSandboxes: 3, maxConcurrentSandboxesPerMember: 5, machineSizes: ['standard', 'large'], - overageAllowed: true + overageAllowed: true, + features: { audit_log: false, sso: false, scim: false, mfa_enforcement: false } + }) + expect(aggregateGrants([{ key: 'audit_log', value: false, perSeat: false, seats: null }, { key: 'audit_log', value: true, perSeat: false, seats: null }]).features).toEqual({ + audit_log: true, + sso: false, + scim: false, + mfa_enforcement: false }) }) }) diff --git a/cloud/apps/sandbox-control/src/api/audit-ingest.ts b/cloud/apps/sandbox-control/src/api/audit-ingest.ts new file mode 100644 index 00000000000..2b967dfbc13 --- /dev/null +++ b/cloud/apps/sandbox-control/src/api/audit-ingest.ts @@ -0,0 +1,101 @@ +// Audit events that happen in another Orca service (auth: invites, team rename, SSO, SCIM and MFA +// policy; billing: plan changes) arrive here, so the team audit log reads one store. +// +// POST /v1/internal/audit-events {accountId, events: [{eventId, action, at?, actorUserId?, target?, detail?}]} +// -> {recorded, duplicates} +// +// Auth's token (MEMBER_REMOVED_TOKEN) or the operator token. The route only appends: it can't +// read, change or delete a row. `eventId` makes a retry a no-op. Actions are limited to the +// families those services own, so a pushed event can never pass for an API key or secret change. +// Member removal and role changes are not sent here: they have their own pushes, which also do +// the cleanup, and record the event themselves (actorUserId on those bodies). +import { z } from 'zod' + +const EVENT_FAMILIES = ['member', 'team', 'sso', 'scim', 'mfa', 'billing'] as const +const ACTION = new RegExp(`^(${EVENT_FAMILIES.join('|')})\\.[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)?$`) +/** Recorded by this service from the member pushes; a second copy from outside would double them. */ +const OWNED_HERE = new Set(['member.removed', 'member.left', 'member.role_changed', 'member.role_synced']) + +const Id = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/) +const SENSITIVE_DETAIL_KEY = /secret|token|password|passphrase|hint|value|ciphertext|credential|private|code/i +const MAX_DETAIL_BYTES = 2048 + +const EventSchema = z + .object({ + eventId: z.string().min(8).max(64).regex(/^[A-Za-z0-9_-]+$/), + action: z.string().max(80).regex(ACTION), + /** When it happened (ms). Clamped to now: a sender's fast clock can't post-date the log. */ + at: z.number().int().positive().optional(), + /** The person who did it; omitted when the service itself did (a scheduler, a directory sync). */ + actorUserId: Id.optional(), + target: z + .object({ + type: z.enum(['member', 'invite', 'team', 'sso_connection', 'scim_directory', 'scim_group', 'policy', 'plan']), + id: Id.optional(), + /** Display text: an invitee's email, a group or connection name. */ + label: z.string().min(1).max(200).optional() + }) + .strict() + .optional(), + detail: z.record(z.string().max(64), z.union([z.string().max(300), z.number(), z.boolean(), z.null()])).optional() + }) + .strict() + +export const AuditIngestSchema = z + .object({ + accountId: z.string().min(1).max(160).regex(/^(org|dev:org):[A-Za-z0-9_.:-]+$/), + events: z.array(EventSchema).min(1).max(50) + }) + .strict() + +export type AuditIngestBody = z.infer + +export class AuditIngestError extends Error {} + +/** Appends the events; one that was already recorded (same eventId) is skipped. */ +export async function ingestAuditEvents(db: D1Database, body: AuditIngestBody, now: number): Promise<{ recorded: number; duplicates: number }> { + const statements: D1PreparedStatement[] = [] + for (const event of body.events) { + if (OWNED_HERE.has(event.action)) { + throw new AuditIngestError(`action_recorded_by_control_plane:${event.action}`) + } + const detail: Record = {} + for (const [key, value] of Object.entries(event.detail ?? {})) { + if (SENSITIVE_DETAIL_KEY.test(key)) { + // Refused rather than dropped, so a sender finds out in its own tests. + throw new AuditIngestError(`detail_key_not_allowed:${key}`) + } + detail[key] = value + } + if (event.target?.label) { + detail.name = event.target.label + } + const text = Object.keys(detail).length ? JSON.stringify(detail) : null + if (text && text.length > MAX_DETAIL_BYTES) { + throw new AuditIngestError('detail_too_large') + } + const actor = event.actorUserId + statements.push( + db + .prepare( + `INSERT OR IGNORE INTO audit_log (id, account_id, actor_type, actor_id, user_id, action, target_type, target_id, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + `aud_x_${event.eventId}`, + body.accountId, + actor ? 'user' : 'system', + actor ?? event.action.split('.')[0]!, + actor ?? null, + event.action, + event.target?.type ?? null, + event.target?.id ?? null, + text, + Math.min(event.at ?? now, now) + ) + ) + } + const results = await db.batch(statements) + const recorded = results.reduce((sum, result) => sum + (result.meta.changes ?? 0), 0) + return { recorded, duplicates: body.events.length - recorded } +} diff --git a/cloud/apps/sandbox-control/src/api/audit-log.ts b/cloud/apps/sandbox-control/src/api/audit-log.ts new file mode 100644 index 00000000000..61ec3bc81ba --- /dev/null +++ b/cloud/apps/sandbox-control/src/api/audit-log.ts @@ -0,0 +1,411 @@ +// The team audit log: every recorded change in one feed, for owners and admins of a team whose +// plan includes it (billing's `audit_log` entitlement, Enterprise). +// +// GET /v1/audit-log?category=&actor=&cursor=&limit= -> {events, nextCursor, actors} +// GET /v1/audit-log?format=csv[&category=&actor=] -> text/csv, newest first +// +// Sources, merged newest first in one query: +// audit_log API keys, workspaces, settings, projects, routines, connected apps, members +// secret_events cloud secret creates, updates and deletes (names only) +// Recording happens for every team on every plan, so a team that upgrades sees its history. Only +// reading is gated: scope audit:read, then the owner or admin role, then the plan. +// An event never carries a secret value or hint: secret_events has no such column, and detail +// fields whose name suggests a credential are dropped on the way out. +import { memberNamesFor } from '../member-names' +import { ApiError, requireAdmin, requireScope, type ApiCaller } from './caller' +import type { ApiContext } from './router' + +export const AUDIT_CATEGORIES = ['members', 'security', 'api_keys', 'apps', 'workspaces', 'secrets', 'routines', 'settings', 'billing', 'other'] as const +export type AuditCategory = (typeof AUDIT_CATEGORIES)[number] + +/** Action prefix -> category. The first match wins; anything unmatched is `other`. */ +const CATEGORY_PREFIXES: ReadonlyArray = [ + ['member.', 'members'], + ['team.', 'members'], + ['mfa.', 'security'], + ['sso.', 'security'], + ['scim.', 'security'], + ['api_key.', 'api_keys'], + ['workspace_token.', 'api_keys'], + ['mcp.', 'apps'], + ['workspace.', 'workspaces'], + ['sandbox.', 'workspaces'], + ['secret.', 'secrets'], + ['routine.', 'routines'], + ['settings.', 'settings'], + ['project.', 'settings'], + ['billing.', 'billing'] +] + +export function categoryOf(action: string): AuditCategory { + return CATEGORY_PREFIXES.find(([prefix]) => action.startsWith(prefix))?.[1] ?? 'other' +} + +export const DEFAULT_AUDIT_PAGE = 50 +export const MAX_AUDIT_PAGE = 200 +/** One export is at most this many events (newest first); filter by category or actor for more. */ +export const MAX_EXPORT_ROWS = 5000 + +type MergedRow = { + id: string + created_at: number + actor_type: string + actor_id: string + user_id: string | null + action: string + target_type: string | null + target_id: string | null + detail: string | null + actor_name: string | null +} + +export type AuditActor = { + /** `former_member`: auth told us they left the team. `system`: Orca did it (a sync, a cleanup). */ + kind: 'person' | 'former_member' | 'system' + userId: string | null + name: string | null + /** How the person acted, when not directly in the app. */ + via: 'api_key' | 'mcp' | 'agent' | 'routine' | null +} + +export type AuditEvent = { + id: string + at: number + category: AuditCategory + action: string + actor: AuditActor + target: { type: string; id: string | null; label: string | null } | null + detail: Record | null +} + +// Both sources as one relation with audit_log's columns. A secret's target id is its NAME (the +// variable name is what admins search for; it is not the value). +const MERGED = ` + SELECT id, created_at, actor_type, actor_id, user_id, action, target_type, target_id, detail, NULL AS actor_name + FROM audit_log WHERE account_id = ?1 + UNION ALL + SELECT id, created_at, 'user', actor_user_id, actor_user_id, 'secret.' || action, 'secret', name, + json_object('scope', scope, 'repo', CASE WHEN scope = 'repo' THEN substr(owner_id, instr(owner_id, '#') + 1) END), + actor_name + FROM secret_events WHERE account_id = ?1` + +/** Opaque cursor: `.` of the last event on the page. */ +function parseCursor(cursor: string | null): { at: number; id: string } | null { + const match = /^(\d{1,16})\.([A-Za-z0-9_-]{1,80})$/.exec(cursor ?? '') + return match ? { at: Number(match[1]), id: match[2]! } : null +} + +export type AuditQuery = { + category: AuditCategory | null + /** A user id, or `system`. */ + actor: string | null + cursor: string | null + limit: number +} + +function whereFor(query: AuditQuery): { sql: string; binds: Array } { + const clauses: string[] = [] + const binds: Array = [] + const after = parseCursor(query.cursor) + if (after) { + clauses.push('(created_at < ? OR (created_at = ? AND id < ?))') + binds.push(after.at, after.at, after.id) + } + if (query.category === 'other') { + for (const [prefix] of CATEGORY_PREFIXES) { + clauses.push('substr(action, 1, ?) <> ?') + binds.push(prefix.length, prefix) + } + } else if (query.category) { + const prefixes = CATEGORY_PREFIXES.filter(([, category]) => category === query.category) + clauses.push(`(${prefixes.map(() => 'substr(action, 1, ?) = ?').join(' OR ')})`) + for (const [prefix] of prefixes) { + binds.push(prefix.length, prefix) + } + } + if (query.actor === 'system') { + clauses.push("actor_type = 'system'") + } else if (query.actor) { + // A system row's user_id is who it happened TO (the member removed), not who did it. + clauses.push("actor_type <> 'system' AND user_id = ?") + binds.push(query.actor) + } + return { sql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', binds } +} + +const SENSITIVE_DETAIL_KEY = /secret|token|password|passphrase|hint|value|ciphertext|credential|private/i + +/** Detail as recorded, minus any field that could hold a credential (defence in depth), nulls dropped. */ +function safeDetail(raw: string | null): Record | null { + if (!raw) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const out: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (value !== null && !SENSITIVE_DETAIL_KEY.test(key)) { + out[key] = value + } + } + return Object.keys(out).length ? out : null +} + +const VIA: Record = { api_key: 'api_key', mcp: 'mcp', workspace_token: 'agent', routine: 'routine' } + +type Labels = { names: ReadonlyMap; former: ReadonlySet; workspaces: ReadonlyMap } + +function eventView(row: MergedRow, labels: Labels): AuditEvent { + const detail = safeDetail(row.detail) + const system = row.actor_type === 'system' + const actorId = system ? null : row.user_id + const actor: AuditActor = system + ? { kind: 'system', userId: null, name: null, via: null } + : { + kind: actorId && labels.former.has(actorId) ? 'former_member' : 'person', + userId: actorId, + name: (actorId ? labels.names.get(actorId) : undefined) ?? row.actor_name ?? null, + via: VIA[row.actor_type] ?? null + } + let target: AuditEvent['target'] = null + if (row.target_type) { + const recorded = typeof detail?.name === 'string' ? detail.name : null + const label = + row.target_type === 'workspace' || row.target_type === 'sandbox' + ? ((row.target_id ? labels.workspaces.get(row.target_id) : undefined) ?? recorded) + : row.target_type === 'member' + ? ((row.target_id ? labels.names.get(row.target_id) : undefined) ?? null) + : row.target_type === 'secret' + ? row.target_id + : recorded + target = { type: row.target_type, id: row.target_id, label: label ?? null } + } else if (system && row.user_id && row.action.startsWith('member.')) { + // Auth's pushes record the member in user_id: they are who it happened to. + target = { type: 'member', id: row.user_id, label: labels.names.get(row.user_id) ?? null } + } + return { id: row.id, at: row.created_at, category: categoryOf(row.action), action: row.action, actor, target, detail } +} + +async function inChunks(ids: readonly string[], run: (chunk: string[], marks: string) => Promise): Promise { + const out: T[] = [] + // D1 caps bound parameters per statement; chunk well under it. + for (let i = 0; i < ids.length; i += 90) { + const chunk = ids.slice(i, i + 90) + out.push(...(await run(chunk, chunk.map(() => '?').join(', ')))) + } + return out +} + +async function labelsFor(db: D1Database, accountId: string, rows: readonly MergedRow[]): Promise { + const people = new Set() + const workspaceIds = new Set() + for (const row of rows) { + if (row.user_id) { + people.add(row.user_id) + } + if (row.target_id && row.target_type === 'member') { + people.add(row.target_id) + } + if (row.target_id && (row.target_type === 'workspace' || row.target_type === 'sandbox')) { + workspaceIds.add(row.target_id) + } + } + const ids = [...people] + const [names, left, workspaces] = await Promise.all([ + memberNamesFor(db, accountId, ids), + inChunks(ids, async (chunk, marks) => + ( + await db + .prepare(`SELECT user_id FROM member_changes WHERE account_id = ? AND removed_at IS NOT NULL AND user_id IN (${marks})`) + .bind(accountId, ...chunk) + .all<{ user_id: string }>() + ).results + ), + inChunks([...workspaceIds], async (chunk, marks) => + ( + await db + .prepare(`SELECT id, name FROM sandboxes WHERE account_id = ? AND name IS NOT NULL AND id IN (${marks})`) + .bind(accountId, ...chunk) + .all<{ id: string; name: string }>() + ).results + ) + ]) + return { + names, + former: new Set(left.map((row) => row.user_id)), + workspaces: new Map(workspaces.map((row) => [row.id, row.name])) + } +} + +/** One page of the merged log for an account, newest first. No access checks: callers gate. */ +export async function listAuditEvents( + db: D1Database, + accountId: string, + query: AuditQuery +): Promise<{ events: AuditEvent[]; nextCursor: string | null }> { + const where = whereFor(query) + const { results } = await db + .prepare(`SELECT * FROM (${MERGED}) ${where.sql} ORDER BY created_at DESC, id DESC LIMIT ?`) + .bind(accountId, ...where.binds, query.limit + 1) + .all() + const page = results.slice(0, query.limit) + const labels = await labelsFor(db, accountId, page) + const last = page.at(-1) + return { + events: page.map((row) => eventView(row, labels)), + nextCursor: results.length > query.limit && last ? `${last.created_at}.${last.id}` : null + } +} + +/** Everyone who appears as an actor in the account's log (for the "by person" filter), capped. */ +export async function listAuditActors(db: D1Database, accountId: string): Promise { + const { results } = await db + .prepare(`SELECT user_id, MAX(actor_name) AS actor_name FROM (${MERGED}) WHERE actor_type <> 'system' AND user_id IS NOT NULL GROUP BY user_id LIMIT 200`) + .bind(accountId) + .all<{ user_id: string; actor_name: string | null }>() + const ids = results.map((row) => row.user_id) + const [names, left] = await Promise.all([ + memberNamesFor(db, accountId, ids), + inChunks(ids, async (chunk, marks) => + ( + await db + .prepare(`SELECT user_id FROM member_changes WHERE account_id = ? AND removed_at IS NOT NULL AND user_id IN (${marks})`) + .bind(accountId, ...chunk) + .all<{ user_id: string }>() + ).results + ) + ]) + const former = new Set(left.map((row) => row.user_id)) + return results + .map((row): AuditActor => ({ + kind: former.has(row.user_id) ? 'former_member' : 'person', + userId: row.user_id, + name: names.get(row.user_id) ?? row.actor_name ?? null, + via: null + })) + .sort((a, b) => (a.name ?? '￿').localeCompare(b.name ?? '￿')) +} + +/** Spreadsheets run a cell that starts with = + - @ as a formula: such text is quoted as text. */ +function csvCell(value: string | number | null): string { + if (value === null) { + return '' + } + let text = String(value) + if (typeof value === 'string' && /^[=+\-@\t\r]/.test(text)) { + text = `'${text}` + } + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text +} + +export const CSV_HEADER = ['time', 'category', 'action', 'actor_kind', 'actor_name', 'actor_user_id', 'via', 'target_type', 'target_id', 'target_label', 'detail'] + +export function auditCsv(events: readonly AuditEvent[]): string { + const lines = [CSV_HEADER.join(',')] + for (const event of events) { + lines.push( + [ + new Date(event.at).toISOString(), + event.category, + event.action, + event.actor.kind, + event.actor.name, + event.actor.userId, + event.actor.via, + event.target?.type ?? null, + event.target?.id ?? null, + event.target?.label ?? null, + event.detail ? JSON.stringify(event.detail) : null + ] + .map(csvCell) + .join(',') + ) + } + return `${lines.join('\r\n')}\r\n` +} + +function parseQuery(url: URL): AuditQuery { + const category = url.searchParams.get('category') + if (category !== null && !(AUDIT_CATEGORIES as readonly string[]).includes(category)) { + throw new ApiError(400, 'invalid_request', 'category') + } + const actor = url.searchParams.get('actor') + if (actor !== null && !/^[A-Za-z0-9_.:-]{1,128}$/.test(actor)) { + throw new ApiError(400, 'invalid_request', 'actor') + } + const cursor = url.searchParams.get('cursor') + if (cursor !== null && !parseCursor(cursor)) { + throw new ApiError(400, 'invalid_request', 'cursor') + } + const raw = url.searchParams.get('limit') + const parsed = raw === null ? DEFAULT_AUDIT_PAGE : Number(raw) + const limit = Number.isInteger(parsed) && parsed >= 1 ? Math.min(parsed, MAX_AUDIT_PAGE) : DEFAULT_AUDIT_PAGE + return { category: category as AuditCategory | null, actor, cursor, limit } +} + +/** + * Who may read: a team's owner or admin (a signed-in desktop, or an API key with audit:read that + * such a person made), and only while the team's plan includes the audit log. The role is checked + * before the plan, so a member learns nothing about the team's plan from this route. + */ +export async function requireAuditLogAccess(caller: ApiCaller, ctx: Pick): Promise { + requireScope(caller, 'audit:read') + if (!caller.orgId) { + throw new ApiError(403, 'forbidden', 'team_required') + } + requireAdmin(caller) + await requireAuditLogPlan(caller, ctx) +} + +/** + * The plan half of the gate, shared with the older GET /v1/audit so a team can't read the same + * rows around it. Billing down or unconfigured throws BillingUnavailableError (503): never read + * as "not on the plan", and never as an open log. + */ +export async function requireAuditLogPlan(caller: Pick, ctx: Pick): Promise { + const features = ctx.features ? await ctx.features(caller.accountId) : { auditLog: false } + if (!features.auditLog) { + throw new ApiError(403, 'entitlement_denied', 'audit_log') + } +} + +export async function handleAuditLog(url: URL, caller: ApiCaller, ctx: ApiContext): Promise { + await requireAuditLogAccess(caller, ctx) + const query = parseQuery(url) + const format = url.searchParams.get('format') + if (format === 'csv') { + const events: AuditEvent[] = [] + let cursor = query.cursor + while (events.length < MAX_EXPORT_ROWS) { + const page = await listAuditEvents(ctx.db, caller.accountId, { ...query, cursor, limit: Math.min(1000, MAX_EXPORT_ROWS - events.length) }) + events.push(...page.events) + cursor = page.nextCursor + if (!cursor) { + break + } + } + return new Response(auditCsv(events), { + headers: { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': 'attachment; filename="orca-audit-log.csv"', + 'cache-control': 'no-store', + // More rows exist past this file: the client says so instead of implying it is complete. + 'x-orca-audit-truncated': cursor ? 'true' : 'false' + } + }) + } + if (format !== null && format !== 'json') { + throw new ApiError(400, 'invalid_request', 'format') + } + const page = await listAuditEvents(ctx.db, caller.accountId, query) + // The people filter's options only need loading with the first page. + const actors = query.cursor ? undefined : await listAuditActors(ctx.db, caller.accountId) + return Response.json({ ...page, ...(actors ? { actors } : {}), categories: AUDIT_CATEGORIES }, { headers: { 'cache-control': 'no-store' } }) +} diff --git a/cloud/apps/sandbox-control/src/api/audit.ts b/cloud/apps/sandbox-control/src/api/audit.ts index 03c73aa1ec5..e2a0eecb344 100644 --- a/cloud/apps/sandbox-control/src/api/audit.ts +++ b/cloud/apps/sandbox-control/src/api/audit.ts @@ -37,6 +37,19 @@ export function auditCaller(db: D1Database, caller: ApiCaller, entry: AuditEntry return audit(db, { accountId: caller.accountId, type: actor.type, id: actor.id, userId: caller.userId }, entry, now) } +/** + * A signed-in desktop or phone acting on the lifecycle routes (index.ts), which do not go through + * the public API's auditCaller. Best effort: a failed audit write never fails the action. + */ +export function auditSession( + db: D1Database, + caller: { accountId: string; userId: string }, + entry: AuditEntry, + now: number +): Promise { + return audit(db, { accountId: caller.accountId, type: 'user', id: caller.userId, userId: caller.userId }, entry, now).catch(() => undefined) +} + export type AuditRow = { id: string actor_type: string diff --git a/cloud/apps/sandbox-control/src/api/openapi.ts b/cloud/apps/sandbox-control/src/api/openapi.ts index 7f3514003b4..9b12a407c3a 100644 --- a/cloud/apps/sandbox-control/src/api/openapi.ts +++ b/cloud/apps/sandbox-control/src/api/openapi.ts @@ -209,6 +209,26 @@ const schemas: Record = { logTail: { type: ['string', 'null'], description: 'Single-run view only: short, redacted tail of the runner log' } } }, + AuditEvent: { + type: 'object', + properties: { + id: { type: 'string' }, + at: { type: 'integer' }, + category: { type: 'string' }, + action: { type: 'string', examples: ['secret.updated', 'member.removed', 'api_key.create'] }, + actor: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['person', 'former_member', 'system'] }, + userId: { type: ['string', 'null'] }, + name: { type: ['string', 'null'] }, + via: { type: ['string', 'null'], enum: ['api_key', 'mcp', 'agent', 'routine', null] } + } + }, + target: { type: ['object', 'null'], properties: { type: { type: 'string' }, id: { type: ['string', 'null'] }, label: { type: ['string', 'null'] } } }, + detail: { type: ['object', 'null'] } + } + }, AuditEntry: { type: 'object', properties: { @@ -332,7 +352,7 @@ export const OPERATIONS: Record> = { get: { tags: ['Account'], scope: 'audit:read', - summary: 'Audit log (owner/admin)', + summary: 'Audit log of API activity (owner/admin; for a team, the Enterprise plan). `/v1/audit-log` is the full team log.', params: [ { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 200 } }, { name: 'before', in: 'query', description: 'Cursor: `nextBefore` from the previous page', schema: { type: 'integer' } } @@ -340,6 +360,24 @@ export const OPERATIONS: Record> = { responses: { 200: { description: 'Entries, newest first', schema: obj({ entries: { type: 'array', items: ref('AuditEntry') }, nextBefore: { type: ['integer', 'null'] } }) } } } }, + '/v1/audit-log': { + get: { + tags: ['Account'], + scope: 'audit:read', + summary: 'Team audit log: every recorded change in one feed (team owner/admin; Enterprise plan)', + params: [ + { name: 'category', in: 'query', schema: { type: 'string', enum: ['members', 'security', 'api_keys', 'apps', 'workspaces', 'secrets', 'routines', 'settings', 'billing', 'other'] } }, + { name: 'actor', in: 'query', description: 'A user id, or `system`', schema: { type: 'string' } }, + { name: 'cursor', in: 'query', description: '`nextCursor` from the previous page', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 200 } }, + { name: 'format', in: 'query', description: '`csv` downloads up to 5000 events, newest first', schema: { type: 'string', enum: ['json', 'csv'] } } + ], + responses: { + 200: { description: 'Events, newest first. Never a secret value or hint.', schema: obj({ events: { type: 'array', items: ref('AuditEvent') }, nextCursor: { type: ['string', 'null'] } }) }, + 403: 'Not a team owner or admin (`forbidden`), or the plan does not include the audit log (`entitlement_denied`, reason `audit_log`)' + } + } + }, '/v1/projects': { get: { tags: ['Projects'], scope: 'projects:read', summary: 'List projects', responses: { 200: { description: 'Projects', schema: list('projects', 'Project') } } }, post: { diff --git a/cloud/apps/sandbox-control/src/api/router.ts b/cloud/apps/sandbox-control/src/api/router.ts index cdc1c9eff21..75aa4cc8110 100644 --- a/cloud/apps/sandbox-control/src/api/router.ts +++ b/cloud/apps/sandbox-control/src/api/router.ts @@ -3,6 +3,8 @@ // is rate limited per caller, and every mutation is audited. See openapi.ts for the contract. import { z } from 'zod' import { GuardrailError, consumeCreateToken } from '../guardrails' +import type { PlanFeatures } from '../entitlements' +import { handleAuditLog, requireAuditLogPlan } from './audit-log' import type { LifecycleDeps } from '../lifecycle' import { AGENTS } from './vm-agent' import { memberNamesFor, rememberCallerName } from '../member-names' @@ -52,6 +54,8 @@ export type ApiContext = { life: LifecycleDeps | null waitUntil: (promise: Promise) => void readJson: () => Promise + /** Plan features from billing (the audit log's Enterprise gate). Absent: every feature is off. */ + features?: (accountId: string) => Promise /** Extra route groups mounted under the same auth (routines). */ extensions?: ApiExtension[] } @@ -181,7 +185,7 @@ import { parseWakeWait } from '../wire' import { MachineSizeInput } from '../machine-size' export function isApiPath(pathname: string): boolean { - return /^\/v1\/(me|models|api-keys|settings|audit|projects|workspaces|routines|oauth-approvals|oauth-grants)(\/|$)/.test(pathname) + return /^\/v1\/(me|models|api-keys|settings|audit|audit-log|projects|workspaces|routines|oauth-approvals|oauth-grants)(\/|$)/.test(pathname) } export async function handleApi(request: Request, url: URL, env: Parameters[0], ctx: ApiContext): Promise { @@ -338,9 +342,18 @@ async function route(request: Request, url: URL, caller: ApiCaller, ctx: ApiCont } } + if (resource === 'audit-log' && !id && method === 'GET') { + return handleAuditLog(url, caller, ctx) + } + if (resource === 'audit' && !id && method === 'GET') { requireScope(caller, 'audit:read') requireAdmin(caller) + if (caller.orgId) { + // A team's audit log is an Enterprise feature whichever route reads it (see audit-log.ts). + // A personal account's own entries stay readable: it has no team log. + await requireAuditLogPlan(caller, ctx) + } const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50)) const before = Number(url.searchParams.get('before')) || undefined const rows = await listAudit(ctx.db, caller.accountId, { before, limit }) diff --git a/cloud/apps/sandbox-control/src/entitlements.ts b/cloud/apps/sandbox-control/src/entitlements.ts index ff687d15fa0..93570bac08f 100644 --- a/cloud/apps/sandbox-control/src/entitlements.ts +++ b/cloud/apps/sandbox-control/src/entitlements.ts @@ -42,6 +42,14 @@ export type EntitlementClient = { reportUsage(segment: UsageSegment): Promise /** Accounts whose payment grace expired; polled by the cron. */ listSuspensions(): Promise + /** Plan features that gate a surface (never sandbox starts). Absent on older test doubles: all off. */ + features?(accountId: string): Promise +} + +/** Boolean plan features billing resolves from the account's live grants. */ +export type PlanFeatures = { + /** Enterprise: owners and admins may read the team audit log. Recording never depends on it. */ + auditLog: boolean } export class BillingUnavailableError extends Error { @@ -63,6 +71,7 @@ const UsageResponse = z.looseObject({ reason: z.string().nullable(), suspendRunning: z.boolean().default(false) }) +const FeaturesResponse = z.looseObject({ features: z.looseObject({ audit_log: z.boolean().default(false) }) }) const SuspensionsResponse = z.looseObject({ accounts: z.array(z.looseObject({ accountId: z.string() })).max(10_000) }) @@ -101,7 +110,10 @@ export function withDevAccountBypass(env: Env, client: EntitlementClient): Entit bypassed(segment.accountId) ? Promise.resolve({ canStartSandbox: true, reason: null, suspendRunning: false }) : client.reportUsage(segment), - listSuspensions: () => client.listSuspensions() + listSuspensions: () => client.listSuspensions(), + // Dev test accounts skip billing, so they see every feature (their own data only). + features: (accountId) => + bypassed(accountId) ? Promise.resolve({ auditLog: true }) : (client.features?.(accountId) ?? Promise.resolve({ auditLog: false })) } } @@ -129,7 +141,8 @@ export function createEntitlementClient( return { checkStart: async () => ({ allow: true, reason: null, suspendRunning: false }), reportUsage: async () => ({ canStartSandbox: true, reason: null, suspendRunning: false }), - listSuspensions: async () => [] + listSuspensions: async () => [], + features: async () => ({ auditLog: true }) } } const base = env.BILLING_URL @@ -163,6 +176,10 @@ export function createEntitlementClient( checkStart: (request) => send('POST', '/v1/internal/entitlements/check', request, StartAnswer), reportUsage: (segment) => send('POST', '/v1/internal/usage', segment, UsageResponse), listSuspensions: async () => - (await send('GET', '/v1/internal/suspensions', undefined, SuspensionsResponse)).accounts.map((a) => a.accountId) + (await send('GET', '/v1/internal/suspensions', undefined, SuspensionsResponse)).accounts.map((a) => a.accountId), + features: async (accountId) => { + const answer = await send('POST', '/v1/internal/entitlements/features', { accountId }, FeaturesResponse) + return { auditLog: answer.features.audit_log } + } } } diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index b9ea138d0ad..83dac74951c 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -155,7 +155,8 @@ import { DesktopCommandError, VmCommandError, startDesktop } from './sandbox-vm- import { forgetShareLink, handleShareGateway } from './share-gateway' import { handleShareRoute, revokeSandboxShares, sharePathMode, shareSecret, shareSecrets } from './share-links' import { SshKeyRequestSchema, updateSshKey } from './ssh-access' -import { audit } from './api/audit' +import { audit, auditSession } from './api/audit' +import { AuditIngestError, AuditIngestSchema, ingestAuditEvents } from './api/audit-ingest' import { getSandbox, getSandboxByProviderName, listAccountSandboxes, markUserSlept, type SandboxRow } from './store' import { createVercelSandboxApi } from './vercel-sandbox-api' import { advanceAllBuilds, tickBuild, unfinishedBuilds, type BuildDeps } from './environment-builds' @@ -848,6 +849,11 @@ async function apiContext(env: Env, deps: Deps, request: Request, life?: Promise life: resolved, waitUntil: deps.waitUntil ?? ((promise) => void promise.catch(() => undefined)), readJson: () => readJson(request, MAX_API_JSON_BYTES), + // Works without provider config too: reading the log needs billing, not a sandbox provider. + features: (accountId) => { + const client = resolved?.entitlements ?? deps.entitlements ?? withDevAccountBypass(env, createEntitlementClient(env)) + return client.features?.(accountId) ?? Promise.resolve({ auditLog: false }) + }, extensions: [oauthApprovalsApi, oauthGrantsApi, routines, ...(deps.apiExtensions ?? [])] } } @@ -1244,12 +1250,29 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise { // First, so their desktop tokens minted before this stop working (and can't enroll or re-sync). await recordMemberChange(life.db, accountId, userId, 'removed', memberChangeTime(now, reportedAt)) @@ -55,6 +72,11 @@ export async function removeMember( routinesDisabled: routines.disabled, readerKeysRevoked } - await audit(life.db, { accountId, type: 'system', id: 'auth', userId }, { action: 'member.removed', detail: { ...result, runsCancelled: routines.cancelled } }, now) + await audit( + life.db, + memberEventActor(accountId, userId, actorUserId), + { action: actorUserId === userId ? 'member.left' : 'member.removed', ...memberEventTarget(userId, actorUserId), detail: { ...result, runsCancelled: routines.cancelled } }, + now + ) return result } diff --git a/cloud/apps/sandbox-control/src/operator.ts b/cloud/apps/sandbox-control/src/operator.ts index 380e36822c4..f97598a7837 100644 --- a/cloud/apps/sandbox-control/src/operator.ts +++ b/cloud/apps/sandbox-control/src/operator.ts @@ -21,7 +21,7 @@ import { countAllActive, getControlState, killSwitchOn, setKillSwitch } from './ import { guardrails, type LifecycleDeps } from './lifecycle' import { drainAll, housekeeping } from './retention' import { memberChangeTime, recordMemberChange } from './member-changes' -import { removeMember } from './member-removal' +import { memberEventActor, memberEventTarget, removeMember } from './member-removal' import type { Keyring } from './secrets-crypto' import { kekStatus, rewrapBatch, RewrapError, REWRAP_MAX_LIMIT } from './secrets-rewrap' import { fairUseReport, FAIR_USE_WINDOW_DAYS, sweepSessionCpu } from './session-cpu' @@ -40,8 +40,10 @@ const BindingBody = z .refine((body) => (body.installationId === undefined) !== (body.repoUrl === undefined)) // `changedAt`: auth's time of the change (ms), so tokens auth issued up to then count as stale. const ChangedAt = z.number().int().positive().optional() -const MemberRemovedBody = z.object({ accountId: AccountId, userId: z.string().min(1).max(128), changedAt: ChangedAt }).strict() -const RoleChangedBody = z.object({ accountId: AccountId, userId: z.string().min(1).max(128), role: z.enum(['owner', 'admin', 'member']), changedAt: ChangedAt }).strict() +// `actorUserId`: who made the change in auth (absent for a directory sync or an older auth), for the audit log. +const ActorUserId = z.string().min(1).max(128).optional() +const MemberRemovedBody = z.object({ accountId: AccountId, userId: z.string().min(1).max(128), changedAt: ChangedAt, actorUserId: ActorUserId }).strict() +const RoleChangedBody = z.object({ accountId: AccountId, userId: z.string().min(1).max(128), role: z.enum(['owner', 'admin', 'member']), changedAt: ChangedAt, actorUserId: ActorUserId }).strict() const RewrapBody = z .object({ limit: z.number().int().min(1).max(REWRAP_MAX_LIMIT).optional(), cursor: z.string().max(2048).nullable().optional(), dryRun: z.boolean().optional() }) .strict() @@ -145,7 +147,7 @@ export async function handleOperator( // First, so a desktop token minted before the change can't sync the old role back. await recordMemberChange(life.db, body.data.accountId, body.data.userId, 'role', memberChangeTime(now, body.data.changedAt)) const updated = await syncMemberRole(life.db, body.data.accountId, body.data.userId, body.data.role) - await audit(life.db, { accountId: body.data.accountId, type: 'system', id: 'auth', userId: body.data.userId }, { action: 'member.role_changed', detail: { role: body.data.role, keysUpdated: updated } }, now) + await audit(life.db, memberEventActor(body.data.accountId, body.data.userId, body.data.actorUserId), { action: 'member.role_changed', ...memberEventTarget(body.data.userId, body.data.actorUserId), detail: { role: body.data.role, keysUpdated: updated } }, now) return Response.json({ updated }) } case 'POST /v1/internal/members/removed': { @@ -154,7 +156,7 @@ export async function handleOperator( return Response.json({ error: 'invalid_request' }, { status: 400 }) } // Same cleanup as /v1/internal/org-members/removed: whichever auth calls, nothing of theirs survives. - const removal = await removeMember(life, body.data.accountId, body.data.userId, now, body.data.changedAt) + const removal = await removeMember(life, body.data.accountId, body.data.userId, now, body.data.changedAt, body.data.actorUserId) return Response.json({ revoked: removal.keysRevoked, ...removal }) } case 'GET /v1/internal/fair-use': { diff --git a/cloud/apps/sandbox-control/src/share-links.ts b/cloud/apps/sandbox-control/src/share-links.ts index 751743579ea..014a7feb970 100644 --- a/cloud/apps/sandbox-control/src/share-links.ts +++ b/cloud/apps/sandbox-control/src/share-links.ts @@ -5,6 +5,7 @@ // DELETE /v1/sandboxes/:id/shares/:shareId -> {share} // POST /v1/shares/:token/visit -> {url} (team-only: a one-time sign-in URL for this viewer) // The public side is src/share-gateway.ts. +import { auditSession } from './api/audit' import { z } from 'zod' import type { SandboxCaller } from './auth' import type { Env } from './env' @@ -353,6 +354,12 @@ export async function handleShareRoute(method: string, pathname: string, ctx: Sh expires_at: ctx.now + SHARE_EXPIRY_MS[parsed.data.expiresIn] }) console.log(JSON.stringify({ event: 'share_created', sandboxId: sandbox.id, audience: link.audience, expiresIn: parsed.data.expiresIn })) + await auditSession( + ctx.db, + ctx.caller, + { action: 'workspace.share_create', targetType: 'workspace', targetId: sandbox.id, detail: { shareId: link.id, port: link.port, audience: link.audience, expiresIn: parsed.data.expiresIn } }, + ctx.now + ) return { status: 200, body: { share: await view(link) } } } if (shareId && method === 'DELETE') { @@ -363,6 +370,7 @@ export async function handleShareRoute(method: string, pathname: string, ctx: Sh const revoked = await revokeShareLink(ctx.db, link, ctx.now) ctx.forget(link.id) console.log(JSON.stringify({ event: 'share_revoked', sandboxId: sandbox.id })) + await auditSession(ctx.db, ctx.caller, { action: 'workspace.share_revoke', targetType: 'workspace', targetId: sandbox.id, detail: { shareId: link.id } }, ctx.now) return { status: 200, body: { share: await view(revoked) } } } return notFound diff --git a/cloud/apps/sandbox-control/test/api-followups.test.ts b/cloud/apps/sandbox-control/test/api-followups.test.ts index c1583475998..4107e673be6 100644 --- a/cloud/apps/sandbox-control/test/api-followups.test.ts +++ b/cloud/apps/sandbox-control/test/api-followups.test.ts @@ -6,6 +6,8 @@ const OP = 'o'.repeat(40) async function harness() { const pending: Promise[] = [] const h = await createHarness({ PUBLIC_API_URL: 'https://api.test', OPERATOR_TOKEN: OP }, { waitUntil: (p) => void pending.push(p) }) + // These teams are on Enterprise, so reading the audit log turns on the role and scope alone. + h.billing.features = { auditLog: true } return { ...h, settle: () => Promise.all(pending.splice(0)) } } diff --git a/cloud/apps/sandbox-control/test/audit-log.test.ts b/cloud/apps/sandbox-control/test/audit-log.test.ts new file mode 100644 index 00000000000..0bc82a4660e --- /dev/null +++ b/cloud/apps/sandbox-control/test/audit-log.test.ts @@ -0,0 +1,438 @@ +// The team audit log (GET /v1/audit-log): one feed merged from audit_log and secret_events, paged +// and filtered, readable only by a team's owners and admins while the plan includes it, and never +// carrying a secret value or hint. Plus the push route other services record through. +import { describe, expect, it } from 'vitest' +import { AUDIT_CATEGORIES, CSV_HEADER, categoryOf } from '../src/api/audit-log' +import { createHarness } from './harness' + +const KEK = Buffer.alloc(32, 7).toString('base64') +const AUTH_TOKEN = 'a'.repeat(40) +const OPERATOR = 'o'.repeat(40) +// Fake values only; none may appear in the log, in any format. +const SECRET_VALUES = ['sk-ant-api03-AUDIT-fake-0000-wxyz', 'audit-env-value-two-9876'] + +type Event = { + id: string + at: number + category: string + action: string + actor: { kind: string; userId: string | null; name: string | null; via: string | null } + target: { type: string; id: string | null; label: string | null } | null + detail: Record | null +} + +async function setup(options: { entitled?: boolean } = {}) { + const h = await createHarness({ SECRETS_KEK: KEK, MEMBER_REMOVED_TOKEN: AUTH_TOKEN, OPERATOR_TOKEN: OPERATOR, PUBLIC_API_URL: 'https://api.test' }) + h.billing.features = { auditLog: options.entitled ?? true } + const owner = await h.token('olga', 'acme', 'sandbox', { org_role: 'owner', name: 'Olga Owner' }) + const admin = await h.token('alice', 'acme', 'sandbox', { org_role: 'admin', name: 'Alice Admin' }) + const member = await h.token('bob', 'acme', 'sandbox', { org_role: 'member', name: 'Bob Member' }) + const solo = await h.token('carol') + const log = (bearer: string, query = '') => h.call('GET', `/v1/audit-log${query ? `?${query}` : ''}`, { bearer }) + const internal = (path: string, body: unknown, bearer = AUTH_TOKEN) => h.call('POST', path, { body, bearer }) + return { h, owner, admin, member, solo, log, internal } +} + +/** A key, a setting, two secrets and a removed teammate: one event from every source. */ +async function seed(s: Awaited>) { + const { h, admin, owner, member } = s + const key = await h.call('POST', '/v1/api-keys', { bearer: admin, body: { name: 'CI', scopes: ['workspaces:read'] } }) + expect(key.status).toBe(201) + h.advance(1000) + expect((await h.call('PUT', '/v1/secrets/org/STRIPE_KEY', { bearer: admin, body: { value: SECRET_VALUES[0] } })).status).toBe(200) + h.advance(1000) + expect((await h.call('PUT', '/v1/settings', { bearer: owner, body: { vmCliEnabled: false } })).status).toBe(200) + h.advance(1000) + expect((await h.call('PUT', '/v1/secrets/org/STRIPE_KEY', { bearer: owner, body: { value: SECRET_VALUES[1] } })).status).toBe(200) + h.advance(1000) + // Bob records his name by calling the API, then is removed by Olga. + expect((await h.call('GET', '/v1/me', { bearer: member })).status).toBe(200) + h.advance(1000) + expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'bob', actorUserId: 'olga' })).status).toBe(200) + h.advance(1000) + return { keyId: key.body.key.id as string, keySecret: key.body.secret as string } +} + +describe('merge', () => { + it('returns every source in one feed, newest first, with who, what, target and when', async () => { + const s = await setup() + await seed(s) + const page = await s.log(s.owner) + expect(page.status).toBe(200) + const events: Event[] = page.body.events + expect(events.map((event) => event.action)).toEqual(['member.removed', 'secret.updated', 'settings.update', 'secret.created', 'api_key.create']) + expect(events.map((event) => event.category)).toEqual(['members', 'secrets', 'settings', 'secrets', 'api_keys']) + expect(events.map((event) => event.at)).toEqual([...events.map((event) => event.at)].sort((a, b) => b - a)) + expect(page.body.nextCursor).toBeNull() + expect(page.body.categories).toEqual([...AUDIT_CATEGORIES]) + + const [removed, updated, , created, key] = events as [Event, Event, Event, Event, Event] + expect(removed.actor).toEqual({ kind: 'person', userId: 'olga', name: 'Olga Owner', via: null }) + expect(removed.target).toEqual({ type: 'member', id: 'bob', label: 'Bob Member' }) + expect(updated.actor).toMatchObject({ kind: 'person', userId: 'olga', name: 'Olga Owner' }) + expect(updated.target).toEqual({ type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' }) + expect(updated.detail).toEqual({ scope: 'org' }) + expect(created.actor).toMatchObject({ userId: 'alice', name: 'Alice Admin' }) + expect(key.target).toMatchObject({ type: 'api_key', label: 'CI' }) + }) + + it('names a person who has left as a former teammate, and the system when nobody did it', async () => { + const s = await setup() + // Bob makes a key while a member (an admin-made one would need admin), then leaves by a directory sync. + expect((await s.h.call('POST', '/v1/api-keys', { bearer: s.member, body: { name: 'bobs', scopes: ['workspaces:read'] } })).status).toBe(201) + s.h.advance(1000) + expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'bob' })).status).toBe(200) + const events: Event[] = (await s.log(s.owner)).body.events + expect(events.map((event) => [event.action, event.actor.kind, event.actor.userId])).toEqual([ + ['member.removed', 'system', null], + ['api_key.create', 'former_member', 'bob'] + ]) + expect(events[0]!.target).toEqual({ type: 'member', id: 'bob', label: 'Bob Member' }) + expect(events[1]!.actor.name).toBe('Bob Member') + // Leaving by yourself reads as that, not as a removal. + s.h.advance(1000) + expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'alice', actorUserId: 'alice' })).status).toBe(200) + expect((await s.log(s.owner)).body.events[0]).toMatchObject({ action: 'member.left', actor: { kind: 'former_member', userId: 'alice' } }) + }) + + it('records who changed a role when auth says, and how a person acted through an API key', async () => { + const s = await setup() + const { keySecret } = await seed(s) + expect((await s.internal('/v1/internal/members/role-changed', { accountId: 'org:acme', userId: 'alice', role: 'member', actorUserId: 'olga' }, OPERATOR)).status).toBe(200) + const role: Event = (await s.log(s.owner)).body.events[0] + expect(role).toMatchObject({ action: 'member.role_changed', actor: { kind: 'person', userId: 'olga' }, target: { type: 'member', id: 'alice' }, detail: { role: 'member' } }) + const adminKey = await s.h.call('POST', '/v1/api-keys', { bearer: s.owner, body: { name: 'ops', scopes: ['projects:write'] } }) + s.h.advance(1000) + expect((await s.h.call('POST', '/v1/projects', { bearer: adminKey.body.secret, body: { name: 'web', repo: { url: 'https://github.com/acme/web' } } })).status).toBe(201) + void keySecret + expect((await s.log(s.owner)).body.events[0]).toMatchObject({ action: 'project.create', category: 'settings', actor: { kind: 'person', userId: 'olga', via: 'api_key' } }) + }) + + it("keeps each team's log to itself", async () => { + const s = await setup() + await seed(s) + const other = await s.h.token('zed', 'globex', 'sandbox', { org_role: 'owner' }) + expect((await s.log(other)).body.events).toEqual([]) + expect((await s.log(other)).body.actors).toEqual([]) + }) +}) + +describe('paging', () => { + it('walks the merged feed across both sources without a gap or a repeat', async () => { + const s = await setup() + await seed(s) + const all: Event[] = (await s.log(s.owner)).body.events + const seen: string[] = [] + let cursor: string | null = null + let pages = 0 + do { + const page: { status: number; body: { events: Event[]; nextCursor: string | null; actors?: unknown } } = await s.log(s.owner, `limit=2${cursor ? `&cursor=${cursor}` : ''}`) + expect(page.status).toBe(200) + expect(page.body.events.length).toBeLessThanOrEqual(2) + // The people filter's options come with the first page only. + expect(page.body.actors !== undefined).toBe(cursor === null) + seen.push(...page.body.events.map((event) => event.id)) + cursor = page.body.nextCursor + pages += 1 + } while (cursor) + expect(pages).toBe(3) + expect(seen).toEqual(all.map((event) => event.id)) + }) + + it('orders events that share a millisecond by id, so a page boundary between them is stable', async () => { + const s = await setup() + // No clock advance: a key and a secret in the same millisecond. + await s.h.call('POST', '/v1/api-keys', { bearer: s.admin, body: { name: 'one', scopes: ['workspaces:read'] } }) + await s.h.call('PUT', '/v1/secrets/org/SAME_MS', { bearer: s.admin, body: { value: SECRET_VALUES[0] } }) + await s.h.call('POST', '/v1/api-keys', { bearer: s.admin, body: { name: 'two', scopes: ['workspaces:read'] } }) + const all: Event[] = (await s.log(s.owner)).body.events + expect(new Set(all.map((event) => event.at)).size).toBe(1) + const first = await s.log(s.owner, 'limit=1') + const second = await s.log(s.owner, `limit=1&cursor=${first.body.nextCursor}`) + const third = await s.log(s.owner, `limit=1&cursor=${second.body.nextCursor}`) + expect([first, second, third].map((page) => page.body.events[0].id)).toEqual(all.map((event) => event.id)) + expect(third.body.nextCursor).toBeNull() + }) + + it('clamps the page size and refuses a malformed cursor, category or actor', async () => { + const s = await setup() + await seed(s) + expect((await s.log(s.owner, 'limit=100000')).status).toBe(200) + expect((await s.log(s.owner, 'limit=0')).body.events).toHaveLength(5) + for (const query of ['cursor=nope', 'category=everything', 'actor=a%20b', 'format=xml']) { + const refused = await s.log(s.owner, query) + expect([query, refused.status, refused.body.error]).toEqual([query, 400, 'invalid_request']) + } + }) +}) + +describe('filtering', () => { + it('by category, including across sources', async () => { + const s = await setup() + await seed(s) + const actions = async (query: string) => ((await s.log(s.owner, query)).body.events as Event[]).map((event) => event.action) + expect(await actions('category=secrets')).toEqual(['secret.updated', 'secret.created']) + expect(await actions('category=members')).toEqual(['member.removed']) + expect(await actions('category=api_keys')).toEqual(['api_key.create']) + expect(await actions('category=settings')).toEqual(['settings.update']) + expect(await actions('category=billing')).toEqual([]) + expect(await actions('category=other')).toEqual([]) + // Paging holds inside a filter. + const first = await s.log(s.owner, 'category=secrets&limit=1') + expect(first.body.events[0].action).toBe('secret.updated') + expect(((await s.log(s.owner, `category=secrets&limit=1&cursor=${first.body.nextCursor}`)).body.events as Event[]).map((event) => event.action)).toEqual(['secret.created']) + }) + + it('every action the control plane records has a category', () => { + const recorded = ['api_key.create', 'workspace_token.issue', 'settings.update', 'project.delete', 'workspace.share_create', 'sandbox.ssh_key_added', 'routine.run.start', 'mcp.authorize', 'member.role_synced', 'secret.deleted', 'mfa.policy.enabled', 'sso.went_live', 'scim.user_provisioned', 'billing.plan_changed'] + expect(recorded.map(categoryOf)).toEqual(['api_keys', 'api_keys', 'settings', 'settings', 'workspaces', 'workspaces', 'routines', 'apps', 'members', 'secrets', 'security', 'security', 'security', 'billing']) + expect(categoryOf('something.new')).toBe('other') + }) + + it('by actor: a person, or the system; what happened TO someone is not what they did', async () => { + const s = await setup() + await seed(s) + expect((await s.internal('/v1/internal/org-members/removed', { accountId: 'org:acme', userId: 'dave' })).status).toBe(200) + const actions = async (query: string) => ((await s.log(s.owner, query)).body.events as Event[]).map((event) => event.action) + expect(await actions('actor=alice')).toEqual(['secret.created', 'api_key.create']) + expect(await actions('actor=olga')).toEqual(['member.removed', 'secret.updated', 'settings.update']) + expect(await actions('actor=system')).toEqual(['member.removed']) + // Dave was removed by the system and did nothing himself. + expect(await actions('actor=dave')).toEqual([]) + expect(await actions('actor=olga&category=secrets')).toEqual(['secret.updated']) + const actors = (await s.log(s.owner)).body.actors as Array<{ userId: string; name: string | null; kind: string }> + expect(actors.map((actor) => [actor.userId, actor.name, actor.kind])).toEqual([ + ['alice', 'Alice Admin', 'person'], + ['olga', 'Olga Owner', 'person'] + ]) + }) +}) + +describe('who may read', () => { + it('owners and admins of the team; never a member, a personal account or a signed-out caller', async () => { + const s = await setup() + await seed(s) + expect((await s.log(s.owner)).status).toBe(200) + const admin = await s.h.token('ada', 'acme', 'sandbox', { org_role: 'admin' }) + expect((await s.log(admin)).status).toBe(200) + const member = await s.h.token('mia', 'acme', 'sandbox', { org_role: 'member' }) + expect((await s.log(member)).body).toEqual({ error: 'insufficient_scope', reason: 'audit:read' }) + expect((await s.log(s.solo)).body).toEqual({ error: 'forbidden', reason: 'team_required' }) + expect((await s.h.call('GET', '/v1/audit-log', { bearer: 'not-a-token' })).status).toBe(401) + }) + + it('an API key reads it only with audit:read, and never past its creator being demoted', async () => { + const s = await setup() + await seed(s) + const reader = await s.h.call('POST', '/v1/api-keys', { bearer: s.owner, body: { name: 'siem', scopes: ['audit:read'] } }) + const other = await s.h.call('POST', '/v1/api-keys', { bearer: s.owner, body: { name: 'ci', scopes: ['workspaces:read'] } }) + expect((await s.log(reader.body.secret)).status).toBe(200) + expect((await s.log(other.body.secret)).body).toEqual({ error: 'insufficient_scope', reason: 'audit:read' }) + expect((await s.internal('/v1/internal/members/role-changed', { accountId: 'org:acme', userId: 'olga', role: 'member' }, OPERATOR)).status).toBe(200) + expect((await s.log(reader.body.secret)).status).toBe(403) + }) +}) + +describe('the plan gate', () => { + it('refuses a team without the audit_log entitlement, for the list and the export, and says why', async () => { + const s = await setup({ entitled: false }) + await seed(s) + for (const query of ['', 'format=csv', 'category=secrets']) { + const refused = await s.log(s.owner, query) + expect(refused.status).toBe(403) + expect(refused.body).toEqual({ error: 'entitlement_denied', reason: 'audit_log' }) + expect(JSON.stringify(refused.body)).not.toContain('STRIPE_KEY') + } + }) + + it('checks the role before the plan, so a member learns nothing about the plan', async () => { + const s = await setup({ entitled: false }) + expect((await s.log(s.member)).body).toEqual({ error: 'insufficient_scope', reason: 'audit:read' }) + }) + + it('keeps recording without the entitlement, so upgrading shows the history', async () => { + const s = await setup({ entitled: false }) + await seed(s) + expect((await s.log(s.owner)).status).toBe(403) + s.h.billing.features = { auditLog: true } + expect(((await s.log(s.owner)).body.events as Event[]).map((event) => event.action)).toEqual(['member.removed', 'secret.updated', 'settings.update', 'secret.created', 'api_key.create']) + }) + + it('fails closed when billing is down: 503, never an open log and never "not on the plan"', async () => { + const s = await setup() + await seed(s) + s.h.billing.down = true + const refused = await s.log(s.owner) + expect(refused.status).toBe(503) + expect(refused.body).toEqual({ error: 'billing_unavailable' }) + }) + + it("asks billing about the caller's own team", async () => { + const s = await setup() + const asked: string[] = [] + s.h.deps.entitlements!.features = async (accountId) => { + asked.push(accountId) + return { auditLog: accountId === 'org:acme' } + } + expect((await s.log(s.owner)).status).toBe(200) + const other = await s.h.token('zed', 'globex', 'sandbox', { org_role: 'owner' }) + expect((await s.log(other)).status).toBe(403) + expect(asked).toEqual(['org:acme', 'org:globex']) + }) +}) + +describe('secrets never leak', () => { + it('no value, hint or last four in the JSON, the CSV or the stored rows', async () => { + const s = await setup() + await seed(s) + // An audit entry that (wrongly) carried credential-looking fields is still not served. + s.h.raw + .prepare(`INSERT INTO audit_log (id, account_id, actor_type, actor_id, user_id, action, target_type, target_id, detail, created_at) VALUES (?, ?, 'user', 'olga', 'olga', 'settings.update', 'account', 'org:acme', ?, ?)`) + .run('aud_leaky', 'org:acme', JSON.stringify({ name: 'safe', apiToken: 'tok_should_not_show', secretValue: 'val_should_not_show', hint: 'wxyz', password: 'pw_should_not_show' }), s.h.now()) + const json = await s.log(s.owner) + const csv = await (await s.h.send(new Request('https://sandbox.test/v1/audit-log?format=csv', { headers: { authorization: `Bearer ${s.owner}` } }))).text() + const stored = JSON.stringify(s.h.raw.prepare('SELECT * FROM secret_events').all()) + for (const text of [JSON.stringify(json.body), csv, stored]) { + for (const value of SECRET_VALUES) { + expect(text).not.toContain(value) + expect(text).not.toContain(value.slice(-4)) + } + expect(text).not.toMatch(/should_not_show/) + } + const leaky = (json.body.events as Event[]).find((event) => event.id === 'aud_leaky')! + expect(leaky.detail).toEqual({ name: 'safe' }) + // The names are there: that is the point of the log. + expect(JSON.stringify(json.body)).toContain('STRIPE_KEY') + }) + + it("a member's personal secrets never appear in the team's log", async () => { + const s = await setup() + expect((await s.h.call('PUT', '/v1/secrets/personal/MY_OWN', { bearer: s.admin, body: { value: SECRET_VALUES[0] } })).status).toBe(200) + expect(JSON.stringify((await s.log(s.owner)).body)).not.toContain('MY_OWN') + }) +}) + +describe('CSV export', () => { + it('exports the filtered log with a header, quoting and formula-safe cells', async () => { + const s = await setup() + await seed(s) + await s.h.call('POST', '/v1/api-keys', { bearer: s.owner, body: { name: '=HYPERLINK("http://evil","x"), "quoted"', scopes: ['workspaces:read'] } }) + const response = await s.h.send(new Request('https://sandbox.test/v1/audit-log?format=csv&category=api_keys', { headers: { authorization: `Bearer ${s.owner}` } })) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/csv; charset=utf-8') + expect(response.headers.get('content-disposition')).toBe('attachment; filename="orca-audit-log.csv"') + expect(response.headers.get('x-orca-audit-truncated')).toBe('false') + const lines = (await response.text()).trimEnd().split('\r\n') + expect(lines[0]).toBe(CSV_HEADER.join(',')) + expect(lines).toHaveLength(3) + expect(lines[1]).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z,api_keys,api_key\.create,person,Olga Owner,olga,,api_key,key_[A-Za-z0-9]+,/) + // The label starts with "=": quoted as text, with its quotes doubled. + expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"",""x""), ""quoted"""`) + expect(lines[2]).toContain(',Alice Admin,alice,') + }) +}) + +describe('events from other services (POST /v1/internal/audit-events)', () => { + const invite = { eventId: 'evt_invite_0001', action: 'member.invited', actorUserId: 'olga', target: { type: 'invite', label: 'new@acme.dev' }, detail: { role: 'member' } } + + it('appends them to the same log, once, with their actor, target and category', async () => { + const s = await setup() + await s.h.call('GET', '/v1/me', { bearer: s.owner }) + const body = { + accountId: 'org:acme', + events: [invite, { eventId: 'evt_mfa_0001', action: 'mfa.policy.enabled', actorUserId: 'olga', target: { type: 'policy' }, detail: { grace: 7, pendingMembers: 3 } }, { eventId: 'evt_scim_0001', action: 'scim.user_provisioned', target: { type: 'member', id: 'erin' } }] + } + expect((await s.internal('/v1/internal/audit-events', body)).body).toEqual({ recorded: 3, duplicates: 0 }) + // A retry records nothing twice. + expect((await s.internal('/v1/internal/audit-events', body)).body).toEqual({ recorded: 0, duplicates: 3 }) + const events: Event[] = (await s.log(s.owner)).body.events + expect(events).toHaveLength(3) + const byAction = Object.fromEntries(events.map((event) => [event.action, event])) + expect(byAction['member.invited']).toMatchObject({ category: 'members', actor: { kind: 'person', userId: 'olga', name: 'Olga Owner' }, target: { type: 'invite', id: null, label: 'new@acme.dev' }, detail: { role: 'member' } }) + expect(byAction['mfa.policy.enabled']).toMatchObject({ category: 'security', target: { type: 'policy' }, detail: { grace: 7, pendingMembers: 3 } }) + expect(byAction['scim.user_provisioned']).toMatchObject({ category: 'security', actor: { kind: 'system', userId: null }, target: { type: 'member', id: 'erin' } }) + }) + + it("accepts auth's token or the operator's, and nothing else", async () => { + const s = await setup() + const body = { accountId: 'org:acme', events: [invite] } + expect((await s.internal('/v1/internal/audit-events', body, OPERATOR)).status).toBe(200) + expect((await s.internal('/v1/internal/audit-events', body, 'x'.repeat(40))).status).toBe(404) + expect((await s.internal('/v1/internal/audit-events', body, s.owner)).status).toBe(404) + // Auth's token still opens none of the operator routes. + expect((await s.h.call('GET', '/v1/internal/status', { bearer: AUTH_TOKEN })).status).toBe(404) + }) + + it('refuses anything that could pass for another kind of change, hold a credential, or post-date the log', async () => { + const s = await setup() + const post = (event: Record, accountId = 'org:acme') => s.internal('/v1/internal/audit-events', { accountId, events: [{ ...invite, ...event }] }) + for (const action of ['api_key.create', 'secret.deleted', 'workspace.destroy', 'settings.update', 'member', 'MEMBER.invited', 'member.removed', 'member.role_changed']) { + expect([action, (await post({ action })).status]).toEqual([action, 400]) + } + expect((await post({ detail: { resetCode: '123456' } })).body).toEqual({ error: 'invalid_request', reason: 'detail_key_not_allowed:resetCode' }) + expect((await post({ detail: { scimToken: 'x' } })).status).toBe(400) + expect((await post({ detail: { nested: { a: 1 } } })).status).toBe(400) + expect((await post({ unknown: true })).status).toBe(400) + expect((await post({}, 'user:alice')).status).toBe(400) + expect((await s.log(s.owner)).body.events).toEqual([]) + // A sender's fast clock can't put an event in the future. + expect((await post({ at: s.h.now() + 86_400_000 })).status).toBe(200) + expect((await s.log(s.owner)).body.events[0].at).toBe(s.h.now()) + }) +}) + +describe('the older GET /v1/audit', () => { + it('is behind the same plan gate for a team, so it is no way around it; a personal account keeps its own', async () => { + const s = await setup({ entitled: false }) + await seed(s) + expect((await s.h.call('GET', '/v1/audit', { bearer: s.owner })).body).toEqual({ error: 'entitlement_denied', reason: 'audit_log' }) + const key = await s.h.call('POST', '/v1/api-keys', { bearer: s.owner, body: { name: 'siem', scopes: ['audit:read'] } }) + expect((await s.h.call('GET', '/v1/audit', { bearer: key.body.secret })).status).toBe(403) + s.h.billing.features = { auditLog: true } + expect((await s.h.call('GET', '/v1/audit', { bearer: key.body.secret })).status).toBe(200) + // No team, no team log: a personal account still reads its own API activity on any plan. + s.h.billing.features = { auditLog: false } + await s.h.call('POST', '/v1/api-keys', { bearer: s.solo, body: { name: 'mine', scopes: ['workspaces:read'] } }) + const own = await s.h.call('GET', '/v1/audit', { bearer: s.solo }) + expect(own.status).toBe(200) + expect(own.body.entries.map((entry: { action: string }) => entry.action)).toEqual(['api_key.create']) + // But never the unified route. + expect((await s.log(s.solo)).body).toEqual({ error: 'forbidden', reason: 'team_required' }) + }) +}) + +describe('what is recorded', () => { + it('desktop workspace changes: create, share, stop sharing, archive, unarchive and delete, each by the person who did it', async () => { + const h = await createHarness({ SHARE_LINK_SECRET: 's'.repeat(40), SHARE_HOST_SUFFIX: 'share.test' }) + h.billing.features = { auditLog: true } + const owner = await h.token('olga', 'acme', 'sandbox', { org_role: 'owner', name: 'Olga Owner' }) + const created = await h.call('POST', '/v1/sandboxes', { bearer: owner, body: { workspaceId: 'ws-1', machineSize: 'standard' } }) + expect(created.status).toBe(200) + const id: string = created.body.sandbox.id + h.advance(1000) + const share = await h.call('POST', `/v1/sandboxes/${id}/shares`, { bearer: owner, body: { port: 3000, audience: 'team', expiresIn: '1h' } }) + expect(share.status).toBe(200) + h.advance(1000) + expect((await h.call('DELETE', `/v1/sandboxes/${id}/shares/${share.body.share.id}`, { bearer: owner })).status).toBe(200) + h.advance(1000) + expect((await h.call('POST', `/v1/sandboxes/${id}/archive`, { bearer: owner, body: {} })).status).toBe(200) + h.advance(1000) + expect((await h.call('POST', `/v1/sandboxes/${id}/unarchive`, { bearer: owner })).status).toBe(200) + h.advance(1000) + expect((await h.call('DELETE', `/v1/sandboxes/${id}`, { bearer: owner })).status).toBe(200) + const events: Event[] = (await h.call('GET', '/v1/audit-log?category=workspaces', { bearer: owner })).body.events + expect(events.map((event) => event.action)).toEqual(['workspace.destroy', 'workspace.unarchive', 'workspace.archive', 'workspace.share_revoke', 'workspace.share_create', 'workspace.create']) + expect(events.every((event) => event.actor.userId === 'olga' && event.actor.kind === 'person' && event.target?.type === 'workspace' && event.target.id === id)).toBe(true) + // The share link itself (a bearer URL) is never in the log. + expect(JSON.stringify(events)).not.toMatch(/https?:\/\/|share\.test/) + expect(events.find((event) => event.action === 'workspace.share_create')!.detail).toEqual({ shareId: share.body.share.id, port: 3000, audience: 'team', expiresIn: '1h' }) + }) + + it('audit entries the old GET /v1/audit serves are the same rows, so nothing is lost for API users', async () => { + const s = await setup() + await seed(s) + const old = await s.h.call('GET', '/v1/audit', { bearer: s.owner }) + const unified: Event[] = (await s.log(s.owner)).body.events + expect(old.body.entries.map((entry: { id: string }) => entry.id)).toEqual(unified.filter((event) => !event.id.startsWith('sev_')).map((event) => event.id)) + }) +}) diff --git a/cloud/apps/sandbox-control/test/device-sessions.test.ts b/cloud/apps/sandbox-control/test/device-sessions.test.ts index 4501e6a149c..8721ddc2e7f 100644 --- a/cloud/apps/sandbox-control/test/device-sessions.test.ts +++ b/cloud/apps/sandbox-control/test/device-sessions.test.ts @@ -15,6 +15,8 @@ const OP = 'o'.repeat(40) async function setup() { vi.spyOn(console, 'log').mockImplementation(() => {}) const h = await createHarness({ OPERATOR_TOKEN: OP }) + // On Enterprise, so a refused audit read below is refused for the role, not the plan. + h.billing.features = { auditLog: true } const key = await deviceKey() const enroll = (bearer?: string, publicKeyB64 = key.publicKeyB64) => h.call('POST', '/v1/device-sessions', { body: { label: 'Neil’s iPhone', platform: 'ios', publicKeyB64 }, bearer }) diff --git a/cloud/apps/sandbox-control/test/harness.ts b/cloud/apps/sandbox-control/test/harness.ts index 2cad1040f42..945c243cebf 100644 --- a/cloud/apps/sandbox-control/test/harness.ts +++ b/cloud/apps/sandbox-control/test/harness.ts @@ -1,6 +1,6 @@ import { SignJWT, exportJWK, generateKeyPair } from 'jose' import { AUTH_IAT_BACKDATE_MS } from '../src/member-changes' -import type { EntitlementClient, StartDecision, UsageAnswer, UsageSegment } from '../src/entitlements' +import { BillingUnavailableError, type EntitlementClient, type PlanFeatures, type StartDecision, type UsageAnswer, type UsageSegment } from '../src/entitlements' import type { Env } from '../src/env' import { handleRequest, type Deps } from '../src/index' import { reconcile } from '../src/reconcile' @@ -23,7 +23,9 @@ export async function createHarness(overrides: Partial = {}, extraDeps: Par down: false, checks: [] as { accountId: string; runningSandboxes: number }[], requests: [] as Parameters[0][], - usage: [] as UsageSegment[] + usage: [] as UsageSegment[], + /** Plan features billing reports (the audit log's Enterprise gate): off unless a test turns one on. */ + features: { auditLog: false } as PlanFeatures } const entitlements: EntitlementClient = { async checkStart(request) { @@ -40,6 +42,12 @@ export async function createHarness(overrides: Partial = {}, extraDeps: Par }, async listSuspensions() { return billing.suspensions + }, + async features() { + if (billing.down) { + throw new BillingUnavailableError('billing down') + } + return billing.features } } const env: Env = { diff --git a/cloud/apps/sandbox-control/test/ssh-access.test.ts b/cloud/apps/sandbox-control/test/ssh-access.test.ts index f679f2f9d15..23af59fbb5e 100644 --- a/cloud/apps/sandbox-control/test/ssh-access.test.ts +++ b/cloud/apps/sandbox-control/test/ssh-access.test.ts @@ -23,7 +23,7 @@ describe('ssh key routes', () => { // The key travels in the env, never on the command line. expect(command).toMatchObject({ command: SSH_KEYS_COMMAND, args: ['add'], env: { ORCA_SSH_PUBLIC_KEY: KEY } }) expect([...h.vercel.authorizedKeys.values()]).toEqual([[KEY]]) - expect(h.raw.prepare('SELECT action, target_id, detail FROM audit_log').all()).toEqual([ + expect(h.raw.prepare("SELECT action, target_id, detail FROM audit_log WHERE action LIKE 'sandbox.ssh%'").all()).toEqual([ { action: 'sandbox.ssh_key_added', target_id: id, detail: JSON.stringify({ key: 'orca:mac-studio:ws-1' }) } ]) })