Merge pull request #613 from nwparker/cloud/enterprise-audit-log

feat(cloud): ENTERPRISE-AUDIT (1/2) unified team audit log API, Enterprise feature entitlements, audit ingest
This commit is contained in:
Neil
2026-11-12 21:45:16 -08:00
committed by GitHub
23 changed files with 1409 additions and 28 deletions
+20 -1
View File
@@ -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" }
]
}
]
},
+32 -4
View File
@@ -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<EntitlementKey, { schema: z.ZodType; a
max_concurrent_sandboxes: { schema: NonNegativeInt, aggregate: 'sum', perSeatAllowed: true },
max_concurrent_sandboxes_per_member: { schema: NonNegativeInt, aggregate: 'max', perSeatAllowed: false },
machine_sizes: { schema: z.array(z.enum(MACHINE_SIZES)).max(MACHINE_SIZES.length), aggregate: 'union', perSeatAllowed: false },
overage_allowed: { schema: z.boolean(), aggregate: 'or', perSeatAllowed: false }
overage_allowed: { schema: z.boolean(), aggregate: 'or', perSeatAllowed: false },
audit_log: { schema: z.boolean(), aggregate: 'feature', perSeatAllowed: false },
sso: { schema: z.boolean(), aggregate: 'feature', perSeatAllowed: false },
scim: { schema: z.boolean(), aggregate: 'feature', perSeatAllowed: false },
mfa_enforcement: { schema: z.boolean(), aggregate: 'feature', perSeatAllowed: false }
}
/**
* Boolean features a plan switches on. They gate a surface, never sandbox starts or usage. A new
* one is a key above, a `feature` registry row, an entry here, and a catalog version that sets it.
*/
export const FEATURE_KEYS = ['audit_log', 'sso', 'scim', 'mfa_enforcement'] as const satisfies readonly EntitlementKey[]
export type FeatureKey = (typeof FEATURE_KEYS)[number]
export type FeatureFlags = Record<FeatureKey, boolean>
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<string, number> = {}
const sizes = new Set<MachineSize>()
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
}
}
+37
View File
@@ -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<Env, 'BILLING_DB' | 'ENTITLEMENTS_MODE'>, accountId: string, now: number): Promise<FeatureFlags> {
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
}
+43
View File
@@ -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:<planId>:<ticket>`. 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<Record<EntitlementKey, { value: unknown; perSeat: boolean }>> = {}
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 }
+14
View File
@@ -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)
+6 -2
View File
@@ -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
})),
+123
View File
@@ -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<ReturnType<typeof createHarness>>, 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<ReturnType<typeof createHarness>>, 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' })
})
})
+8 -1
View File
@@ -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
})
})
})
@@ -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<typeof AuditIngestSchema>
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<string, unknown> = {}
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 }
}
@@ -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<readonly [string, AuditCategory]> = [
['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<string, unknown> | 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: `<created_at>.<id>` 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<string | number> } {
const clauses: string[] = []
const binds: Array<string | number> = []
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<string, unknown> | 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<string, unknown> = {}
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<string, AuditActor['via']> = { api_key: 'api_key', mcp: 'mcp', workspace_token: 'agent', routine: 'routine' }
type Labels = { names: ReadonlyMap<string, string>; former: ReadonlySet<string>; workspaces: ReadonlyMap<string, string> }
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<T>(ids: readonly string[], run: (chunk: string[], marks: string) => Promise<T[]>): Promise<T[]> {
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<Labels> {
const people = new Set<string>()
const workspaceIds = new Set<string>()
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<MergedRow>()
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<AuditActor[]> {
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<ApiContext, 'features'>): Promise<void> {
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<ApiCaller, 'accountId'>, ctx: Pick<ApiContext, 'features'>): Promise<void> {
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<Response> {
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' } })
}
@@ -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<void> {
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
+39 -1
View File
@@ -209,6 +209,26 @@ const schemas: Record<string, object> = {
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<string, Record<string, Op>> = {
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<string, Record<string, Op>> = {
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: {
+14 -1
View File
@@ -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<unknown>) => void
readJson: () => Promise<unknown>
/** Plan features from billing (the audit log's Enterprise gate). Absent: every feature is off. */
features?: (accountId: string) => Promise<PlanFeatures>
/** 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<typeof authenticateApi>[0], ctx: ApiContext): Promise<Response> {
@@ -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 })
+20 -3
View File
@@ -42,6 +42,14 @@ export type EntitlementClient = {
reportUsage(segment: UsageSegment): Promise<UsageAnswer>
/** Accounts whose payment grace expired; polled by the cron. */
listSuspensions(): Promise<string[]>
/** Plan features that gate a surface (never sandbox starts). Absent on older test doubles: all off. */
features?(accountId: string): Promise<PlanFeatures>
}
/** 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 }
}
}
}
+36 -5
View File
@@ -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<Res
// Auth's token (MEMBER_REMOVED_TOKEN) is good for its own routes only: the member-removed and
// role-changed pushes and deleting a team. Never the other operator routes.
const teamDelete = teamDeleteRoute(url.pathname, request.method)
// Append-only events from auth for the team audit log (api/audit-ingest.ts).
const auditIngest = url.pathname === '/v1/internal/audit-events' && request.method === 'POST'
const memberPush =
request.method === 'POST' && (url.pathname === '/v1/internal/org-members/removed' || url.pathname === '/v1/internal/members/role-changed')
const authOnly = (memberPush || teamDelete !== null) && (await isOperator(request, env.MEMBER_REMOVED_TOKEN))
const authOnly = (memberPush || teamDelete !== null || auditIngest) && (await isOperator(request, env.MEMBER_REMOVED_TOKEN))
if (!authOnly && !(await isOperator(request, env.OPERATOR_TOKEN))) {
return fail('not_found', 404)
}
if (auditIngest) {
// Append-only, and needs no sandbox provider: events from auth and billing for the team audit log.
const body = AuditIngestSchema.safeParse(await readJson(request, MAX_API_JSON_BYTES))
if (!body.success) {
return fail('invalid_request', 400)
}
try {
return json(await ingestAuditEvents(env.SANDBOX_DB, body.data, (deps.now ?? Date.now)()))
} catch (error) {
if (error instanceof AuditIngestError) {
return fail('invalid_request', 400, error.message)
}
throw error
}
}
if (teamDelete) {
if (teamDelete.action === 'delete-check') {
return json({ workspaces: await teamWorkspaceCount(env.SANDBOX_DB, teamDelete.accountId) })
@@ -1300,7 +1323,7 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
return fail('invalid_request', 400)
}
// Same cleanup as /v1/internal/members/removed: keys, phones, workspace grants and routines.
const removal = await removeMember(life, body.data.accountId, body.data.userId, life.now(), body.data.changedAt)
const removal = await removeMember(life, body.data.accountId, body.data.userId, life.now(), body.data.changedAt, body.data.actorUserId)
return json({ revoked: removal.grantsRevoked, queued: removal.grantsQueued, ...removal })
}
return (
@@ -1701,6 +1724,7 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
deferMemberGit: true
})
await recordOwnerDeviceForCreate(life, ready)
await auditSession(life.db, caller, { action: 'workspace.create', targetType: 'workspace', targetId: ready.row.id, detail: { origin: 'desktop' } }, life.now())
timings = ready.timings
return { status: 200, body: readyView(ready.row, ready.connection, ready.cloning, features.includes('repo-update')) }
}
@@ -1752,6 +1776,8 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
.bind(life.now(), row.id),
life.db.prepare('DELETE FROM workspace_presence WHERE sandbox_id = ?').bind(row.id)
])
// The name goes with the row's history: the workspace can no longer be looked up.
await auditSession(life.db, caller, { action: 'workspace.destroy', targetType: 'workspace', targetId: row.id, detail: { name: row.name ?? null } }, life.now())
return json({ sandbox: view(destroyed) })
}
case 'POST suspend': {
@@ -1805,12 +1831,15 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
if (!body.success) {
return fail('invalid_request', 400)
}
return json({ sandbox: view(await archiveSandbox(life, row, body.data, workspaceConfig(env, deps))) })
const archived = await archiveSandbox(life, row, body.data, workspaceConfig(env, deps))
await auditSession(life.db, caller, { action: 'workspace.archive', targetType: 'workspace', targetId: row.id }, life.now())
return json({ sandbox: view(archived) })
}
case 'GET archive':
return json({ archive: await readArchive(env.SANDBOX_DB, row.id) })
case 'POST unarchive': {
const ready = await unarchiveSandbox(life, row)
await auditSession(life.db, caller, { action: 'workspace.unarchive', targetType: 'workspace', targetId: row.id }, life.now())
return json(readyView(ready.row, ready.connection))
}
case 'POST env/refresh':
@@ -1994,7 +2023,9 @@ const MemberRemovedSchema = z
accountId: z.string().min(1).max(160).regex(/^(org|dev:org):[A-Za-z0-9_.:-]+$/),
userId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/),
/** Auth's time of the removal (ms); tokens issued up to then stop working. */
changedAt: z.number().int().positive().optional()
changedAt: z.number().int().positive().optional(),
/** Who removed them (the same id when they left by themselves); absent from a directory sync. */
actorUserId: z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/).optional()
})
.strict()
@@ -19,12 +19,29 @@ export type MemberRemoval = {
readerKeysRevoked: number
}
/**
* The audit actor for a membership event auth pushed: the person who made the change when auth
* names one, else the system with the affected member in user_id (how older rows were written).
*/
export function memberEventActor(accountId: string, memberId: string, actorUserId?: string) {
return actorUserId
? { accountId, type: 'user', id: actorUserId, userId: actorUserId }
: { accountId, type: 'system', id: 'auth', userId: memberId }
}
/** With a named actor the member is the target; without one they stay in user_id (see above). */
export function memberEventTarget(memberId: string, actorUserId?: string): { targetType?: string; targetId?: string } {
return actorUserId ? { targetType: 'member', targetId: memberId } : {}
}
export async function removeMember(
life: LifecycleDeps,
accountId: string,
userId: string,
now: number,
reportedAt?: number
reportedAt?: number,
/** Who removed them in auth, when it says; the same id means they left by themselves. */
actorUserId?: string
): Promise<MemberRemoval> {
// 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
}
+7 -5
View File
@@ -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': {
@@ -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
@@ -6,6 +6,8 @@ const OP = 'o'.repeat(40)
async function harness() {
const pending: Promise<unknown>[] = []
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)) }
}
@@ -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<string, unknown> | 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<ReturnType<typeof setup>>) {
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<string, unknown>, 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))
})
})
@@ -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 })
+10 -2
View File
@@ -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<Env> = {}, extraDeps: Par
down: false,
checks: [] as { accountId: string; runningSandboxes: number }[],
requests: [] as Parameters<EntitlementClient['checkStart']>[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<Env> = {}, extraDeps: Par
},
async listSuspensions() {
return billing.suspensions
},
async features() {
if (billing.down) {
throw new BillingUnavailableError('billing down')
}
return billing.features
}
}
const env: Env = {
@@ -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' }) }
])
})