Merge cloud/integration into SEC-6 (KMS unwrapper; kms_unavailable log via secretLog)

This commit is contained in:
Neil
2026-11-12 22:40:50 -08:00
57 changed files with 3082 additions and 397 deletions
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -48,7 +48,7 @@ nudge_once() {
local mark="${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)"
[ -e "$mark" ] && return 0
: >"$mark" 2>/dev/null || return 0
printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." >&2
printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." >&2
}
# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's
# connected GitHub nor the App installation) must never fall through to git's own "Username for
@@ -91,7 +91,7 @@ github_remote_slug() {
no_access_line() {
local owner="${github_slug%%/*}" verb='push to'
[ "$sub" = push ] || verb='read'
printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account." >&2
printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account." >&2
}
# GitHub's "not found" is a typo'd name or a private repo it won't confirm: say which half fits whom.
not_found_line() {
+15
View File
@@ -99,6 +99,21 @@ export type Env = {
SECRETS_KEK?: string
SECRETS_KEK_ID?: string
SECRETS_KEK_RETIRED?: string
/**
* `kms`: the KEKs are held encrypted by a Google Cloud KMS key and unwrapped once per isolate
* (src/secrets-kms.ts). Unset (default): SECRETS_KEK / SECRETS_KEK_RETIRED above, unchanged.
* With `kms`, SECRETS_KEK and SECRETS_KEK_RETIRED are ignored, and a KMS failure answers 503
* `secrets_unavailable` (sessions do not start without their secrets).
* SECRETS_KMS_KEY projects/<p>/locations/<l>/keyRings/<r>/cryptoKeys/<k>
* SECRETS_KMS_SERVICE_ACCOUNT (secret) service-account JSON allowed to decrypt with that key only
* SECRETS_KEK_WRAPPED base64 KMS ciphertext of the active KEK's 32 bytes, AAD `orca-secrets-kek:<id>`
* SECRETS_KEK_RETIRED_WRAPPED JSON {"<id>":"<base64 KMS ciphertext>"} after a rotation
*/
SECRETS_KEK_SOURCE?: string
SECRETS_KMS_KEY?: string
SECRETS_KMS_SERVICE_ACCOUNT?: string
SECRETS_KEK_WRAPPED?: string
SECRETS_KEK_RETIRED_WRAPPED?: string
/**
* `v1` (default) | `v2`: the row format new secrets are sealed in (src/secrets-crypto.ts). With
* v2 a KEK rotation re-wraps data keys only. Set it only once every Worker version that reads this
+26 -7
View File
@@ -119,6 +119,7 @@ import { DEFAULT_RETENTION, type RetentionConfig } from './retention'
import { reconcile } from './reconcile'
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets'
import { SecretsKmsError } from './secrets-kms'
import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh'
import { ACCOUNT_PROVIDERS, accountProvider, isSharedWorkspace } from './account-providers'
import { handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks'
@@ -880,7 +881,7 @@ export async function lifecycleDeps(env: Env, deps: Deps): Promise<LifecycleDeps
provider,
entitlements: deps.entitlements ?? withDevAccountBypass(env, createEntitlementClient(env)),
github: deps.github ?? githubMinter(env),
secrets: await secretsDeps(env),
...(await secretsState(env)),
memberIdentity: await memberIdentityFor(env, deps),
now: deps.now ?? Date.now,
sleep: deps.sleep,
@@ -1113,17 +1114,22 @@ export function secretsEgressMode(env: Pick<Env, 'SECRETS_EGRESS_PLACEHOLDERS' |
return { egressPlaceholders, egressPlaceholdersOff: off }
}
async function secretsDeps(env: Env): Promise<LifecycleDeps['secrets']> {
async function secretsState(env: Env): Promise<Pick<LifecycleDeps, 'secrets' | 'secretsUnavailable'>> {
const keyring = keyringFor(env)
if (!keyring) {
return undefined
return {}
}
try {
return { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) }
} catch {
return { secrets: { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) } }
} catch (error) {
if (error instanceof SecretsKmsError) {
// KMS holds the keys and is not handing them over. Routes that need no secrets keep working;
// a session start and the secrets routes answer 503 (keyringFor logged the reason).
return { secretsUnavailable: error.reason }
}
// A malformed KEK must not block sandbox starts; the secrets routes surface it as 503.
console.log(JSON.stringify({ event: 'secrets_kek_invalid' }))
return undefined
return {}
}
}
@@ -1552,7 +1558,10 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Res
// ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries.
return fail('unauthorized', 401, 'stale_token')
}
const secrets = await secretsDeps(env)
const { secrets, secretsUnavailable } = await secretsState(env)
if (secretsUnavailable) {
return secretsUnavailableResponse()
}
if (!secrets) {
return fail('not_configured', 503)
}
@@ -2315,6 +2324,13 @@ async function githubReturn(env: Env, deps: Deps, url: URL): Promise<Response> {
}
}
/** The KMS-held secret keys cannot be fetched right now (SECRETS_KEK_SOURCE=kms). Retryable. */
function secretsUnavailableResponse(): Response {
const response = fail('secrets_unavailable', 503)
response.headers.set('retry-after', '30')
return response
}
function errorResponse(error: unknown): Response {
if (error instanceof SyntaxError) {
return fail('invalid_json', 400)
@@ -2354,6 +2370,9 @@ function errorResponse(error: unknown): Response {
if (error instanceof BillingUnavailableError) {
return fail('billing_unavailable', 503)
}
if (error instanceof SecretsKmsError) {
return secretsUnavailableResponse()
}
// Provider and VM failures are summarized; their bodies never reach the desktop.
if (error instanceof ProviderError || error instanceof VmCommandError) {
return fail('provider_unavailable', 502, error instanceof VmCommandError ? error.code : undefined)
@@ -24,6 +24,7 @@ import { ProviderError, type ProviderSandboxState, type SandboxProviderApi } fro
import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress'
import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env'
import type { Keyring } from './secrets-crypto'
import type { SecretsKmsReason } from './secrets-kms'
import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots'
import {
ORCAD_PORT,
@@ -89,6 +90,8 @@ export type LifecycleDeps = {
* egressPlaceholders: every account (`true`) or only the listed account ids (staged rollout);
* egressPlaceholdersOff: per-account kill switch that wins over both.
*/
/** Set while the KMS-held keys cannot be fetched: a session start fails instead of running without its secrets. */
secretsUnavailable?: SecretsKmsReason
secrets?: {
keyring: Keyring
egressPlaceholders: boolean | ReadonlySet<string>
+5 -1
View File
@@ -23,6 +23,7 @@ import { drainAll, housekeeping } from './retention'
import { memberChangeTime, recordMemberChange } from './member-changes'
import { memberEventActor, memberEventTarget, removeMember } from './member-removal'
import type { Keyring } from './secrets-crypto'
import { SecretsKmsError } from './secrets-kms'
import { kekStatus, rewrapBatch, RewrapError, REWRAP_MAX_LIMIT } from './secrets-rewrap'
import { fairUseReport, FAIR_USE_WINDOW_DAYS, sweepSessionCpu } from './session-cpu'
import { listByStatus } from './store'
@@ -174,7 +175,10 @@ export async function handleOperator(
let keyring: Keyring | null
try {
keyring = (await deps.keyring?.()) ?? null
} catch {
} catch (error) {
if (error instanceof SecretsKmsError) {
return Response.json({ error: 'secrets_unavailable', reason: error.reason }, { status: 503, headers: noStore })
}
// A malformed SECRETS_KEK* value. The cause is not echoed: it could quote key material.
return Response.json({ error: 'secrets_misconfigured' }, { status: 503, headers: noStore })
}
@@ -0,0 +1,143 @@
// Google Cloud KMS unwrap of the secret store's KEKs (SECRETS_KEK_SOURCE=kms; off by default).
// The Worker then holds no plaintext KEK in its config: it holds each KEK encrypted by a KMS key
// (SECRETS_KEK_WRAPPED) and a service account that may only decrypt with that key. keyringFor
// (secrets.ts) unwraps once per isolate and keeps the imported keys in memory.
// Each wrapped KEK is bound to its key id through KMS additional authenticated data, so a blob
// deployed under the wrong id fails to unwrap instead of sealing rows under a mislabelled key.
// Nothing from a KMS or token response is logged or put in an error: only a reason and a status.
import { SignJWT, importPKCS8 } from 'jose'
import { z } from 'zod'
export type SecretsKmsReason =
/** SECRETS_KEK_SOURCE=kms but a KMS setting is missing or malformed. */
| 'config'
/** Google refused the service account or its permission on the key (401/403). */
| 'denied'
/** KMS rejected the wrapped KEK: wrong key, wrong KEK id, or a corrupted blob (400/404). */
| 'invalid'
/** KMS or the token endpoint could not be reached, timed out or answered 5xx. */
| 'unavailable'
/** The secret store cannot get its keys right now. Callers answer 503; they never fall back to another key. */
export class SecretsKmsError extends Error {
constructor(
readonly reason: SecretsKmsReason,
readonly status?: number
) {
super(`secrets_kms_${reason}`)
this.name = 'SecretsKmsError'
}
}
const KEY_NAME = /^projects\/[a-z][a-z0-9-]{4,28}[a-z0-9]\/locations\/[a-z0-9-]{1,40}\/keyRings\/[A-Za-z0-9_-]{1,63}\/cryptoKeys\/[A-Za-z0-9_-]{1,63}$/
// Fixed endpoints: the service-account file's own token_uri is not trusted.
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const KMS_URL = 'https://cloudkms.googleapis.com/v1'
const SCOPE = 'https://www.googleapis.com/auth/cloudkms'
const TIMEOUT_MS = 5000
const ATTEMPTS = 2
const ServiceAccount = z.looseObject({ client_email: z.string().min(3).max(320), private_key: z.string().min(100) })
const TokenResponse = z.looseObject({ access_token: z.string().min(1), expires_in: z.number().positive().optional() })
const DecryptResponse = z.looseObject({ plaintext: z.string().min(1) })
/** The AAD a KEK is wrapped with (`gcloud kms encrypt --additional-authenticated-data-file`). */
export function kekWrapAad(kekId: string): string {
return `orca-secrets-kek:${kekId}`
}
export type KekUnwrapper = (wrapped: string, kekId: string) => Promise<string>
/**
* Returns `unwrap(wrapped, kekId)`: the base64 KEK inside a base64 KMS ciphertext. Throws
* SecretsKmsError; a 5xx or network failure is retried once.
*/
export function createKekUnwrapper(config: { keyName: string; serviceAccountJson: string; fetch?: typeof fetch; now?: () => number }): KekUnwrapper {
const doFetch = config.fetch ?? fetch
const now = config.now ?? Date.now
if (!KEY_NAME.test(config.keyName)) {
throw new SecretsKmsError('config')
}
let account: z.infer<typeof ServiceAccount>
try {
account = ServiceAccount.parse(JSON.parse(config.serviceAccountJson))
} catch {
// The parse error could quote the credential; it is dropped.
throw new SecretsKmsError('config')
}
let signingKey: CryptoKey | null = null
let token: { value: string; expiresAt: number } | null = null
async function send(url: string, init: RequestInit): Promise<Response> {
let failure = new SecretsKmsError('unavailable')
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
let response: Response
try {
response = await doFetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) })
} catch {
continue
}
if (response.ok) {
return response
}
await response.body?.cancel().catch(() => {})
if (response.status === 401 || response.status === 403) {
throw new SecretsKmsError('denied', response.status)
}
if (response.status < 500 && response.status !== 429) {
throw new SecretsKmsError('invalid', response.status)
}
failure = new SecretsKmsError('unavailable', response.status)
}
throw failure
}
async function accessToken(): Promise<string> {
if (token && token.expiresAt > now() + 60_000) {
return token.value
}
try {
signingKey ??= await importPKCS8(account.private_key, 'RS256')
} catch {
throw new SecretsKmsError('config')
}
const iat = Math.floor(now() / 1000) - 30
const assertion = await new SignJWT({ scope: SCOPE })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(account.client_email)
.setAudience(TOKEN_URL)
.setIssuedAt(iat)
.setExpirationTime(iat + 600)
.sign(signingKey)
let response: Response
try {
response = await send(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString()
})
} catch (error) {
// Google answers a bad or disabled service account with 400 invalid_grant: that is a refusal, not a bad KEK.
throw error instanceof SecretsKmsError && error.reason === 'invalid' ? new SecretsKmsError('denied', error.status) : error
}
const parsed = TokenResponse.safeParse(await response.json().catch(() => null))
if (!parsed.success) {
throw new SecretsKmsError('unavailable')
}
token = { value: parsed.data.access_token, expiresAt: now() + (parsed.data.expires_in ?? 3600) * 1000 }
return token.value
}
return async (wrapped, kekId) => {
const response = await send(`${KMS_URL}/${config.keyName}:decrypt`, {
method: 'POST',
headers: { authorization: `Bearer ${await accessToken()}`, 'content-type': 'application/json' },
body: JSON.stringify({ ciphertext: wrapped, additionalAuthenticatedData: btoa(kekWrapAad(kekId)) })
})
const parsed = DecryptResponse.safeParse(await response.json().catch(() => null))
if (!parsed.success) {
throw new SecretsKmsError('unavailable')
}
return parsed.data.plaintext
}
}
+68 -8
View File
@@ -9,7 +9,8 @@
import { z } from 'zod'
import type { SandboxCaller } from './auth'
import type { Env } from './env'
import { createKeyring, type Keyring, type SecretBinding } from './secrets-crypto'
import { createKeyring, type Keyring, type SealFormat, type SecretBinding } from './secrets-crypto'
import { createKekUnwrapper, SecretsKmsError } from './secrets-kms'
import { AUTH_IAT_BACKDATE_MS } from './member-changes'
import { BASE_URL_NAMES, baseUrlHost } from './provider-env'
import type { SandboxRow } from './store'
@@ -458,21 +459,80 @@ export async function effectiveSecrets(db: D1Database, caller: SandboxCaller, re
})
}
let cachedKeyring: { source: string; keyring: Promise<Keyring> } | null = null
let cachedKeyring: { source: string; keyring: Promise<Keyring>; failedAt?: number } | null = null
/** How long a failed KMS unwrap is remembered, so an outage is not one KMS round trip per request. */
export const KMS_RETRY_AFTER_MS = 10_000
/** The Worker's keyring, or null when SECRETS_KEK is not configured. */
export function keyringFor(env: Env): Promise<Keyring> | null {
function parseKeyMap(json: string | undefined): Record<string, string> {
return json ? (JSON.parse(json) as Record<string, string>) : {}
}
/**
* SECRETS_KEK_SOURCE=kms: every KEK comes out of Cloud KMS (src/secrets-kms.ts), once per isolate.
* SECRETS_KEK is never read on this path, so a KMS failure cannot fall back to a plaintext key.
*/
function kmsKeyring(env: Env, sealFormat: SealFormat, fetcher: typeof fetch | undefined): Promise<Keyring> {
const activeId = env.SECRETS_KEK_ID ?? 'k1'
const source = `kms\n${activeId}\n${env.SECRETS_KMS_KEY ?? ''}\n${env.SECRETS_KEK_WRAPPED ?? ''}\n${env.SECRETS_KEK_RETIRED_WRAPPED ?? ''}\n${sealFormat}`
const cached = cachedKeyring
if (cached?.source === source && (cached.failedAt === undefined || Date.now() - cached.failedAt < KMS_RETRY_AFTER_MS)) {
return cached.keyring
}
const load = async (): Promise<Keyring> => {
if (!env.SECRETS_KMS_KEY || !env.SECRETS_KMS_SERVICE_ACCOUNT || !env.SECRETS_KEK_WRAPPED) {
throw new SecretsKmsError('config')
}
let retiredWrapped: Record<string, string>
try {
retiredWrapped = parseKeyMap(env.SECRETS_KEK_RETIRED_WRAPPED)
} catch {
throw new SecretsKmsError('config')
}
const unwrap = createKekUnwrapper({ keyName: env.SECRETS_KMS_KEY, serviceAccountJson: env.SECRETS_KMS_SERVICE_ACCOUNT, fetch: fetcher })
const active = await unwrap(env.SECRETS_KEK_WRAPPED, activeId)
const retired: Record<string, string> = {}
for (const [id, wrapped] of Object.entries(retiredWrapped)) {
if (typeof wrapped !== 'string') {
throw new SecretsKmsError('config')
}
retired[id] = await unwrap(wrapped, id)
}
try {
return await createKeyring({ id: activeId, key: active }, retired, { sealFormat })
} catch {
// KMS returned something that is not a 32-byte key, or an id is malformed.
throw new SecretsKmsError('invalid')
}
}
const entry: NonNullable<typeof cachedKeyring> = { source, keyring: load() }
entry.keyring.catch((error: unknown) => {
entry.failedAt = Date.now()
const kms = error instanceof SecretsKmsError ? error : null
// Reason and HTTP status only: never a response body, a token or a key.
secretLog('secrets_kms_unavailable', { reason: kms?.reason ?? 'error', ...(kms?.status ? { status: kms.status } : {}) })
})
cachedKeyring = entry
return entry.keyring
}
/**
* The Worker's keyring, or null when the secret store is not configured. With SECRETS_KEK_SOURCE=kms
* the promise rejects with SecretsKmsError while KMS cannot hand over the keys.
*/
export function keyringFor(env: Env, fetcher?: typeof fetch): Promise<Keyring> | null {
// Anything but the exact opt-in stays v1, the format every deployed version reads.
const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1'
if (env.SECRETS_KEK_SOURCE === 'kms') {
return kmsKeyring(env, sealFormat, fetcher)
}
if (!env.SECRETS_KEK) {
return null
}
// Anything but the exact opt-in stays v1, the format every deployed version reads.
const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1'
const source = `${env.SECRETS_KEK_ID ?? 'k1'}\n${env.SECRETS_KEK}\n${env.SECRETS_KEK_RETIRED ?? ''}\n${sealFormat}`
if (cachedKeyring?.source !== source) {
const retired = env.SECRETS_KEK_RETIRED ? (JSON.parse(env.SECRETS_KEK_RETIRED) as Record<string, string>) : {}
cachedKeyring = {
source,
keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, retired, { sealFormat })
keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, parseKeyMap(env.SECRETS_KEK_RETIRED), { sealFormat })
}
}
return cachedKeyring.keyring
@@ -9,6 +9,7 @@ import { deriveProviderEnv, deriveProviderScopes, providerHosts } from './provid
import { applySecretPlaceholders, encodeAccountRoute, encodeEgressSecretHosts } from './secret-egress'
import { resolveSandboxSecrets } from './secrets'
import type { Keyring } from './secrets-crypto'
import { SecretsKmsError } from './secrets-kms'
import type { SandboxRow } from './store'
import { secretLog } from './secret-log'
@@ -80,9 +81,12 @@ function egressSwapOn(deps: Pick<LifecycleDeps, 'secrets' | 'config'>, row: Pick
export type { AgentMode }
export async function sessionSecretEnv(
deps: Pick<LifecycleDeps, 'db' | 'secrets' | 'config'>,
deps: Pick<LifecycleDeps, 'db' | 'secrets' | 'secretsUnavailable' | 'config'>,
row: SandboxRow
): Promise<SessionSecrets> {
if (deps.secretsUnavailable) {
throw new SecretsKmsError(deps.secretsUnavailable)
}
if (!deps.secrets) {
return EMPTY
}
@@ -63,7 +63,7 @@ describe('the git shim never lets a github.com push prompt for credentials', ()
expect(err).not.toContain("Username for 'https://github.com'")
expect(err).not.toContain('could not read Username')
expect(err).toContain(
"orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings › Account."
"orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings → Account."
)
})
@@ -1,5 +1,5 @@
// #503 follow-up (design-squad): a commit that goes out under the workspace identity (Orca Cloud's
// name) says once per session how to use your own: connect GitHub in Settings › Account.
// name) says once per session how to use your own: connect GitHub in Settings → Account.
// Pushes and read-only git never say it; orca-git-as's own fallback line is replaced by it.
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const SHIM = fileURLToPath(new URL('../image/vm/git', import.meta.url).href)
const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours."
const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours."
let home = ''
let cwd = ''
@@ -0,0 +1,321 @@
import { createCipheriv, createDecipheriv, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto'
import { jwtVerify } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { START_COMMAND } from '../src/sandbox-vm-commands'
import { KMS_RETRY_AFTER_MS } from '../src/secrets'
import { createKeyring } from '../src/secrets-crypto'
import { createKekUnwrapper, kekWrapAad, SecretsKmsError } from '../src/secrets-kms'
import { createHarness } from './harness'
// Fake keys and values only; the log check looks for every one of them.
const K1 = Buffer.alloc(32, 7).toString('base64')
const K2 = Buffer.alloc(32, 9).toString('base64')
const PLAIN = Buffer.alloc(32, 5).toString('base64')
const OP = 'o'.repeat(40)
const KEY_NAME = 'projects/onorca-cloud/locations/us/keyRings/orca-secrets/cryptoKeys/secrets-kek'
const VALUE = 'kms-secret-value-aaaa-1111'
const VALUE_2 = 'kms-secret-value-bbbb-2222'
type FakeKms = ReturnType<typeof createFakeKms>
/**
* Google's token endpoint and Cloud KMS, in memory. The service-account assertion is verified against the
* account's public key, and encrypt/decrypt are real AES-256-GCM under a root key that never leaves this object.
*/
function createFakeKms() {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const root = randomBytes(32)
const email = 'orca-secrets@onorca-cloud.iam.gserviceaccount.com'
const state = {
tokens: 0,
decrypts: 0,
issued: [] as string[],
/** `down`: 503 from KMS. `denied`: 403 from KMS. `no-account`: token endpoint refuses. `offline`: fetch throws. */
mode: 'up' as 'up' | 'down' | 'denied' | 'no-account' | 'offline'
}
const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (state.mode === 'offline') {
throw new TypeError('fetch failed')
}
if (url === 'https://oauth2.googleapis.com/token') {
state.tokens++
if (state.mode === 'no-account') {
return Response.json({ error: 'invalid_grant', error_description: 'account disabled' }, { status: 400 })
}
const form = new URLSearchParams(String(init?.body))
expect(form.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer')
const { payload } = await jwtVerify(form.get('assertion') ?? '', publicKey as unknown as KeyObject, {
issuer: email,
audience: 'https://oauth2.googleapis.com/token'
})
expect(payload.scope).toBe('https://www.googleapis.com/auth/cloudkms')
const token = `ya29.fake-${randomBytes(8).toString('hex')}`
state.issued.push(token)
return Response.json({ access_token: token, expires_in: 3600, token_type: 'Bearer' })
}
if (url === `https://cloudkms.googleapis.com/v1/${KEY_NAME}:decrypt`) {
state.decrypts++
if (state.mode === 'down') {
return Response.json({ error: { code: 503, message: 'backend unavailable' } }, { status: 503 })
}
const bearer = new Headers(init?.headers).get('authorization')?.slice('Bearer '.length) ?? ''
if (state.mode === 'denied' || !state.issued.includes(bearer)) {
return Response.json({ error: { code: 403, message: 'Permission cloudkms.cryptoKeyVersions.useToDecrypt denied' } }, { status: 403 })
}
const body = JSON.parse(String(init?.body)) as { ciphertext: string; additionalAuthenticatedData?: string }
try {
const bytes = Buffer.from(body.ciphertext, 'base64')
const decipher = createDecipheriv('aes-256-gcm', root, bytes.subarray(0, 12))
decipher.setAAD(Buffer.from(body.additionalAuthenticatedData ?? '', 'base64'))
decipher.setAuthTag(bytes.subarray(bytes.length - 16))
const plaintext = Buffer.concat([decipher.update(bytes.subarray(12, bytes.length - 16)), decipher.final()])
return Response.json({ plaintext: plaintext.toString('base64') })
} catch {
return Response.json({ error: { code: 400, message: 'Decryption failed: the ciphertext is invalid.' } }, { status: 400 })
}
}
throw new Error(`unexpected fetch ${url}`)
}) as typeof fetch
return {
state,
fetch: fetcher,
serviceAccount: JSON.stringify({
type: 'service_account',
client_email: email,
private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
// Not trusted by the client: the real endpoint is pinned.
token_uri: 'https://attacker.example/token'
}),
/** What `gcloud kms encrypt` does in the runbook: wrap a KEK's 32 bytes, bound to its id. */
wrap(kekB64: string, kekId: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', root, iv)
cipher.setAAD(Buffer.from(kekWrapAad(kekId)))
return Buffer.concat([iv, cipher.update(Buffer.from(kekB64, 'base64')), cipher.final(), cipher.getAuthTag()]).toString('base64')
}
}
}
let logs: string[] = []
let kms: FakeKms
let clock = 1_800_000_000_000
beforeEach(() => {
logs = []
kms = createFakeKms()
// The keyring cache is per isolate (module state); a fresh fake means fresh wrapped blobs, so no test sees another's entry.
vi.stubGlobal('fetch', kms.fetch)
// Past any earlier test's remembered failure.
clock += 10 * KMS_RETRY_AFTER_MS
vi.spyOn(Date, 'now').mockImplementation(() => clock)
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
})
}
})
afterEach(() => {
// No value, key, credential or access token reached a log line.
const privateKey = (JSON.parse(kms.serviceAccount) as { private_key: string }).private_key
for (const secret of [VALUE, VALUE_2, K1, K2, PLAIN, privateKey.split('\n')[1]!, ...kms.state.issued]) {
expect(logs.join('\n')).not.toContain(secret)
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function kmsEnv(extra: Record<string, string> = {}): Record<string, string> {
return {
OPERATOR_TOKEN: OP,
SECRETS_KEK_SOURCE: 'kms',
SECRETS_KMS_KEY: KEY_NAME,
SECRETS_KMS_SERVICE_ACCOUNT: kms.serviceAccount,
SECRETS_KEK_WRAPPED: kms.wrap(K1, 'k1'),
...extra
}
}
const put = (h: Awaited<ReturnType<typeof createHarness>>, bearer: string, name: string, value: string) =>
h.call('PUT', `/v1/secrets/personal/${name}`, { bearer, body: { value } })
describe('KMS client', () => {
it('unwraps a KEK wrapped for its id, with a pinned token endpoint and one token for many calls', async () => {
const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
expect(await unwrap(kms.wrap(K1, 'k1'), 'k1')).toBe(K1)
expect(await unwrap(kms.wrap(K2, 'k2'), 'k2')).toBe(K2)
expect(kms.state).toMatchObject({ tokens: 1, decrypts: 2 })
// A blob wrapped for another id, or a corrupted one, does not unwrap.
await expect(unwrap(kms.wrap(K1, 'k1'), 'k2')).rejects.toMatchObject({ reason: 'invalid', status: 400 })
await expect(unwrap('AAAA', 'k1')).rejects.toMatchObject({ reason: 'invalid' })
})
it('classifies failures and retries only the retryable ones', async () => {
const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
const wrapped = kms.wrap(K1, 'k1')
kms.state.mode = 'down'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ name: 'SecretsKmsError', reason: 'unavailable', status: 503 })
expect(kms.state.decrypts).toBe(2)
kms.state.mode = 'denied'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied', status: 403 })
expect(kms.state.decrypts).toBe(3)
kms.state.mode = 'offline'
await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'unavailable' })
const refused = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch })
kms.state.mode = 'no-account'
await expect(refused(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied' })
// Error text is a fixed code: nothing Google said is carried along.
const error = await refused(wrapped, 'k1').catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(SecretsKmsError)
expect(String(error)).toBe('SecretsKmsError: secrets_kms_denied')
})
it('refuses a malformed key name or credential without echoing it', () => {
for (const keyName of ['', 'projects/x', `${KEY_NAME}/../../other`, `${KEY_NAME}:encrypt`, 'https://evil.example/projects/a/locations/b/keyRings/c/cryptoKeys/d']) {
expect(() => createKekUnwrapper({ keyName, serviceAccountJson: kms.serviceAccount })).toThrow('secrets_kms_config')
}
for (const serviceAccountJson of ['', '{', '{"client_email":"a@b.c"}', 'not-json-PRIVATE']) {
expect(() => createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson })).toThrow('secrets_kms_config')
}
})
})
describe('secret store with SECRETS_KEK_SOURCE=kms', () => {
it('is off unless the flag is exactly `kms`: the plaintext SECRETS_KEK path is untouched and KMS is never called', async () => {
for (const source of [undefined, '', 'KMS', 'env']) {
const h = await createHarness({ ...kmsEnv(), SECRETS_KEK: PLAIN, ...(source === undefined ? {} : { SECRETS_KEK_SOURCE: source }) })
if (source === undefined) {
delete h.env.SECRETS_KEK_SOURCE
}
const alice = await h.token('alice')
expect((await put(h, alice, 'PLAIN_VAR', VALUE)).status).toBe(200)
const row = h.raw.prepare('SELECT kek_id, wrapped_dek, ciphertext FROM secrets').get() as { kek_id: string; wrapped_dek: string; ciphertext: string }
const plain = await createKeyring({ id: 'k1', key: PLAIN })
expect(await plain.open({ scope: 'personal', ownerId: 'user:alice', name: 'PLAIN_VAR' }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext })).toBe(VALUE)
}
expect(kms.state).toMatchObject({ tokens: 0, decrypts: 0 })
})
it('unwraps the KEK once per isolate and seals with it, ignoring any plaintext SECRETS_KEK', async () => {
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN }))
const alice = await h.token('alice')
expect((await put(h, alice, 'FIRST_VAR', VALUE)).status).toBe(200)
expect((await put(h, alice, 'SECOND_VAR', VALUE_2)).status).toBe(200)
expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).body.secrets).toHaveLength(2)
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] })
// A session start decrypts them too.
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined()
// One token and one decrypt for all of it; an outage afterwards does not matter to a warm isolate.
expect(kms.state).toMatchObject({ tokens: 1, decrypts: 1 })
kms.state.mode = 'down'
expect((await put(h, alice, 'THIRD_VAR', VALUE)).status).toBe(200)
expect(kms.state.decrypts).toBe(1)
// The rows are under the KMS-held key, not the plaintext one that was also configured.
const row = h.raw.prepare(`SELECT kek_id, wrapped_dek, ciphertext FROM secrets WHERE name = 'FIRST_VAR'`).get() as { kek_id: string; wrapped_dek: string; ciphertext: string }
const sealed = { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }
const binding = { scope: 'personal', ownerId: 'user:alice', name: 'FIRST_VAR' }
expect(await (await createKeyring({ id: 'k1', key: K1 })).open(binding, sealed)).toBe(VALUE)
await expect((await createKeyring({ id: 'k1', key: PLAIN })).open(binding, sealed)).rejects.toThrow()
})
it.each([
['down', 'unavailable'],
['denied', 'denied'],
['no-account', 'denied'],
['offline', 'unavailable']
] as const)('answers a clear 503 when KMS is %s, without falling back to the plaintext key', async (mode, reason) => {
kms.state.mode = mode
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN }))
const alice = await h.token('alice')
const refused = await put(h, alice, 'ANY_VAR', VALUE)
expect(refused).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect((await h.send(new Request('https://sandbox.test/v1/secrets?scope=personal', { headers: { authorization: `Bearer ${alice}` } }))).headers.get('retry-after')).toBe('30')
expect(await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } })
expect(await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } })
// Nothing was stored under any key.
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 })
// A session does not start without its secrets, and no VM is left behind.
const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })
expect(created).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect(h.vercel.commands.filter((c) => c.command === START_COMMAND)).toHaveLength(0)
// Routes that need no secrets still answer.
expect((await h.call('GET', '/v1/internal/status', { bearer: OP })).status).toBe(200)
expect(logs.some((line) => line.includes('"secrets_kms_unavailable"') && line.includes(`"reason":"${reason}"`))).toBe(true)
})
it('remembers a failure briefly instead of calling KMS per request, then recovers', async () => {
kms.state.mode = 'down'
const h = await createHarness(kmsEnv())
const alice = await h.token('alice')
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503)
const attempts = kms.state.decrypts
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503)
expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).status).toBe(503)
expect(kms.state.decrypts).toBe(attempts)
kms.state.mode = 'up'
clock += KMS_RETRY_AFTER_MS + 1
expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(200)
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
})
it('answers 503 for an incomplete or mislabelled KMS config, never the plaintext key', async () => {
const cases: Record<string, string>[] = [
{ SECRETS_KMS_KEY: '' },
{ SECRETS_KMS_SERVICE_ACCOUNT: '' },
{ SECRETS_KEK_WRAPPED: '' },
{ SECRETS_KMS_KEY: 'projects/onorca-cloud' },
{ SECRETS_KEK_RETIRED_WRAPPED: '{' },
// The active blob was wrapped for k1 but is deployed as k2.
{ SECRETS_KEK_ID: 'k2' },
// KMS hands back something that is not a 32-byte key.
{ SECRETS_KEK_WRAPPED: kms.wrap(Buffer.alloc(16, 1).toString('base64'), 'k1') }
]
for (const broken of cases) {
const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN, ...broken }))
expect(await put(h, await h.token('alice'), 'ANY_VAR', VALUE)).toEqual({ status: 503, body: { error: 'secrets_unavailable' } })
expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 })
}
})
it('rotates under KMS: new wrapped KEK active, old one retired, sweep, then the retired blob is dropped', async () => {
const h = await createHarness(kmsEnv())
const alice = await h.token('alice')
await put(h, alice, 'FIRST_VAR', VALUE)
await put(h, alice, 'SECOND_VAR', VALUE_2)
Object.assign(h.env, {
SECRETS_KEK_WRAPPED: kms.wrap(K2, 'k2'),
SECRETS_KEK_ID: 'k2',
SECRETS_KEK_RETIRED_WRAPPED: JSON.stringify({ k1: h.env.SECRETS_KEK_WRAPPED })
})
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k2', retiredKekIds: ['k1'], pending: 2, retiredInUse: ['k1'], unknownKekIds: [] })
expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: { dryRun: true } })).body).toMatchObject({ scanned: 2, failed: 0 })
expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).body).toMatchObject({ resealed: 2, pending: 0, done: true })
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ pending: 0, retiredDroppable: ['k1'] })
delete h.env.SECRETS_KEK_RETIRED_WRAPPED
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ retiredKekIds: [], pending: 0, unknownKekIds: [] })
const onlyNew = await createKeyring({ id: 'k2', key: K2 })
const rows = h.raw.prepare('SELECT name, kek_id, wrapped_dek, ciphertext FROM secrets ORDER BY name').all() as { name: string; kek_id: string; wrapped_dek: string; ciphertext: string }[]
const opened = await Promise.all(
rows.map((row) => onlyNew.open({ scope: 'personal', ownerId: 'user:alice', name: row.name }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext }))
)
expect(opened).toEqual([VALUE, VALUE_2])
})
it('moves an existing plaintext-KEK store behind KMS without touching a row', async () => {
const h = await createHarness({ OPERATOR_TOKEN: OP, SECRETS_KEK: K1 })
const alice = await h.token('alice')
await put(h, alice, 'FIRST_VAR', VALUE)
const before = h.raw.prepare('SELECT * FROM secrets').all()
// Same KEK, same id, now held by KMS; the Worker secret is removed.
Object.assign(h.env, kmsEnv())
delete h.env.SECRETS_KEK
expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] })
expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200)
expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined()
expect(h.raw.prepare('SELECT * FROM secrets').all()).toEqual(before)
})
})
+2 -1
View File
@@ -1,6 +1,7 @@
# SEC-3: moving the secrets-store KEK behind a KMS (design, not implemented)
Status: design and recommendation, 2026-11-12. Nothing here has been built or provisioned. No prod, no Terraform.
Status: phase 1 Worker code is implemented behind `SECRETS_KEK_SOURCE=kms` (off by default; `src/secrets-kms.ts`, tested against a fake KMS only, never against Google). No KMS resources exist: provisioning and turn-on are in the launch runbook, for Neil. Phase 2 is design only.
Differences from the plan below: the wrapped KEK is bound to its KEK id with KMS additional authenticated data; there is no cache window or stale-on-error (keys stay in memory for the isolate's life, so revocation needs a redeploy); a failed unwrap is retried at most every 10 seconds; with the flag on, `SECRETS_KEK` is never read (no fallback), and create/wake answer 503 `secrets_unavailable`.
Prices, latencies and vendor feature claims are from memory of public docs and are **unmeasured**; check each before committing.
## Where we are after the SEC-3 PR
+78 -10
View File
@@ -147,19 +147,87 @@ describe('teammate names', () => {
expect(keys.map((key) => key.createdByName)).toEqual(['Ada Lovelace', null, undefined])
})
it('reads audit actor names', async () => {
const entry = {
id: 'a1',
actor: { type: 'user', id: 'u_ada', userId: 'u_ada', userName: 'Ada Lovelace' },
action: 'api_key.create',
target: null,
createdAt: 1
it('reads the team audit log: filters go in the query, unknown kinds and categories still parse', async () => {
const event = {
id: 'aud_1',
at: 5,
category: 'something_new',
action: 'secret.updated',
actor: { kind: 'robot', userId: 'u_ada', name: 'Ada Lovelace', via: 'carrier_pigeon' },
target: { type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' },
detail: { scope: 'org' }
}
const fetchImpl = vi.fn<typeof fetch>(async () =>
respond(200, { entries: [entry], nextBefore: null })
respond(200, {
events: [event],
nextCursor: '5.aud_1',
actors: [event.actor],
categories: ['secrets']
})
)
const page = await client(fetchImpl).listAudit()
expect(page.entries[0]?.actor.userName).toBe('Ada Lovelace')
const page = await client(fetchImpl).listAuditLog({
category: 'secrets',
actor: 'u_ada',
limit: 50
})
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&actor=u_ada&limit=50'
)
expect(page.nextCursor).toBe('5.aud_1')
expect(page.events[0]).toMatchObject({
category: 'other',
actor: { kind: 'person', name: 'Ada Lovelace', via: null },
target: { label: 'STRIPE_KEY' }
})
})
it('tells a plan without the audit log apart from a missing role and an older control plane', async () => {
const answer = (status: number, body: unknown) =>
codeOf(client(vi.fn<typeof fetch>(async () => respond(status, body))).listAuditLog())
expect(await answer(403, { error: 'entitlement_denied', reason: 'audit_log' })).toBe(
'plan-required'
)
expect(await answer(403, { error: 'forbidden', reason: 'admin_required' })).toBe(
'admin-required'
)
expect(await answer(403, { error: 'insufficient_scope', reason: 'audit:read' })).toBe(
'forbidden'
)
expect(await answer(404, { error: 'not_found' })).toBe('not-available')
expect(await answer(503, { error: 'billing_unavailable' })).toBe('server')
const refused = await cloudApiErrorOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).listAuditLog()
)
expect(refused.detail).toBe('audit_log')
})
it('exports the audit log as CSV text and says when the export was cut short', async () => {
const csv = 'time,category,action\r\n2026-11-12T00:00:00.000Z,secrets,secret.updated\r\n'
const fetchImpl = vi.fn<typeof fetch>(
async () =>
new Response(csv, {
status: 200,
headers: { 'content-type': 'text/csv', 'x-orca-audit-truncated': 'true' }
})
)
const exported = await client(fetchImpl).exportAuditLogCsv({ category: 'secrets' })
expect(String(fetchImpl.mock.calls[0]?.[0])).toBe(
'https://cp.example.dev/v1/audit-log?category=secrets&format=csv'
)
expect(exported).toEqual({ csv, truncated: true })
expect(
await codeOf(
client(
vi.fn<typeof fetch>(async () =>
respond(403, { error: 'entitlement_denied', reason: 'audit_log' })
)
).exportAuditLogCsv()
)
).toBe('plan-required')
})
})
+41 -7
View File
@@ -4,7 +4,7 @@
import { z } from 'zod'
import {
CloudApiApprovalSchema,
CloudApiAuditPageSchema,
CloudAuditLogPageSchema,
CloudApiCreatedKeySchema,
CloudApiGrantListSchema,
CloudApiInstallationListSchema,
@@ -20,6 +20,8 @@ import {
RoutineUpdatedSchema,
RoutineWebhookSchema,
type CloudApiErrorCode,
type CloudAuditLogExport,
type CloudAuditLogQuery,
type CreateCloudApiApprovalInput,
type CreateCloudApiKeyInput,
type RoutineInput,
@@ -29,6 +31,8 @@ import {
const REQUEST_TIMEOUT_MS = 20_000
const MAX_DETAIL_CHARS = 200
// 5000 events is about 1.5 MB; anything far past that is not the export this client asked for.
const MAX_EXPORT_CHARS = 16 * 1024 * 1024
export class CloudApiError extends Error {
constructor(
@@ -80,6 +84,9 @@ export function classifyCloudApiResponse(
return { code: 'signed-out' }
}
if (status === 403) {
if (error === 'entitlement_denied') {
return { code: 'plan-required', ...(reason ? { detail: reason } : {}) }
}
if (reason === 'admin_required' || reason?.startsWith('admin_scope:')) {
return { code: 'admin-required' }
}
@@ -104,12 +111,12 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
const fetchImpl = deps.fetchImpl ?? fetch
const base = `${deps.controlPlaneUrl.replace(/\/+$/, '')}/`
async function request<S extends z.ZodType>(
/** The authenticated call: a 2xx response, or a CloudApiError worded from the answer. */
async function send(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
schema: S,
options: RequestOptions = {}
): Promise<z.infer<S>> {
): Promise<Response> {
let token: string
try {
token = await deps.getAccessToken()
@@ -138,8 +145,8 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
} catch {
throw new CloudApiError('network')
}
const body: unknown = await response.json().catch(() => null)
if (!response.ok) {
const body: unknown = await response.json().catch(() => null)
const { code, detail } = classifyCloudApiResponse(response.status, body, options)
const retryAfter = Number(response.headers.get('retry-after'))
throw new CloudApiError(
@@ -150,6 +157,17 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
: undefined
)
}
return response
}
async function request<S extends z.ZodType>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
schema: S,
options: RequestOptions = {}
): Promise<z.infer<S>> {
const response = await send(method, path, options)
const body: unknown = await response.json().catch(() => null)
const parsed = schema.safeParse(body)
if (!parsed.success) {
throw new CloudApiError('invalid-response')
@@ -183,8 +201,24 @@ export function createCloudApiClient(deps: CloudApiClientDeps) {
getSettings: () => request('GET', 'v1/settings', CloudApiSettingsSchema),
putSettings: (settings: { vmCliEnabled: boolean }) =>
request('PUT', 'v1/settings', CloudApiSettingsSchema, { body: settings }),
listAudit: (query: { limit?: number; before?: number } = {}) =>
request('GET', 'v1/audit', CloudApiAuditPageSchema, { query }),
listAuditLog: (query: CloudAuditLogQuery = {}) =>
request('GET', 'v1/audit-log', CloudAuditLogPageSchema, {
query,
notFoundMeansUnavailable: true
}),
exportAuditLogCsv: async (
query: Pick<CloudAuditLogQuery, 'category' | 'actor'> = {}
): Promise<CloudAuditLogExport> => {
const response = await send('GET', 'v1/audit-log', {
query: { ...query, format: 'csv' },
notFoundMeansUnavailable: true
})
const csv = await response.text().catch(() => null)
if (csv === null || csv.length > MAX_EXPORT_CHARS) {
throw new CloudApiError('invalid-response')
}
return { csv, truncated: response.headers.get('x-orca-audit-truncated') === 'true' }
},
listProjects: () => request('GET', 'v1/projects', CloudApiProjectListSchema),
listInstallations: () =>
request('GET', 'v1/github/installations', CloudApiInstallationListSchema, {
+8 -7
View File
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import { z } from 'zod'
import {
CloudAuditLogQuerySchema,
CreateCloudApiApprovalInputSchema,
CreateCloudApiKeyInputSchema,
RoutineInputSchema,
@@ -16,12 +17,7 @@ import {
} from './cloud-api-service'
const Id = z.string().min(1).max(128)
const AuditQuery = z
.object({
limit: z.number().int().min(1).max(200).optional(),
before: z.number().int().nonnegative().optional()
})
.strict()
const AuditExportQuery = CloudAuditLogQuerySchema.pick({ category: true, actor: true })
const SettingsInput = z.object({ vmCliEnabled: z.boolean() }).strict()
type Handler = (...args: unknown[]) => Promise<CloudApiResult<unknown>>
@@ -59,7 +55,12 @@ const HANDLERS: Record<string, Handler> = {
'cloudApi:revokeGrant': call([Id], (c, id) => c.revokeGrant(id)),
'cloudApi:getSettings': call([], (c) => c.getSettings()),
'cloudApi:putSettings': call([SettingsInput], (c, input) => c.putSettings(input)),
'cloudApi:listAudit': call([AuditQuery.optional()], (c, q) => c.listAudit(q ?? {})),
'cloudApi:listAuditLog': call([CloudAuditLogQuerySchema.optional()], (c, q) =>
c.listAuditLog(q ?? {})
),
'cloudApi:exportAuditLogCsv': call([AuditExportQuery.optional()], (c, q) =>
c.exportAuditLogCsv(q ?? {})
),
'cloudApi:listProjects': call([], (c) => c.listProjects()),
'cloudApi:listInstallations': call([], (c) => c.listInstallations()),
'cloudApi:listRoutines': call([], (c) => c.listRoutines()),
+11 -7
View File
@@ -1,7 +1,6 @@
import { ipcRenderer } from 'electron'
import type {
CloudApiApproval,
CloudApiAuditPage,
CloudApiAvailability,
CloudApiCreatedKey,
CloudApiGrant,
@@ -11,6 +10,9 @@ import type {
CloudApiProject,
CloudApiResult,
CloudApiSettings,
CloudAuditLogExport,
CloudAuditLogPage,
CloudAuditLogQuery,
CreateCloudApiApprovalInput,
CreateCloudApiKeyInput,
Routine,
@@ -23,7 +25,7 @@ import type {
RoutineUpdated
} from '../../shared/cloud-api/cloud-api-types'
/** Desktop only: Orca Cloud API keys, org settings, audit and routines. The session token stays in main. */
/** Desktop only: Orca Cloud API keys, org settings, the team audit log and routines. The session token stays in main. */
export type CloudApiBridge = {
availability: () => Promise<CloudApiAvailability>
/** The desktop workspace running on this Orca Cloud sandbox, if this computer has it. */
@@ -37,10 +39,11 @@ export type CloudApiBridge = {
revokeGrant: (id: string) => Promise<CloudApiResult<unknown>>
getSettings: () => Promise<CloudApiResult<CloudApiSettings>>
putSettings: (input: { vmCliEnabled: boolean }) => Promise<CloudApiResult<CloudApiSettings>>
listAudit: (query?: {
limit?: number
before?: number
}) => Promise<CloudApiResult<CloudApiAuditPage>>
/** The team audit log: owners and admins of a team whose plan includes it (else `plan-required`). */
listAuditLog: (query?: CloudAuditLogQuery) => Promise<CloudApiResult<CloudAuditLogPage>>
exportAuditLogCsv: (
query?: Pick<CloudAuditLogQuery, 'category' | 'actor'>
) => Promise<CloudApiResult<CloudAuditLogExport>>
listProjects: () => Promise<CloudApiResult<{ projects: CloudApiProject[] }>>
listInstallations: () => Promise<CloudApiResult<{ installations: CloudApiInstallation[] }>>
listRoutines: () => Promise<CloudApiResult<{ routines: Routine[] }>>
@@ -69,7 +72,8 @@ export const cloudApiBridge: CloudApiBridge = {
revokeGrant: invoke('cloudApi:revokeGrant'),
getSettings: invoke('cloudApi:getSettings'),
putSettings: invoke('cloudApi:putSettings'),
listAudit: invoke('cloudApi:listAudit'),
listAuditLog: invoke('cloudApi:listAuditLog'),
exportAuditLogCsv: invoke('cloudApi:exportAuditLogCsv'),
listProjects: invoke('cloudApi:listProjects'),
listInstallations: invoke('cloudApi:listInstallations'),
listRoutines: invoke('cloudApi:listRoutines'),
@@ -280,7 +280,7 @@ export function PlanBillingView({
<div className="flex flex-wrap gap-2">
{hasPlan || onTrial ? null : (
<Button type="button" size="sm" onClick={onSeePlans}>
{t('auto.components.billing.upgrade.cta', 'See plans')}
{t('auto.components.billing.comparePlans', 'Compare plans')}
</Button>
)}
{hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? (
@@ -231,7 +231,6 @@ export function TeamPageBody({
openTeammateProfile(userId)
}}
onDismissArrival={clearTeamPageArrival}
onContactSales={(url) => void window.api.shell.openUrl(url)}
inline={inline}
dangerZone={
org && teamDelete.check ? (
@@ -89,8 +89,7 @@ const base: TeamPageViewProps = {
onSeePlans: noop,
onManageBilling: noop,
onCreateWorkspace: noop,
onDismissArrival: noop,
onContactSales: noop
onDismissArrival: noop
}
function Page(props: Partial<TeamPageViewProps>): React.JSX.Element {
@@ -1,7 +1,6 @@
import { useEffect, useId, useRef } from 'react'
import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { CreditCard, RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
import { useTranslate } from '@/i18n/use-translate'
@@ -52,8 +51,6 @@ export type TeamPageViewProps = {
onManageBilling: () => void
onCreateWorkspace: () => void
onDismissArrival: () => void
/** Opens the Enterprise page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */
onContactSales: (url: string) => void
/** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */
inline?: { onOpenPlanBilling: () => void }
/** Owners in Settings › Team: "Delete <team>", after "Leave <team>" (TEAM-DELETE). */
@@ -83,7 +80,6 @@ function SkeletonRows(): React.JSX.Element {
export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
const t = useTranslate()
const inviteInput = useRef<HTMLInputElement>(null)
const ssoTitleId = useId()
const hasRoster = props.roster !== null
const inline = props.inline ?? null
useEffect(() => {
@@ -109,7 +105,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
const roster = props.roster?.status === 'ok' ? props.roster.roster : null
const entitlement = props.billing?.status === 'ok' ? props.billing.entitlement : null
const enterprise = entitlement?.offers?.enterprise ?? null
const rows = roster ? teamRows(roster, props.viewerUserId) : []
const selfRow = rows.find((row) => row.self)
const viewerRole = roster?.viewerRole ?? 'member'
@@ -298,24 +293,8 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
) : null}
</section>
{(roster?.canManageMembers && enterprise) || props.dangerZone ? (
{props.dangerZone ? (
<div className="divide-y divide-border/60 rounded-xl border border-border">
{roster?.canManageMembers && enterprise ? (
<section aria-labelledby={ssoTitleId} className="flex flex-wrap items-center gap-3 p-4">
<h3 id={ssoTitleId} className="min-w-[14rem] flex-1 text-sm font-medium">
{t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')}
</h3>
<OpenExternalSplitButton
variant="outline"
size="sm"
link={enterprise.contactUrl}
onClick={() => props.onContactSales(enterprise.contactUrl)}
>
{t('auto.components.billing.enterpriseContactSales', 'Contact sales')}
<ExternalLink />
</OpenExternalSplitButton>
</section>
) : null}
{props.dangerZone}
</div>
) : null}
@@ -233,7 +233,7 @@ export function TeamUpgradeCard({
</div>
{entitlement.canManageBilling ? (
<Button type="button" onClick={onSeePlans}>
{t('auto.components.billing.upgrade.cta', 'See plans')}
{t('auto.components.billing.comparePlans', 'Compare plans')}
<ArrowRight />
</Button>
) : null}
@@ -124,6 +124,10 @@ describe('the paired cloud workspace surface', () => {
const { container } = render(<OrcaCloudWorkspaceSurface worktreeId={ID} />)
await flush()
expect(container.querySelector('[data-cloud-cover="waking"]')).not.toBeNull()
// PAIRED-WAKE-TYPE-THROUGH: while it wakes the pane underneath stays typeable, like owned.
expect(container.querySelector('[data-cloud-cover="waking"]')?.className).toContain(
'pointer-events-none'
)
act(() =>
seedPaired('sleeping', {
wake: {
@@ -143,6 +147,10 @@ describe('the paired cloud workspace surface', () => {
/Try again/.test(b.textContent ?? '')
)
expect(retry).toBeDefined()
// A failed wake covers the pane again: nothing typed there would reach a terminal.
expect(container.querySelector('[data-cloud-cover="waking"]')?.className).not.toContain(
'pointer-events-none'
)
fireEvent.click(retry!)
expect(wakePaired).toHaveBeenCalledWith(ID)
})
@@ -27,6 +27,8 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
import { coveredPaneHadFocus, useInertSiblings } from './cloud-cover-focus'
import { CloudWakingOverlay } from './CloudWakingOverlay'
import { useWakeOverlay } from './use-wake-overlay'
import { useWakeInput, useWakeInputNotices } from './use-wake-input'
import { clearWakeInput } from '@/components/terminal-pane/input/wake-input-buffer'
import { useConfirmedRestart } from './use-cloud-panel-actions'
import { useCloudWorkspaceView } from './use-cloud-workspace-view'
import { useSleepCopyProgress } from './offline/CloudSleepCopyState'
@@ -114,10 +116,15 @@ export function PairedCloudWorkspaceSurface({
const view = useCloudWorkspaceView(workspaceId, { pairedRows: true })
const name = useAppStore((state) => findWorktreeById(state, workspaceId)?.displayName ?? null)
const now = useNow(1_000, view.wake !== undefined)
// PAIRED-WAKE-TYPE-THROUGH: typing is held while it wakes and delivered to the woken terminal,
// exactly as for an owned workspace (the store begins and ends the hold on wake progress).
const wakeInput = useWakeInput(workspaceId)
useWakeInputNotices(workspaceId)
const overlay = useWakeOverlay(
view.wake,
Math.max(now, view.wake?.at ?? 0),
t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.')
t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.'),
wakeInput.pending > 0
)
const copySaveEntry = useSleepCopySave(workspaceId)
const wakeInFlight = Boolean(
@@ -141,10 +148,12 @@ export function PairedCloudWorkspaceSurface({
const slowNow = useNow(30_000, layer === 'asleep')
const restart = useConfirmedRestart(workspaceId)
// While it wakes the pane stays typeable under the overlay (a failed wake covers it again).
const typeThrough = layer === 'waking' && overlay.wake?.phase !== 'failed'
const coverRef = useRef<HTMLDivElement | null>(null)
const headingRef = useRef<HTMLHeadingElement | null>(null)
const [takeFocus, setTakeFocus] = useState(false)
useInertSiblings(coverRef, layer !== null && !overlay.leaving)
useInertSiblings(coverRef, layer !== null && !typeThrough && !overlay.leaving)
useLayoutEffect(() => {
setTakeFocus(layer !== null && coveredPaneHadFocus(coverRef.current))
}, [layer])
@@ -175,7 +184,13 @@ export function PairedCloudWorkspaceSurface({
{layer ? (
<div
ref={coverRef}
className={layer === 'asleep' ? 'absolute inset-0 z-20' : 'absolute inset-0 z-30'}
className={
typeThrough
? 'pointer-events-none absolute inset-0 z-30'
: layer === 'asleep'
? 'absolute inset-0 z-20'
: 'absolute inset-0 z-30'
}
data-cloud-cover={layer}
data-cloud-paired=""
>
@@ -185,6 +200,9 @@ export function PairedCloudWorkspaceSurface({
workspaceName={name}
leaving={overlay.leaving}
autoFocus={takeFocus}
typeThrough={typeThrough}
pendingInput={wakeInput.pending}
onClearPending={() => clearWakeInput(workspaceId)}
onRetry={() => void wakePairedCloudWorkspace(workspaceId)}
onRestart={restart}
onDismiss={() => dismissCloudWake(workspaceId)}
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
// The Orca API keys pane for a team WITHOUT the Enterprise plan. Key activity lives only in the
// Enterprise audit log now, so this pane must never call an audit route (the control plane
// refuses those without the entitlement) and must never show an error because of one.
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type {
CloudApiKeyList,
CloudApiMe,
CloudApiResult
} from '../../../../../shared/cloud-api/cloud-api-types'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CloudApiKeysPane } from './CloudApiKeysPane'
beforeEach(() => {
vi.useFakeTimers({ now: NOW, toFake: ['Date'] })
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// A fixed clock, so "5 minutes ago" never depends on when the test runs.
const NOW = Date.UTC(2026, 10, 12, 17, 30)
const ME: CloudApiMe = {
accountId: 'org:acme',
userId: 'u_ada',
orgId: 'acme',
role: 'owner',
scopes: ['workspaces:read', 'workspaces:write', 'audit:read']
}
const KEYS: CloudApiKeyList = {
availableScopes: ['workspaces:read', 'workspaces:write', 'audit:read'],
keys: [
{
id: 'key_ci',
name: 'GitHub Actions deploy',
prefix: 'orca_sk_Ab3x',
scopes: ['workspaces:read'],
createdBy: 'u_ada',
createdByName: 'Ada Lovelace',
createdAt: NOW - 3 * 86_400_000,
lastUsedAt: NOW - 5 * 60_000,
expiresAt: null
}
]
}
const ok = <T,>(data: T): Promise<CloudApiResult<T>> => Promise.resolve({ ok: true, data })
/**
* The bridge as a non-Enterprise team's control plane answers it: everything the pane needs
* works, and ANY audit read is refused for the plan. Every call is recorded by name.
*/
function nonEnterpriseBridge(): { api: Record<string, unknown>; calls: string[] } {
const calls: string[] = []
const known: Record<string, () => unknown> = {
availability: () =>
Promise.resolve({ status: 'ready', controlPlaneUrl: 'https://api.orca.dev' }),
me: () => ok(ME),
listApiKeys: () => ok(KEYS),
getSettings: () => ok({ settings: { vmCliEnabled: true }, canEdit: true }),
listGrants: () => ok({ grants: [] })
}
const api = new Proxy(
{},
{
get: (_target, name) => {
if (typeof name !== 'string') {
return undefined
}
return (...args: unknown[]) => {
calls.push(name)
void args
return (
known[name]?.() ??
Promise.resolve({ ok: false, error: 'plan-required', detail: 'audit_log' })
)
}
}
}
)
return { api, calls }
}
it('lists the keys with their last use, makes no audit call and shows no error for a non-Enterprise team', async () => {
const { api, calls } = nonEnterpriseBridge()
// The pane reads window.api (happy-dom's window is the test global).
vi.stubGlobal('api', { cloudApi: api, shell: { openUrl: vi.fn() } })
const { container } = render(
<TooltipProvider>
<CloudApiKeysPane />
</TooltipProvider>
)
await waitFor(() => expect(screen.getByText('GitHub Actions deploy')).toBeTruthy())
// The keys list and each key's "last used" stay.
expect(container.textContent).toMatch(/5 minutes ago/)
// No activity list, no way to ask for one, and nothing that reads as a failure.
expect(container.textContent).not.toMatch(/Recent activity|Show activity|Audit log/)
expect(container.querySelector('[role="alert"]')).toBeNull()
expect(container.textContent).not.toMatch(/doesn’t include|permission|Try again|problem/i)
// Not one audit read, gated or otherwise.
expect(calls.filter((name) => /audit/i.test(name))).toEqual([])
expect([...new Set(calls)].sort()).toEqual([
'availability',
'getSettings',
'listApiKeys',
'listGrants',
'me'
])
})
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import type {
CloudApiAuditPage,
CloudApiGrant,
CloudApiKeyList,
CloudApiMe,
@@ -29,7 +28,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null {
const [keys, setKeys] = useState<CloudLoadState<CloudApiKeyList>>({ status: 'loading' })
const [settings, setSettings] = useState<CloudLoadState<CloudApiSettings>>({ status: 'loading' })
const [grants, setGrants] = useState<CloudLoadState<CloudApiGrant[]>>({ status: 'loading' })
const [audit, setAudit] = useState<CloudLoadState<CloudApiAuditPage>>({ status: 'loading' })
const [now, setNow] = useState(() => Date.now())
const begin = useRequestGeneration()
const [refreshing, setRefreshing] = useState(false)
@@ -85,19 +83,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null {
setNow(Date.now())
}, [api, begin, word])
const loadAudit = useCallback(async (): Promise<void> => {
if (!api) {
return
}
const isCurrent = begin('audit')
setAudit({ status: 'loading' })
const result = await api.listAudit({ limit: 25 })
if (isCurrent()) {
setAudit(toCloudLoadState(result, word))
setNow(Date.now())
}
}, [api, begin, word])
useEffect(() => {
if (availability?.status === 'ready') {
void load()
@@ -116,9 +101,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null {
viewer={viewer}
keys={keys}
settings={settings}
audit={
viewer && viewer.role !== 'member' && viewer.scopes.includes('audit:read') ? audit : null
}
now={now}
onRefresh={() => void load()}
refreshing={refreshing}
@@ -158,7 +140,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null {
setSettings({ status: 'ok', data: result.data })
return null
}}
onLoadAudit={() => void loadAudit()}
onCreateApproval={(input) => api.createApproval(input)}
grants={grants}
onRefreshGrants={() => {
@@ -1,20 +1,13 @@
import { useState } from 'react'
import { ExternalLink, History, ShieldCheck } from 'lucide-react'
import { ExternalLink, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import type {
CloudApiAuditEntry,
CloudApiAuditPage,
CloudApiMe,
CloudApiSettings
} from '../../../../../shared/cloud-api/cloud-api-types'
import { useTranslate } from '@/i18n/use-translate'
import type { CloudApiSettings } from '../../../../../shared/cloud-api/cloud-api-types'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { PersonSentence } from './PersonName'
import { formatAbsoluteTime, formatRelativeFromNow } from './cloud-api-format'
import { CloudApiErrorNotice, CloudApiSkeletonRows, CopyValueButton } from './cloud-api-parts'
import { CopyValueButton } from './cloud-api-parts'
import type { CloudLoadState } from './cloud-load-state'
function LinkRow({
@@ -184,182 +177,3 @@ export function OrgSettings({
</SettingsGroup>
)
}
const AUDIT_ACTIONS: Record<string, [key: string, fallback: string]> = {
'api_key.create': ['auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'],
'api_key.revoke': ['auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'],
'settings.update': [
'auto.components.settings.cloudApi.audit.action.settings',
'Changed agent access'
],
'workspace.create': [
'auto.components.settings.cloudApi.audit.action.wsCreate',
'Started a workspace'
],
'workspace.destroy': [
'auto.components.settings.cloudApi.audit.action.wsDestroy',
'Deleted a workspace'
],
'workspace.sleep': [
'auto.components.settings.cloudApi.audit.action.wsSleep',
'Put a workspace to sleep'
],
'workspace.wake': ['auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'],
'workspace.archive': [
'auto.components.settings.cloudApi.audit.action.wsArchive',
'Archived a workspace'
],
'workspace.message': [
'auto.components.settings.cloudApi.audit.action.wsMessage',
'Messaged an agent'
],
'project.create': [
'auto.components.settings.cloudApi.audit.action.projectCreate',
'Created a project'
],
'project.update': [
'auto.components.settings.cloudApi.audit.action.projectUpdate',
'Edited a project'
],
'project.delete': [
'auto.components.settings.cloudApi.audit.action.projectDelete',
'Deleted a project'
],
'mcp.approval_create': [
'auto.components.settings.cloudApi.audit.action.approval',
'Made an app approval code'
]
}
/** "Created API key “CI deploy”"; unknown actions fall back to their machine name. */
function auditActionLabel(entry: CloudApiAuditEntry, t: Translate): string {
const known = AUDIT_ACTIONS[entry.action]
const base = known ? t(known[0], known[1]) : entry.action
const name = entry.detail?.name
return typeof name === 'string' && name.length > 0
? t('auto.components.settings.cloudApi.audit.actionNamed', '{{action}} “{{name}}”', {
action: base,
name: name.slice(0, 80)
})
: base
}
function ActorLabel({
entry,
viewer
}: {
entry: CloudApiAuditEntry
viewer: CloudApiMe | null
}): React.JSX.Element {
const t = useTranslate()
if (entry.actor.type === 'workspace_token') {
return (
<>{t('auto.components.settings.cloudApi.audit.viaAgent', 'Agent in a cloud workspace')}</>
)
}
const mine = Boolean(entry.actor.userId) && entry.actor.userId === viewer?.userId
const personId = entry.actor.userId ?? entry.actor.id
const sentence =
entry.actor.type === 'api_key'
? mine
? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key')
: t('auto.components.settings.cloudApi.audit.personViaKey', '<person></person> via API key')
: entry.actor.type === 'mcp'
? mine
? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP')
: t('auto.components.settings.cloudApi.audit.personViaMcp', '<person></person> via MCP')
: mine
? t('auto.components.settings.cloudApi.audit.you', 'You')
: '<person></person>'
return mine ? (
<>{sentence}</>
) : (
<PersonSentence sentence={sentence} name={entry.actor.userName} id={personId} idInTooltip />
)
}
export function AuditLog({
audit,
now,
viewer,
initiallyOpen,
onLoad
}: {
audit: CloudLoadState<CloudApiAuditPage>
now: number
viewer: CloudApiMe | null
initiallyOpen: boolean
onLoad: () => void
}): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const [open, setOpen] = useState(initiallyOpen)
return (
<SettingsGroup
title={t('auto.components.settings.cloudApi.audit.title', 'Recent activity')}
description={t(
'auto.components.settings.cloudApi.audit.description',
'Key, workspace and settings changes across your team. Visible to admins only.'
)}
action={
<Button
type="button"
variant="outline"
size="sm"
aria-expanded={open}
aria-controls="cloud-api-activity"
onClick={() => {
if (!open) {
onLoad()
}
setOpen(!open)
}}
>
<History />
{open
? t('auto.components.settings.cloudApi.audit.hide', 'Hide')
: t('auto.components.settings.cloudApi.audit.show', 'Show activity')}
</Button>
}
bodyClassName={open ? undefined : 'hidden'}
>
{!open ? null : audit.status === 'loading' ? (
<CloudApiSkeletonRows
rows={2}
label={t('auto.components.settings.cloudApi.audit.loading', 'Loading activity')}
/>
) : audit.status === 'error' ? (
<div data-settings-row="" className="py-4">
<CloudApiErrorNotice message={audit.message} onRetry={onLoad} />
</div>
) : audit.data.entries.length === 0 ? (
<p data-settings-row="" className="py-4 text-xs text-muted-foreground">
{t('auto.components.settings.cloudApi.audit.empty', 'Nothing has happened yet.')}
</p>
) : (
<ul id="cloud-api-activity">
{audit.data.entries.slice(0, 25).map((entry) => (
<li
key={entry.id}
data-settings-row=""
className="flex items-center gap-3 py-2 text-xs"
>
<span className="min-w-0 flex-1 truncate text-foreground" title={entry.action}>
{auditActionLabel(entry, t)}
</span>
<span className="w-44 shrink-0 truncate text-muted-foreground">
<ActorLabel entry={entry} viewer={viewer} />
</span>
<span
className="shrink-0 text-muted-foreground"
title={formatAbsoluteTime(entry.createdAt, locale)}
>
{formatRelativeFromNow(entry.createdAt, now, locale)}
</span>
</li>
))}
</ul>
)}
</SettingsGroup>
)
}
@@ -1,7 +1,6 @@
import { defineFixture } from '../../../component-fixtures/component-fixture'
import type {
CloudApiGrant,
CloudApiAuditPage,
CloudApiKeyList,
CloudApiMe,
CloudApiSettings
@@ -65,34 +64,6 @@ const KEYS: CloudApiKeyList = {
}
const SETTINGS: CloudApiSettings = { settings: { vmCliEnabled: true }, canEdit: true }
const AUDIT: CloudApiAuditPage = {
nextBefore: null,
entries: [
{
id: 'a1',
actor: { type: 'user', id: 'u_7f3k', userId: 'u_7f3k', userName: null },
action: 'api_key.create',
target: { type: 'api_key', id: 'key_ci' },
detail: { name: 'GitHub Actions deploy' },
createdAt: NOW - 3 * DAY
},
{
id: 'a2',
actor: { type: 'api_key', id: 'key_dash', userId: 'u_grace', userName: 'Grace Hopper' },
action: 'workspace.create',
target: { type: 'workspace', id: 'sbx_7Hq2' },
createdAt: NOW - 5 * MIN
},
{
id: 'a3',
actor: { type: 'workspace_token', id: 'key_ws', userId: 'u_ada' },
action: 'workspace.message',
target: { type: 'workspace', id: 'sbx_7Hq2' },
createdAt: NOW - 2 * MIN
}
]
}
const GRANTS: CloudApiGrant[] = [
{
id: 'fam_claude',
@@ -126,13 +97,11 @@ const base: CloudApiKeysViewProps = {
viewer: ADMIN,
keys: { status: 'ok', data: KEYS },
settings: { status: 'ok', data: SETTINGS },
audit: { status: 'ok', data: AUDIT },
now: NOW,
onRefresh: () => {},
onCreate: async () => ({ ok: false, error: 'server' }),
onRevoke: async () => null,
onToggleVmCli: async () => null,
onLoadAudit: () => {},
onCreateApproval: async () => ({ ok: false, error: 'server' }),
grants: { status: 'ok', data: [] },
onRevokeGrant: async () => null,
@@ -155,11 +124,6 @@ export const disconnectAppConfirm = defineFixture({
)
})
export const listWithActivity = defineFixture({
width: 760,
render: () => <CloudApiKeysView {...base} initial={{ auditOpen: true }} />
})
export const empty = defineFixture({
width: 760,
render: () => (
@@ -180,7 +144,6 @@ export const loading = defineFixture({
keys={{ status: 'loading' }}
grants={{ status: 'loading' }}
settings={{ status: 'loading' }}
audit={null}
/>
)
})
@@ -196,7 +159,6 @@ export const error = defineFixture({
}}
settings={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }}
grants={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }}
audit={null}
/>
)
})
@@ -212,7 +174,6 @@ export const memberView = defineFixture({
data: { availableScopes: MEMBER.scopes, keys: [{ ...KEYS.keys[0]!, createdBy: 'u_linus' }] }
}}
settings={{ status: 'ok', data: { settings: { vmCliEnabled: false }, canEdit: false } }}
audit={null}
/>
)
})
@@ -229,7 +190,6 @@ export const createDialogMemberCustom = defineFixture({
{...base}
viewer={MEMBER}
keys={{ status: 'ok', data: { availableScopes: MEMBER.scopes, keys: [] } }}
audit={null}
initial={{ create: true }}
/>
),
@@ -7,7 +7,6 @@ import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type {
CloudApiApproval,
CloudApiAuditPage,
CloudApiCreatedKey,
CloudApiGrant,
CloudApiKey,
@@ -20,7 +19,7 @@ import type {
} from '../../../../../shared/cloud-api/cloud-api-types'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { ApproveAppDialog } from './ApproveAppDialog'
import { AccessLinks, AuditLog, OrgSettings } from './CloudApiKeysSections'
import { AccessLinks, OrgSettings } from './CloudApiKeysSections'
import { CloudConfirmDialog } from './CloudConfirmDialog'
import { CloudConnectedApps } from './CloudConnectedApps'
import type { CloudLoadState } from './cloud-load-state'
@@ -37,7 +36,6 @@ export type CloudApiKeysViewProps = {
keys: CloudLoadState<CloudApiKeyList>
settings: CloudLoadState<CloudApiSettings>
/** Null hides the activity log (members cannot read it). */
audit: CloudLoadState<CloudApiAuditPage> | null
now: number
onRefresh: () => void
/** A refresh is running while the current data stays on screen. */
@@ -46,7 +44,6 @@ export type CloudApiKeysViewProps = {
/** Resolves to an error message or null. */
onRevoke: (key: CloudApiKey) => Promise<string | null>
onToggleVmCli: (enabled: boolean) => Promise<string | null>
onLoadAudit: () => void
grants: CloudLoadState<CloudApiGrant[]>
onRevokeGrant: (grant: CloudApiGrant) => Promise<string | null>
onRefreshGrants: () => void
@@ -58,7 +55,6 @@ export type CloudApiKeysViewProps = {
create?: boolean
created?: CloudApiCreatedKey
revokeKeyId?: string
auditOpen?: boolean
approve?: boolean
approval?: CloudApiApproval
revokeGrantId?: string
@@ -252,16 +248,6 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen
settings={props.settings}
onToggle={props.onToggleVmCli}
/>
{props.audit ? (
<AuditLog
audit={props.audit}
now={props.now}
viewer={viewer}
initiallyOpen={initial?.auditOpen ?? false}
onLoad={props.onLoadAudit}
/>
) : null}
</div>
<CreateApiKeyDialog
@@ -29,6 +29,11 @@ export function cloudApiErrorCopy(
'auto.components.settings.cloudApi.error.adminRequired',
'Only a team owner or admin can do this.'
)
case 'plan-required':
return t(
'auto.components.settings.cloudApi.error.planRequired',
'Your team’s plan doesn’t include this.'
)
case 'forbidden':
return t(
'auto.components.settings.cloudApi.error.forbidden',
@@ -13,7 +13,6 @@ export const getCloudApiKeysSearchEntries = createLocalizedCatalog(() => [
...translateSearchKeyword('auto.components.settings.cloudApi.keywordToken', 'token'),
...translateSearchKeyword('auto.components.settings.cloudApi.keywordMcp', 'mcp'),
...translateSearchKeyword('auto.components.settings.cloudApi.keywordCli', 'cli'),
...translateSearchKeyword('auto.components.settings.cloudApi.keywordAudit', 'audit log'),
...translateSearchKeyword('auto.components.settings.cloudApi.keywordCloud', 'cloud')
]
}
@@ -0,0 +1,393 @@
// Settings › Enterprise › Audit log: every recorded change for the team in one list, filtered by
// category and by person, with CSV export. Owners and admins only. A team whose plan does not
// include it sees the upsell instead; the control plane refuses the data either way.
import { Download, History, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate'
import {
CLOUD_AUDIT_CATEGORIES,
type CloudAuditActor,
type CloudAuditCategory,
type CloudAuditEvent
} from '../../../../../shared/cloud-api/cloud-api-types'
import { PersonName, PersonSentence } from '../cloud-api/PersonName'
import { formatAbsoluteTime, formatRelativeFromNow } from '../cloud-api/cloud-api-format'
import {
CloudApiEmptyState,
CloudApiErrorNotice,
CloudApiSkeletonRows
} from '../cloud-api/cloud-api-parts'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell'
import { auditCategoryLabel, auditEventSentence } from './enterprise-audit-copy'
export type AuditLogFilters = { category: CloudAuditCategory | null; actor: string | null }
export type AuditLogState =
| { status: 'loading' }
/** The team's plan does not include the audit log. */
| { status: 'upsell' }
| { status: 'error'; message: string }
| {
status: 'ok'
events: CloudAuditEvent[]
/** More events exist past the ones loaded. */
hasMore: boolean
loadingMore: boolean
/** A failed "Show more" keeps the list and says so under it. */
moreError: string | null
/** A filter change is loading; the previous list stays on screen, dimmed. */
refreshing: boolean
}
export type AuditLogExportState =
| { status: 'idle' }
| { status: 'exporting' }
/** Saved; `truncated` means the file holds the newest events only. */
| { status: 'done'; truncated: boolean }
| { status: 'error'; message: string }
export type EnterpriseAuditLogProps = {
log: AuditLogState
filters: AuditLogFilters
/** Everyone who appears in the log, for the person filter. */
actors: readonly CloudAuditActor[]
exportState: AuditLogExportState
now: number
viewerUserId: string | null
contact: EnterpriseContact
onFilter: (filters: AuditLogFilters) => void
onLoadMore: () => void
onRetry: () => void
onExport: () => void
onContactSales: (url: string) => void
/** False when the page banner already carries Contact sales: the upsell is then a plain line. */
upsellButton?: boolean
}
const ALL = '__all__'
const SYSTEM = 'system'
function ActorCell({
event,
viewerUserId
}: {
event: CloudAuditEvent
viewerUserId: string | null
}): React.JSX.Element {
const t = useTranslate()
const { actor } = event
if (actor.kind === 'system' || !actor.userId) {
return <>{t('settings.enterprise.audit.actor.system', 'Orca')}</>
}
const mine = actor.userId === viewerUserId
const sentence =
actor.via === 'api_key'
? mine
? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key')
: t('auto.components.settings.cloudApi.audit.personViaKey', '<person></person> via API key')
: actor.via === 'mcp'
? mine
? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP')
: t('auto.components.settings.cloudApi.audit.personViaMcp', '<person></person> via MCP')
: actor.via === 'agent'
? mine
? t('settings.enterprise.audit.actor.youViaAgent', 'Your agent')
: t('settings.enterprise.audit.actor.personViaAgent', '<person></person>’s agent')
: actor.via === 'routine'
? mine
? t('settings.enterprise.audit.actor.youViaRoutine', 'Your routine')
: t('settings.enterprise.audit.actor.personViaRoutine', '<person></person>’s routine')
: mine
? t('auto.components.settings.cloudApi.audit.you', 'You')
: '<person></person>'
if (mine) {
return <>{sentence}</>
}
return (
<>
<PersonSentence sentence={sentence} name={actor.name} id={actor.userId} idInTooltip />
{/* A named person who has left: say so, or they read as a current teammate. */}
{actor.kind === 'former_member' && actor.name ? (
<span className="text-muted-foreground">
{' · '}
{t('auto.components.settings.cloudApi.formerMember', 'Former member')}
</span>
) : null}
</>
)
}
function actorOptionLabel(actor: CloudAuditActor, viewerUserId: string | null, t: Translate) {
if (actor.userId && actor.userId === viewerUserId) {
return t('auto.components.settings.cloudApi.audit.you', 'You')
}
return (
<>
<PersonName name={actor.name} id={actor.userId ?? ''} />
{actor.kind === 'former_member' && actor.name ? (
<span className="ml-1.5 text-muted-foreground">
{t('auto.components.settings.cloudApi.formerMember', 'Former member')}
</span>
) : null}
</>
)
}
function Filters({
filters,
actors,
viewerUserId,
disabled,
onFilter
}: Pick<EnterpriseAuditLogProps, 'filters' | 'actors' | 'viewerUserId' | 'onFilter'> & {
disabled: boolean
}): React.JSX.Element {
const t = useTranslate()
return (
<div data-settings-row="" className="flex flex-wrap items-center gap-2 py-3">
<Select
value={filters.category ?? ALL}
disabled={disabled}
onValueChange={(value) =>
onFilter({
...filters,
category: CLOUD_AUDIT_CATEGORIES.find((category) => category === value) ?? null
})
}
>
<SelectTrigger
size="sm"
className="w-44"
aria-label={t('settings.enterprise.audit.filter.categoryLabel', 'Filter by activity')}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={ALL}>
{t('settings.enterprise.audit.filter.allActivity', 'All activity')}
</SelectItem>
{CLOUD_AUDIT_CATEGORIES.filter((category) => category !== 'other').map((category) => (
<SelectItem key={category} value={category}>
{auditCategoryLabel(category, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.actor ?? ALL}
disabled={disabled}
onValueChange={(value) => onFilter({ ...filters, actor: value === ALL ? null : value })}
>
<SelectTrigger
size="sm"
className="w-52"
aria-label={t('settings.enterprise.audit.filter.actorLabel', 'Filter by person')}
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72">
<SelectItem value={ALL}>
{t('settings.enterprise.audit.filter.everyone', 'Everyone')}
</SelectItem>
{actors
.filter((actor) => actor.userId)
.map((actor) => (
<SelectItem key={actor.userId} value={actor.userId ?? ''}>
{actorOptionLabel(actor, viewerUserId, t)}
</SelectItem>
))}
<SelectItem value={SYSTEM}>
{t('settings.enterprise.audit.actor.system', 'Orca')}
</SelectItem>
</SelectContent>
</Select>
</div>
)
}
export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.Element {
const t = useTranslate()
const locale = useIntlLocale()
const { log, filters, exportState } = props
const filtered = filters.category !== null || filters.actor !== null
const canExport = log.status === 'ok' && log.events.length > 0
return (
<SettingsGroup
title={t('settings.enterprise.audit.title', 'Audit log')}
description={t(
'settings.enterprise.audit.description',
'Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.'
)}
action={
log.status === 'ok' ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={!canExport}
aria-disabled={exportState.status === 'exporting'}
aria-busy={exportState.status === 'exporting'}
onClick={() => (exportState.status === 'exporting' ? undefined : props.onExport())}
>
{exportState.status === 'exporting' ? (
<Loader2 className="animate-spin motion-reduce:animate-none" />
) : (
<Download />
)}
{t('settings.enterprise.audit.export', 'Export CSV')}
</Button>
) : undefined
}
>
{log.status === 'upsell' && props.upsellButton === false ? (
<EnterpriseUpsellLine>
{t(
'settings.enterprise.audit.upsellBody',
'Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.'
)}
</EnterpriseUpsellLine>
) : log.status === 'upsell' ? (
<EnterpriseUpsell
title={t('settings.enterprise.audit.upsellTitle', 'Audit log is part of {{plan}}', {
plan: props.contact.planName
})}
body={t(
'settings.enterprise.audit.upsellBody',
'Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.'
)}
contact={props.contact}
onContactSales={props.onContactSales}
/>
) : log.status === 'loading' ? (
<CloudApiSkeletonRows
rows={3}
label={t('settings.enterprise.audit.loading', 'Loading the audit log')}
/>
) : log.status === 'error' ? (
<div data-settings-row="" className="py-4">
<CloudApiErrorNotice message={log.message} onRetry={props.onRetry} />
</div>
) : (
<>
{/* Nothing to narrow down in a log with nothing in it. */}
{filtered || log.events.length > 0 ? (
<Filters
filters={filters}
actors={props.actors}
viewerUserId={props.viewerUserId}
disabled={log.refreshing}
onFilter={props.onFilter}
/>
) : null}
{exportState.status === 'error' ? (
<div data-settings-row="" className="py-3">
<InlineError title={exportState.message} />
</div>
) : exportState.status === 'done' && exportState.truncated ? (
<p data-settings-row="" role="status" className="py-3 text-xs text-muted-foreground">
{t(
'settings.enterprise.audit.exportTruncated',
'The file has the newest 5,000 events. Filter by activity or person to export the rest.'
)}
</p>
) : null}
{log.events.length === 0 ? (
<div data-settings-row="">
<CloudApiEmptyState
icon={History}
title={
filtered
? t('settings.enterprise.audit.emptyFilteredTitle', 'Nothing matches')
: t('settings.enterprise.audit.emptyTitle', 'Nothing recorded yet')
}
description={
filtered
? t(
'settings.enterprise.audit.emptyFilteredBody',
'No recorded change matches these filters.'
)
: t(
'settings.enterprise.audit.emptyBody',
'Changes to members, API keys, secrets, workspaces and settings will show up here.'
)
}
action={
filtered ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => props.onFilter({ category: null, actor: null })}
>
{t('settings.enterprise.audit.clearFilters', 'Clear filters')}
</Button>
) : undefined
}
/>
</div>
) : (
<ul
aria-label={t('settings.enterprise.audit.title', 'Audit log')}
aria-busy={log.refreshing}
className={log.refreshing ? 'opacity-60' : undefined}
>
{log.events.map((event) => (
<li
key={event.id}
data-settings-row=""
className="flex items-center gap-3 py-2 text-xs"
>
<span className="min-w-0 flex-1 truncate text-foreground" title={event.action}>
{auditEventSentence(event, t)}
</span>
<span className="w-28 shrink-0 truncate text-muted-foreground">
{auditCategoryLabel(event.category, t)}
</span>
<span className="w-48 shrink-0 truncate text-foreground">
<ActorCell event={event} viewerUserId={props.viewerUserId} />
</span>
<span
className="w-24 shrink-0 text-right text-muted-foreground"
title={formatAbsoluteTime(event.at, locale)}
>
{formatRelativeFromNow(event.at, props.now, locale)}
</span>
</li>
))}
</ul>
)}
{log.moreError ? (
<div data-settings-row="" className="py-3">
<CloudApiErrorNotice message={log.moreError} onRetry={props.onLoadMore} />
</div>
) : log.hasMore ? (
<div data-settings-row="" className="flex justify-center py-3">
<Button
type="button"
variant="ghost"
size="sm"
aria-disabled={log.loadingMore}
aria-busy={log.loadingMore}
onClick={() => (log.loadingMore ? undefined : props.onLoadMore())}
>
{log.loadingMore ? (
<Loader2 className="animate-spin motion-reduce:animate-none" />
) : null}
{t('settings.enterprise.audit.showMore', 'Show more')}
</Button>
</div>
) : null}
</>
)}
</SettingsGroup>
)
}
@@ -0,0 +1,65 @@
// Settings › Enterprise, wired to the account: who is looking, billing's Enterprise contact page,
// and the audit log's data. Shown only to a team's owners and admins (the nav hides it otherwise,
// and the control plane refuses the data regardless).
import { useEffect } from 'react'
import { useOrcaAccountKey } from '@/hooks/use-orca-account-key'
import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability'
import { activeTeamRole } from '@/lib/orca-cloud/active-team-role'
import { useAppStore } from '@/store'
import { refreshBillingSummary, useBillingState } from '../../billing/billing-summary-store'
import { canManageMembers } from '../../billing/team/team-roles'
import { EnterpriseSettingsView } from './EnterpriseSettingsView'
import { ENTERPRISE_CONTACT_URL, type EnterpriseContact } from './EnterpriseUpsell'
import { useEnterpriseAuditLog } from './use-enterprise-audit-log'
/** Billing's own name and contact page for Enterprise; the fixed page until billing has answered. */
function useEnterpriseContact(): EnterpriseContact {
const billing = useBillingState()
useEffect(() => {
void refreshBillingSummary()
}, [])
const offer =
billing.result?.status === 'ok' ? billing.result.entitlement.offers?.enterprise : null
return {
planName: offer?.label || 'Enterprise',
contactUrl: offer?.contactUrl || ENTERPRISE_CONTACT_URL
}
}
export function EnterpriseSettingsPane(): React.JSX.Element | null {
// Remount on a team switch so one team's log never shows under another.
return <EnterpriseSettingsPaneBody key={useOrcaAccountKey()} />
}
function EnterpriseSettingsPaneBody(): React.JSX.Element | null {
const availability = useOrcaCloudApiAvailability()
const authStatus = useAppStore((s) => s.orcaProfileAuthStatus)
const role = activeTeamRole(authStatus)
const admin = role !== null && canManageMembers(role)
const contact = useEnterpriseContact()
const audit = useEnterpriseAuditLog(availability?.status === 'ready' && admin)
if (availability?.status !== 'ready' || !admin) {
return null
}
return (
<EnterpriseSettingsView
// Enterprise's features come together in one plan version, so the audit log's answer says
// which side of the plan the team is on. Unknown while it loads or fails: the block waits.
ssoIncluded={audit.log.status === 'ok' ? true : audit.log.status === 'upsell' ? false : null}
audit={{
log: audit.log,
filters: audit.filters,
actors: audit.actors,
exportState: audit.exportState,
now: audit.now,
viewerUserId: authStatus?.cloud?.userId ?? null,
contact,
onFilter: audit.setFilters,
onLoadMore: audit.loadMore,
onRetry: audit.retry,
onExport: audit.exportCsv,
onContactSales: (url) => void window.api.shell.openUrl(url)
}}
/>
)
}
@@ -0,0 +1,129 @@
import { defineFixture } from '../../../component-fixtures/component-fixture'
import { SettingsSection } from '../section-layout/SettingsSection'
import { EnterpriseSettingsView } from './EnterpriseSettingsView'
import { fixtureAuditProps } from './enterprise-audit.fixtures'
import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog'
const BARE_BODY = 'rounded-none border-0 bg-transparent p-0 shadow-none'
/** The pane as Settings draws it: page title and description over the bare body. */
function Page({
audit,
ssoIncluded
}: {
audit: EnterpriseAuditLogProps
ssoIncluded?: boolean | null
}): React.JSX.Element {
return (
<div className="bg-background px-8 py-6">
<SettingsSection
id="cloud-enterprise"
title="Enterprise"
description="Single sign-on, sign-in policies and the audit log for your team."
forceVisible
bodyClassName={BARE_BODY}
>
<EnterpriseSettingsView
audit={audit}
ssoIncluded={
ssoIncluded !== undefined
? ssoIncluded
: audit.log.status === 'ok'
? true
: audit.log.status === 'upsell'
? false
: null
}
/>
</SettingsSection>
</div>
)
}
/** An Enterprise team's owner: the merged log, filters and export; single sign-on is theirs, so no upsell. */
export const auditLog = defineFixture({
width: 860,
render: () => <Page audit={fixtureAuditProps()} />
})
/** A team on neither: one banner with the page's only Contact sales, and a plain line per block. */
export const upsell = defineFixture({
width: 860,
render: () => <Page audit={fixtureAuditProps({ log: { status: 'upsell' }, actors: [] })} />
})
/** Entitled, nothing recorded yet. */
export const empty = defineFixture({
width: 860,
render: () => (
<Page
audit={fixtureAuditProps({
log: {
status: 'ok',
events: [],
hasMore: false,
loadingMore: false,
moreError: null,
refreshing: false
},
actors: []
})}
/>
)
})
/** Filters that match nothing: say so and offer the way back. */
export const emptyFiltered = defineFixture({
width: 860,
render: () => (
<Page
audit={fixtureAuditProps({
log: {
status: 'ok',
events: [],
hasMore: false,
loadingMore: false,
moreError: null,
refreshing: false
},
filters: { category: 'billing', actor: 'u_grace' }
})}
/>
)
})
export const loading = defineFixture({
width: 860,
render: () => <Page audit={fixtureAuditProps({ log: { status: 'loading' }, actors: [] })} />
})
export const loadFailed = defineFixture({
width: 860,
render: () => (
<Page
audit={fixtureAuditProps({
log: {
status: 'error',
message: 'Couldn’t reach Orca Cloud. Check your connection and try again.'
},
actors: []
})}
/>
)
})
/** After an export that hit the 5,000-event cap. */
export const exportTruncated = defineFixture({
width: 860,
render: () => (
<Page audit={fixtureAuditProps({ exportState: { status: 'done', truncated: true } })} />
)
})
/** Only the audit log is missing (single sign-on is included): that block keeps its own button. */
export const upsellAuditOnly = defineFixture({
width: 860,
render: () => (
<Page audit={fixtureAuditProps({ log: { status: 'upsell' }, actors: [] })} ssoIncluded />
)
})
@@ -0,0 +1,371 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types'
import {
EnterpriseSettingsView as RawView,
type EnterpriseSettingsViewProps
} from './EnterpriseSettingsView'
import { FIXTURE_EVENTS, FIXTURE_NOW, fixtureAuditProps } from './enterprise-audit.fixtures'
import { auditCategoryLabel, auditEventSentence, isKnownAuditAction } from './enterprise-audit-copy'
afterEach(cleanup)
/** The view as the pane feeds it: the audit log's answer says which side of the plan the team is on. */
function EnterpriseSettingsView(
props: Omit<EnterpriseSettingsViewProps, 'ssoIncluded'> & { ssoIncluded?: boolean | null }
): React.JSX.Element {
const status = props.audit.log.status
const derived = status === 'ok' ? true : status === 'upsell' ? false : null
return (
<RawView
{...props}
ssoIncluded={props.ssoIncluded === undefined ? derived : props.ssoIncluded}
/>
)
}
const EMPTY = {
status: 'ok' as const,
events: [],
hasMore: false,
loadingMore: false,
moreError: null,
refreshing: false
}
function rows(container: HTMLElement): string[] {
return [...container.querySelectorAll('ul[aria-label="Audit log"] > li')].map((row) =>
[...row.children].map((cell) => cell.textContent).join(' | ')
)
}
describe('audit log', () => {
it('lists who did what to what and when, newest first, from every source', () => {
const { container } = render(<EnterpriseSettingsView audit={fixtureAuditProps()} />)
expect(rows(container)).toEqual([
'Changed secret STRIPE_SECRET_KEY | Secrets | Grace Hopper | 4 minutes ago',
'Changed the role of Grace Hopper | Members | You | 38 minutes ago',
'Shared a link to a port on workspace “checkout-redesign” | Workspaces | Grace Hopper | 3 hours ago',
'Started workspace “nightly-evals” | Workspaces | Grace Hopper via API key | 5 hours ago',
'Removed Linus Park from the team | Members | You | yesterday',
'Turned off a routine | Routines | Orca | yesterday',
'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former member | 2 days ago',
'Added secret ANTHROPIC_API_KEY | Secrets | You | 3 days ago',
'Changed agent access | Settings | You | 6 days ago',
'Connected an app | Connected apps | Grace Hopper via MCP | 9 days ago'
])
})
it('never renders a secret value, hint or raw user id inline; the id stays one hover away', () => {
const leaky: CloudAuditEvent = {
...FIXTURE_EVENTS[0]!,
detail: { scope: 'org', hint: 'wxyz', value: 'sk-live-should-not-show' }
}
const { container } = render(
<EnterpriseSettingsView
audit={fixtureAuditProps({ log: { ...EMPTY, events: [leaky, FIXTURE_EVENTS[6]!] } })}
/>
)
expect(container.textContent).not.toMatch(/wxyz|should-not-show/)
expect(container.textContent).not.toContain('u_linus')
expect(container.querySelector('[title="u_linus"]')).not.toBeNull()
})
it('shows an action this version does not know by its machine name instead of hiding it', () => {
const unknown: CloudAuditEvent = {
...FIXTURE_EVENTS[0]!,
id: 'aud_new',
category: 'security',
action: 'sso.went_live'
}
const { container } = render(
<EnterpriseSettingsView audit={fixtureAuditProps({ log: { ...EMPTY, events: [unknown] } })} />
)
expect(rows(container)).toEqual(['sso.went_live | Security | Grace Hopper | 4 minutes ago'])
})
it('pages with Show more, and keeps the list when a page fails', () => {
const onLoadMore = vi.fn()
const { rerender, container } = render(
<EnterpriseSettingsView audit={fixtureAuditProps({ onLoadMore })} />
)
fireEvent.click(screen.getByRole('button', { name: 'Show more' }))
expect(onLoadMore).toHaveBeenCalledTimes(1)
rerender(
<EnterpriseSettingsView
audit={fixtureAuditProps({
onLoadMore,
log: { ...EMPTY, events: FIXTURE_EVENTS, hasMore: true, loadingMore: true }
})}
/>
)
// Busy, not disabled: the button keeps focus, and a second click does nothing.
const busy = screen.getByRole('button', { name: 'Show more' })
expect(busy.getAttribute('aria-busy')).toBe('true')
fireEvent.click(busy)
expect(onLoadMore).toHaveBeenCalledTimes(1)
rerender(
<EnterpriseSettingsView
audit={fixtureAuditProps({
onLoadMore,
log: {
...EMPTY,
events: FIXTURE_EVENTS,
hasMore: true,
moreError: 'Couldn’t reach Orca Cloud.'
}
})}
/>
)
expect(rows(container)).toHaveLength(FIXTURE_EVENTS.length)
expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(onLoadMore).toHaveBeenCalledTimes(2)
// The last page has no Show more.
rerender(
<EnterpriseSettingsView
audit={fixtureAuditProps({ log: { ...EMPTY, events: FIXTURE_EVENTS } })}
/>
)
expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull()
})
it('offers both filters and the export, and exports once per click', () => {
const onExport = vi.fn()
const { rerender } = render(<EnterpriseSettingsView audit={fixtureAuditProps({ onExport })} />)
expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe(
'All activity'
)
expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe('Everyone')
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
expect(onExport).toHaveBeenCalledTimes(1)
rerender(
<EnterpriseSettingsView
audit={fixtureAuditProps({ onExport, exportState: { status: 'exporting' } })}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Export CSV' }))
expect(onExport).toHaveBeenCalledTimes(1)
rerender(
<EnterpriseSettingsView
audit={fixtureAuditProps({ onExport, exportState: { status: 'done', truncated: true } })}
/>
)
expect(screen.getByRole('status').textContent).toContain('newest 5,000 events')
})
it('shows the chosen filters, and an empty filtered list offers the way back', () => {
const onFilter = vi.fn()
render(
<EnterpriseSettingsView
audit={fixtureAuditProps({
onFilter,
log: EMPTY,
filters: { category: 'secrets', actor: 'u_grace' }
})}
/>
)
expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe('Secrets')
expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe(
'Grace Hopper'
)
expect(screen.getByText('Nothing matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }))
expect(onFilter).toHaveBeenCalledWith({ category: null, actor: null })
// Nothing to export.
expect(screen.getByRole('button', { name: 'Export CSV' }).hasAttribute('disabled')).toBe(true)
})
it('says nothing is recorded yet when the log is empty and unfiltered', () => {
render(<EnterpriseSettingsView audit={fixtureAuditProps({ log: EMPTY, actors: [] })} />)
expect(screen.getByText('Nothing recorded yet')).toBeTruthy()
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull()
})
it('words a load failure and retries', () => {
const onRetry = vi.fn()
render(
<EnterpriseSettingsView
audit={fixtureAuditProps({
onRetry,
log: { status: 'error', message: 'Orca Cloud is down.' }
})}
/>
)
expect(screen.getByText('Orca Cloud is down.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(onRetry).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull()
})
})
describe('without the Enterprise plan', () => {
it('a team with neither gets ONE Contact sales, in a banner, and a plain line in each block', () => {
const onContactSales = vi.fn()
const { container } = render(
<EnterpriseSettingsView
audit={fixtureAuditProps({ log: { status: 'upsell' }, actors: [], onContactSales })}
/>
)
expect(screen.getByText('Single sign-on and the audit log are part of Enterprise')).toBeTruthy()
// The blocks keep their titles and say what they are, without a second button.
expect(screen.getByText('Single sign-on and provisioning')).toBeTruthy()
expect(screen.getByText('Audit log')).toBeTruthy()
expect(container.textContent).toContain('so your full history is here from the day you upgrade')
expect(screen.queryByText('Audit log is part of Enterprise')).toBeNull()
expect(rows(container)).toEqual([])
expect(screen.queryByRole('combobox')).toBeNull()
expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull()
const contact = screen.getAllByRole('button', { name: 'Contact sales' })
expect(contact).toHaveLength(1)
fireEvent.click(contact[0]!)
expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise')
// The Copy link chevron sits beside it (the open-in-browser rule).
expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(1)
})
it('a team missing only the audit log keeps the button on that block, with no banner', () => {
const onContactSales = vi.fn()
render(
<EnterpriseSettingsView
ssoIncluded
audit={fixtureAuditProps({ log: { status: 'upsell' }, actors: [], onContactSales })}
/>
)
expect(screen.queryByText('Single sign-on and the audit log are part of Enterprise')).toBeNull()
expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Contact sales' }))
expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise')
})
it("uses billing's own plan name and contact page", () => {
const onContactSales = vi.fn()
render(
<EnterpriseSettingsView
audit={fixtureAuditProps({
log: { status: 'upsell' },
contact: { planName: 'Scale', contactUrl: 'https://orca.dev/scale' },
onContactSales
})}
/>
)
expect(screen.getByText('Single sign-on and the audit log are part of Scale')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Contact sales' }))
expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/scale')
})
})
describe('on the Enterprise plan', () => {
it('never sends the team to sales: single sign-on is set up with us, with Contact us', () => {
const onContactSales = vi.fn()
const { container } = render(
<EnterpriseSettingsView audit={fixtureAuditProps({ onContactSales })} />
)
expect(container.textContent).not.toMatch(/part of Enterprise|Contact sales/)
expect(screen.getByText('Single sign-on is set up with us')).toBeTruthy()
expect(screen.getByText('To set up or change single sign-on, contact us.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Contact us' }))
expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise')
})
it('says nothing about single sign-on until the plan is known', () => {
const { container } = render(
<EnterpriseSettingsView
audit={fixtureAuditProps({ log: { status: 'loading' }, actors: [] })}
/>
)
expect(container.textContent).not.toMatch(/Single sign-on|Contact/)
expect(screen.getByText('Audit log')).toBeTruthy()
})
})
describe('slots', () => {
it('lets the SSO and policy owners replace or add their blocks, in order above the audit log', () => {
const { container } = render(
<EnterpriseSettingsView
audit={fixtureAuditProps()}
singleSignOn={<section data-testid="sso">SSO</section>}
policies={<section data-testid="mfa">Require MFA</section>}
/>
)
expect(screen.queryByText('Single sign-on is set up with us')).toBeNull()
const order = [...container.querySelectorAll('[id^="cloud-enterprise-"]')].map(
(node) => node.id
)
expect(order).toEqual([
'cloud-enterprise-sso',
'cloud-enterprise-policies',
'cloud-enterprise-audit-log'
])
})
})
describe('copy', () => {
it('names every category and words every action the control plane records today', () => {
expect(auditCategoryLabel('api_keys')).toBe('API keys')
const recorded = [
'api_key.create',
'api_key.revoke',
'workspace_token.issue',
'settings.update',
'workspace.create',
'workspace.destroy',
'workspace.sleep',
'workspace.wake',
'workspace.archive',
'workspace.unarchive',
'workspace.rename',
'workspace.share_create',
'workspace.share_revoke',
'workspace.message',
'workspace.attachment',
'workspace.session.create',
'workspace.session.cancel',
'workspace.session.rename',
'sandbox.ssh_key_added',
'sandbox.ssh_key_removed',
'project.create',
'project.update',
'project.delete',
'mcp.approval_create',
'mcp.authorize',
'mcp.grant_revoke',
'mcp.code_replay',
'mcp.refresh_replay',
'secret.created',
'secret.updated',
'secret.deleted',
'member.removed',
'member.left',
'member.role_changed',
'member.role_synced',
'routine.create',
'routine.update',
'routine.delete',
'routine.disable',
'routine.run',
'routine.run.start',
'routine.run.finish',
'routine.rotate_webhook'
]
expect(recorded.filter((action) => !isKnownAuditAction(action))).toEqual([])
})
it('falls back to the plain sentence when the target has no name', () => {
const event: CloudAuditEvent = {
id: 'a',
at: FIXTURE_NOW,
category: 'members',
action: 'member.removed',
actor: { kind: 'system', userId: null, name: null, via: null },
target: { type: 'member', id: 'u_x', label: null },
detail: null
}
expect(auditEventSentence(event)).toBe('Removed a teammate')
expect(
auditEventSentence({ ...event, target: { type: 'member', id: 'u_x', label: 'Bo' } })
).toBe('Removed Bo from the team')
})
})
@@ -0,0 +1,123 @@
// Settings › Enterprise, for a team's owners and admins: single sign-on and directory sync, the
// team's sign-in policies, and the audit log. Each block shows its own controls when the team's
// plan includes it and the shared upsell (EnterpriseUpsell) when it does not.
//
// Contact sales appears once per page: a team with neither single sign-on nor the audit log gets
// one banner at the top and a plain line in each block. A team on Enterprise is never sent to sales.
//
// Slots for the blocks other owners build (pass a node; nothing renders until one is passed):
// singleSignOn replaces the built-in single sign-on block (sso-scim)
// policies sign-in policies, e.g. "Require MFA" (mfa)
import { ExternalLink } from 'lucide-react'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { useTranslate } from '@/i18n/use-translate'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { EnterpriseAuditLog, type EnterpriseAuditLogProps } from './EnterpriseAuditLog'
import { EnterpriseUpsell, EnterpriseUpsellLine } from './EnterpriseUpsell'
export const ENTERPRISE_SSO_ANCHOR = 'cloud-enterprise-sso'
export const ENTERPRISE_POLICIES_ANCHOR = 'cloud-enterprise-policies'
export const ENTERPRISE_AUDIT_LOG_ANCHOR = 'cloud-enterprise-audit-log'
export type EnterpriseSettingsViewProps = {
audit: EnterpriseAuditLogProps
/**
* Whether the team's plan includes single sign-on: true, false, or null while that is not known
* yet (the block waits rather than guess). Ignored once `singleSignOn` is passed.
*/
ssoIncluded: boolean | null
singleSignOn?: React.ReactNode
policies?: React.ReactNode
}
export function EnterpriseSettingsView({
audit,
ssoIncluded,
singleSignOn,
policies
}: EnterpriseSettingsViewProps): React.JSX.Element {
const t = useTranslate()
const { contact, onContactSales } = audit
const ssoLocked = singleSignOn === undefined && ssoIncluded === false
const auditLocked = audit.log.status === 'upsell'
// Neither: one banner carries the page's only Contact sales.
const banner = ssoLocked && auditLocked
const ssoUpsellBody = t(
'settings.enterprise.sso.upsellBody',
'Sign in with your identity provider over SAML, and add or remove teammates with SCIM.'
)
return (
<div className="space-y-8">
{banner ? (
<SettingsGroup>
<EnterpriseUpsell
title={t(
'settings.enterprise.bannerTitle',
'Single sign-on and the audit log are part of {{plan}}',
{ plan: contact.planName }
)}
body={t(
'settings.enterprise.bannerBody',
'Talk to us about moving your team to {{plan}}.',
{ plan: contact.planName }
)}
contact={contact}
onContactSales={onContactSales}
/>
</SettingsGroup>
) : null}
{singleSignOn !== undefined ? (
<div id={ENTERPRISE_SSO_ANCHOR}>{singleSignOn}</div>
) : ssoIncluded === null ? null : (
<div id={ENTERPRISE_SSO_ANCHOR}>
<SettingsGroup
title={t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')}
>
{ssoIncluded ? (
// On the plan: never an upsell. We set it up with the team until they can do it here.
<div data-settings-row="" className="flex flex-wrap items-center gap-3 py-4">
<div className="min-w-[14rem] flex-1 space-y-0.5">
<p className="text-sm font-medium">
{t('settings.enterprise.sso.managedTitle', 'Single sign-on is set up with us')}
</p>
<p className="text-xs text-muted-foreground text-pretty">
{t(
'settings.enterprise.sso.managedBody',
'To set up or change single sign-on, contact us.'
)}
</p>
</div>
<OpenExternalSplitButton
variant="outline"
size="sm"
link={contact.contactUrl}
onClick={() => onContactSales(contact.contactUrl)}
>
{t('settings.enterprise.contactUs', 'Contact us')}
<ExternalLink />
</OpenExternalSplitButton>
</div>
) : banner ? (
<EnterpriseUpsellLine>{ssoUpsellBody}</EnterpriseUpsellLine>
) : (
<EnterpriseUpsell
title={t(
'settings.enterprise.sso.upsellTitle',
'Single sign-on is part of {{plan}}',
{ plan: contact.planName }
)}
body={ssoUpsellBody}
contact={contact}
onContactSales={onContactSales}
/>
)}
</SettingsGroup>
</div>
)}
{policies ? <div id={ENTERPRISE_POLICIES_ANCHOR}>{policies}</div> : null}
<div id={ENTERPRISE_AUDIT_LOG_ANCHOR}>
<EnterpriseAuditLog {...audit} upsellButton={!banner} />
</div>
</div>
)
}
@@ -0,0 +1,56 @@
// The one locked state for everything on Settings › Enterprise: what the feature is, that it is
// part of Enterprise, and Contact sales. The button opens the page; its chevron copies the link.
import { ExternalLink } from 'lucide-react'
import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton'
import { useTranslate } from '@/i18n/use-translate'
/** Where Contact sales goes when billing has not answered yet (billing's own Enterprise page). */
export const ENTERPRISE_CONTACT_URL = 'https://orca.dev/enterprise'
/** Billing's name and contact page for the plan, so the upsell never drifts from the plans page. */
export type EnterpriseContact = { planName: string; contactUrl: string }
export function EnterpriseUpsell({
title,
body,
contact,
onContactSales
}: {
/** "Audit log is part of Enterprise". */
title: string
body: string
contact: EnterpriseContact
onContactSales: (url: string) => void
}): React.JSX.Element {
const t = useTranslate()
return (
<div data-settings-row="" className="flex flex-wrap items-center gap-3 py-4">
<div className="min-w-[14rem] flex-1 space-y-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground text-pretty">{body}</p>
</div>
<OpenExternalSplitButton
variant="outline"
size="sm"
link={contact.contactUrl}
onClick={() => onContactSales(contact.contactUrl)}
>
{t('auto.components.billing.enterpriseContactSales', 'Contact sales')}
<ExternalLink />
</OpenExternalSplitButton>
</div>
)
}
/** The locked state without a button, for a block under the page banner that carries Contact sales. */
export function EnterpriseUpsellLine({
children
}: {
children: React.ReactNode
}): React.JSX.Element {
return (
<p data-settings-row="" className="py-4 text-xs text-muted-foreground text-pretty">
{children}
</p>
)
}
@@ -0,0 +1,311 @@
// Wording for the team audit log: each recorded action as a sentence an admin can scan, and the
// names of the categories they filter by. An action this version does not know is shown by its
// machine name rather than hidden, so nothing recorded is ever missing from the log.
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type {
CloudAuditCategory,
CloudAuditEvent
} from '../../../../../shared/cloud-api/cloud-api-types'
export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = translate): string {
switch (category) {
case 'members':
return t('settings.enterprise.audit.category.members', 'Members')
case 'security':
return t('settings.enterprise.audit.category.security', 'Security')
case 'api_keys':
return t('settings.enterprise.audit.category.apiKeys', 'API keys')
case 'apps':
return t('settings.enterprise.audit.category.apps', 'Connected apps')
case 'workspaces':
return t('settings.enterprise.audit.category.workspaces', 'Workspaces')
case 'secrets':
return t('settings.enterprise.audit.category.secrets', 'Secrets')
case 'routines':
return t('settings.enterprise.audit.category.routines', 'Routines')
case 'settings':
return t('settings.enterprise.audit.category.settings', 'Settings')
case 'billing':
return t('settings.enterprise.audit.category.billing', 'Billing')
case 'other':
return t('settings.enterprise.audit.category.other', 'Other')
}
}
type Sentence = (t: Translate) => string
/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */
type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string }
const ACTIONS: Record<string, ActionCopy> = {
'api_key.create': {
plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'),
named: (t, name) =>
t('settings.enterprise.audit.action.keyCreateNamed', 'Created API key “{{name}}”', { name })
},
'api_key.revoke': {
plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'),
named: (t, name) =>
t('settings.enterprise.audit.action.keyRevokeNamed', 'Revoked API key “{{name}}”', { name })
},
'workspace_token.issue': {
plain: (t) =>
t('settings.enterprise.audit.action.workspaceToken', 'Gave an agent access to the Orca API')
},
'settings.update': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.settings', 'Changed agent access')
},
'workspace.create': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.wsCreate', 'Started a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsCreateNamed', 'Started workspace “{{name}}”', { name })
},
'workspace.destroy': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.wsDestroy', 'Deleted a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsDestroyNamed', 'Deleted workspace “{{name}}”', { name })
},
'workspace.sleep': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.wsSleep', 'Put a workspace to sleep'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsSleepNamed', 'Put workspace “{{name}}” to sleep', {
name
})
},
'workspace.wake': {
plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsWakeNamed', 'Woke workspace “{{name}}”', { name })
},
'workspace.archive': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.wsArchive', 'Archived a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsArchiveNamed', 'Archived workspace “{{name}}”', {
name
})
},
'workspace.unarchive': {
plain: (t) => t('settings.enterprise.audit.action.wsUnarchive', 'Restored a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsUnarchiveNamed', 'Restored workspace “{{name}}”', {
name
})
},
'workspace.rename': {
plain: (t) => t('settings.enterprise.audit.action.wsRename', 'Renamed a workspace'),
named: (t, name) =>
t('settings.enterprise.audit.action.wsRenameNamed', 'Renamed a workspace to “{{name}}”', {
name
})
},
'workspace.share_create': {
plain: (t) =>
t('settings.enterprise.audit.action.wsShare', 'Shared a link to a workspace port'),
named: (t, name) =>
t(
'settings.enterprise.audit.action.wsShareNamed',
'Shared a link to a port on workspace “{{name}}”',
{ name }
)
},
'workspace.share_revoke': {
plain: (t) =>
t('settings.enterprise.audit.action.wsUnshare', 'Turned off a workspace share link'),
named: (t, name) =>
t(
'settings.enterprise.audit.action.wsUnshareNamed',
'Turned off a share link on workspace “{{name}}”',
{ name }
)
},
'workspace.message': {
plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsMessage', 'Messaged an agent')
},
'workspace.attachment': {
plain: (t) => t('settings.enterprise.audit.action.wsAttachment', 'Sent a file to an agent')
},
'workspace.session.create': {
plain: (t) => t('settings.enterprise.audit.action.sessionCreate', 'Started an agent')
},
'workspace.session.cancel': {
plain: (t) => t('settings.enterprise.audit.action.sessionCancel', 'Stopped an agent')
},
'workspace.session.rename': {
plain: (t) => t('settings.enterprise.audit.action.sessionRename', 'Renamed an agent session')
},
'sandbox.ssh_key_added': {
plain: (t) => t('settings.enterprise.audit.action.sshAdd', 'Added an SSH key to a workspace'),
named: (t, name) =>
t(
'settings.enterprise.audit.action.sshAddNamed',
'Added an SSH key to workspace “{{name}}”',
{
name
}
)
},
'sandbox.ssh_key_removed': {
plain: (t) =>
t('settings.enterprise.audit.action.sshRemove', 'Removed an SSH key from a workspace'),
named: (t, name) =>
t(
'settings.enterprise.audit.action.sshRemoveNamed',
'Removed an SSH key from workspace “{{name}}”',
{ name }
)
},
'project.create': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.projectCreate', 'Created a project'),
named: (t, name) =>
t('settings.enterprise.audit.action.projectCreateNamed', 'Created project “{{name}}”', {
name
})
},
'project.update': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.projectUpdate', 'Edited a project')
},
'project.delete': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.projectDelete', 'Deleted a project')
},
'mcp.approval_create': {
plain: (t) =>
t('auto.components.settings.cloudApi.audit.action.approval', 'Made an app approval code')
},
'mcp.authorize': {
plain: (t) => t('settings.enterprise.audit.action.mcpAuthorize', 'Connected an app')
},
'mcp.grant_revoke': {
plain: (t) => t('settings.enterprise.audit.action.mcpRevoke', 'Disconnected an app')
},
'mcp.code_replay': {
plain: (t) =>
t(
'settings.enterprise.audit.action.mcpCodeReplay',
'Blocked a reused app sign-in code and disconnected the app'
)
},
'mcp.refresh_replay': {
plain: (t) =>
t(
'settings.enterprise.audit.action.mcpRefreshReplay',
'Blocked a reused app token and disconnected the app'
)
},
'secret.created': {
plain: (t) => t('settings.enterprise.audit.action.secretCreate', 'Added a secret'),
named: (t, name) =>
t('settings.enterprise.audit.action.secretCreateNamed', 'Added secret {{name}}', { name })
},
'secret.updated': {
plain: (t) => t('settings.enterprise.audit.action.secretUpdate', 'Changed a secret'),
named: (t, name) =>
t('settings.enterprise.audit.action.secretUpdateNamed', 'Changed secret {{name}}', { name })
},
'secret.deleted': {
plain: (t) => t('settings.enterprise.audit.action.secretDelete', 'Deleted a secret'),
named: (t, name) =>
t('settings.enterprise.audit.action.secretDeleteNamed', 'Deleted secret {{name}}', { name })
},
'member.invited': {
plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'),
named: (t, name) =>
t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name })
},
'member.invite_revoked': {
plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'),
named: (t, name) =>
t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', {
name
})
},
'member.removed': {
plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'),
named: (t, name) =>
t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', {
name
})
},
'member.left': {
plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team')
},
'member.role_changed': {
plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'),
named: (t, name) =>
t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', {
name
})
},
'member.role_synced': {
plain: (t) =>
t(
'settings.enterprise.audit.action.roleSync',
'Updated a teammate’s API keys to match their role'
)
},
'team.created': {
plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team')
},
'team.renamed': {
plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'),
named: (t, name) =>
t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', {
name
})
},
'routine.create': {
plain: (t) => t('settings.enterprise.audit.action.routineCreate', 'Created a routine')
},
'routine.update': {
plain: (t) => t('settings.enterprise.audit.action.routineUpdate', 'Edited a routine')
},
'routine.delete': {
plain: (t) => t('settings.enterprise.audit.action.routineDelete', 'Deleted a routine')
},
'routine.disable': {
plain: (t) => t('settings.enterprise.audit.action.routineDisable', 'Turned off a routine')
},
'routine.run': {
plain: (t) => t('settings.enterprise.audit.action.routineRun', 'Ran a routine')
},
'routine.run.start': {
plain: (t) => t('settings.enterprise.audit.action.routineRunStart', 'A routine run started')
},
'routine.run.finish': {
plain: (t) => t('settings.enterprise.audit.action.routineRunFinish', 'A routine run finished')
},
'routine.rotate_webhook': {
plain: (t) =>
t('settings.enterprise.audit.action.routineWebhook', 'Replaced a routine’s webhook URL')
}
}
/** The name to put in the sentence: what the control plane labelled the target, else a recorded name. */
function targetName(event: CloudAuditEvent): string | null {
const recorded = event.detail?.name
const name = event.target?.label ?? (typeof recorded === 'string' ? recorded : null)
const clean = name?.trim()
return clean ? clean.slice(0, 80) : null
}
/** "Created API key “CI deploy”"; an unknown action reads as its machine name. */
export function auditEventSentence(event: CloudAuditEvent, t: Translate = translate): string {
const copy = ACTIONS[event.action]
if (!copy) {
return event.action
}
const name = targetName(event)
return name && copy.named ? copy.named(t, name) : copy.plain(t)
}
/** Whether this version words the action (the rest show their machine name). */
export function isKnownAuditAction(action: string): boolean {
return action in ACTIONS
}
@@ -0,0 +1,152 @@
// Sample audit log for the Enterprise settings fixtures and tests: one event from every source the
// control plane merges (API keys, secrets, members, workspaces, settings, apps), by current
// teammates, a former teammate, an API key, an agent and Orca itself. Names only, never values.
import type {
CloudAuditActor,
CloudAuditEvent
} from '../../../../../shared/cloud-api/cloud-api-types'
import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog'
export const FIXTURE_NOW = Date.UTC(2026, 10, 12, 17, 30)
const MIN = 60_000
const HOUR = 60 * MIN
const DAY = 24 * HOUR
const ADA: CloudAuditActor = { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null }
const GRACE: CloudAuditActor = {
kind: 'person',
userId: 'u_grace',
name: 'Grace Hopper',
via: null
}
const LINUS: CloudAuditActor = {
kind: 'former_member',
userId: 'u_linus',
name: 'Linus Park',
via: null
}
const SYSTEM: CloudAuditActor = { kind: 'system', userId: null, name: null, via: null }
export const FIXTURE_ACTORS: CloudAuditActor[] = [ADA, GRACE, LINUS]
export const FIXTURE_EVENTS: CloudAuditEvent[] = [
{
id: 'sev_9',
at: FIXTURE_NOW - 4 * MIN,
category: 'secrets',
action: 'secret.updated',
actor: GRACE,
target: { type: 'secret', id: 'STRIPE_SECRET_KEY', label: 'STRIPE_SECRET_KEY' },
detail: { scope: 'org' }
},
{
id: 'aud_8',
at: FIXTURE_NOW - 38 * MIN,
category: 'members',
action: 'member.role_changed',
actor: ADA,
target: { type: 'member', id: 'u_grace', label: 'Grace Hopper' },
detail: { role: 'admin' }
},
{
id: 'aud_7',
at: FIXTURE_NOW - 3 * HOUR,
category: 'workspaces',
action: 'workspace.share_create',
actor: GRACE,
target: { type: 'workspace', id: 'sbx_7Hq2', label: 'checkout-redesign' },
detail: { port: 3000, audience: 'team', expiresIn: '1d' }
},
{
id: 'aud_6',
at: FIXTURE_NOW - 5 * HOUR,
category: 'workspaces',
action: 'workspace.create',
actor: { ...GRACE, via: 'api_key' },
target: { type: 'workspace', id: 'sbx_2Lm9', label: 'nightly-evals' },
detail: null
},
{
id: 'aud_5',
at: FIXTURE_NOW - 1 * DAY,
category: 'members',
action: 'member.removed',
actor: ADA,
target: { type: 'member', id: 'u_linus', label: 'Linus Park' },
detail: { keysRevoked: 2, devicesRevoked: 1 }
},
{
id: 'aud_4',
at: FIXTURE_NOW - 1 * DAY - 2 * MIN,
category: 'routines',
action: 'routine.disable',
actor: SYSTEM,
target: { type: 'routine', id: 'rtn_41', label: null },
detail: { reason: 'member_removed' }
},
{
id: 'aud_3',
at: FIXTURE_NOW - 2 * DAY,
category: 'api_keys',
action: 'api_key.create',
actor: LINUS,
target: { type: 'api_key', id: 'key_ci', label: 'GitHub Actions deploy' },
detail: { name: 'GitHub Actions deploy' }
},
{
id: 'sev_2',
at: FIXTURE_NOW - 3 * DAY,
category: 'secrets',
action: 'secret.created',
actor: ADA,
target: { type: 'secret', id: 'ANTHROPIC_API_KEY', label: 'ANTHROPIC_API_KEY' },
detail: { scope: 'repo', repo: 'github.com/acme/web' }
},
{
id: 'aud_1',
at: FIXTURE_NOW - 6 * DAY,
category: 'settings',
action: 'settings.update',
actor: ADA,
target: { type: 'account', id: 'org:acme', label: null },
detail: { vmCliEnabled: true }
},
{
id: 'aud_0',
at: FIXTURE_NOW - 9 * DAY,
category: 'apps',
action: 'mcp.authorize',
actor: { ...GRACE, via: 'mcp' },
target: { type: 'oauth_client', id: 'cl_4q', label: null },
detail: null
}
]
const noop = (): void => {}
export function fixtureAuditProps(
patch: Partial<EnterpriseAuditLogProps> = {}
): EnterpriseAuditLogProps {
return {
log: {
status: 'ok',
events: FIXTURE_EVENTS,
hasMore: true,
loadingMore: false,
moreError: null,
refreshing: false
},
filters: { category: null, actor: null },
actors: FIXTURE_ACTORS,
exportState: { status: 'idle' },
now: FIXTURE_NOW,
viewerUserId: 'u_ada',
contact: { planName: 'Enterprise', contactUrl: 'https://orca.dev/enterprise' },
onFilter: noop,
onLoadMore: noop,
onRetry: noop,
onExport: noop,
onContactSales: noop,
...patch
}
}
@@ -0,0 +1,196 @@
// Loads the team audit log for Settings › Enterprise: the first page, more pages, filter changes
// and the CSV export. The control plane decides access; `plan-required` becomes the upsell.
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslate } from '@/i18n/use-translate'
import type {
CloudAuditActor,
CloudAuditEvent
} from '../../../../../shared/cloud-api/cloud-api-types'
import { cloudApiErrorCopy } from '../cloud-api/cloud-api-copy'
import type { CloudApiFailure } from '../cloud-api/cloud-load-state'
import { useRequestGeneration } from '../cloud-api/use-request-generation'
import type { AuditLogExportState, AuditLogFilters, AuditLogState } from './EnterpriseAuditLog'
const PAGE_SIZE = 50
const NO_FILTERS: AuditLogFilters = { category: null, actor: null }
function queryFor(filters: AuditLogFilters): {
category?: NonNullable<AuditLogFilters['category']>
actor?: string
} {
return {
...(filters.category ? { category: filters.category } : {}),
...(filters.actor ? { actor: filters.actor } : {})
}
}
/** Hands the CSV to the browser's download path (Electron shows its save dialog). */
export function saveAuditCsv(csv: string, at: number): void {
// A BOM so spreadsheet apps read names with accents as UTF-8.
const url = URL.createObjectURL(new Blob(['', csv], { type: 'text/csv;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `orca-audit-log-${new Date(at).toISOString().slice(0, 10)}.csv`
document.body.append(link)
link.click()
link.remove()
// Revoked after the click has been handled, or the download can start from a dead URL.
window.setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
export function useEnterpriseAuditLog(enabled: boolean): {
log: AuditLogState
filters: AuditLogFilters
actors: CloudAuditActor[]
exportState: AuditLogExportState
now: number
setFilters: (filters: AuditLogFilters) => void
loadMore: () => void
retry: () => void
exportCsv: () => void
} {
const t = useTranslate()
const api = window.api.cloudApi
const begin = useRequestGeneration()
const [log, setLog] = useState<AuditLogState>({ status: 'loading' })
const [filters, setFiltersState] = useState<AuditLogFilters>(NO_FILTERS)
const [actors, setActors] = useState<CloudAuditActor[]>([])
const [exportState, setExportState] = useState<AuditLogExportState>({ status: 'idle' })
const [now, setNow] = useState(() => Date.now())
const cursor = useRef<string | null>(null)
const loaded = useRef<CloudAuditEvent[]>([])
const word = useCallback(
(failure: CloudApiFailure) => cloudApiErrorCopy(failure.error, t, failure),
[t]
)
const loadFirst = useCallback(
async (next: AuditLogFilters): Promise<void> => {
if (!api) {
return
}
const isCurrent = begin('audit-log')
// A filter change keeps the list on screen until the new one arrives.
setLog((current) =>
current.status === 'ok' ? { ...current, refreshing: true } : { status: 'loading' }
)
const result = await api.listAuditLog({ ...queryFor(next), limit: PAGE_SIZE })
if (!isCurrent()) {
return
}
setNow(Date.now())
if (!result.ok) {
cursor.current = null
loaded.current = []
setLog(
result.error === 'plan-required'
? { status: 'upsell' }
: { status: 'error', message: word(result) }
)
return
}
cursor.current = result.data.nextCursor
loaded.current = result.data.events
if (result.data.actors) {
setActors(result.data.actors)
}
setLog({
status: 'ok',
events: result.data.events,
hasMore: result.data.nextCursor !== null,
loadingMore: false,
moreError: null,
refreshing: false
})
},
[api, begin, word]
)
useEffect(() => {
if (enabled) {
void loadFirst(NO_FILTERS)
}
}, [enabled, loadFirst])
const setFilters = useCallback(
(next: AuditLogFilters): void => {
setFiltersState(next)
setExportState({ status: 'idle' })
void loadFirst(next)
},
[loadFirst]
)
const loadMore = useCallback((): void => {
const after = cursor.current
if (!api || !after) {
return
}
const isCurrent = begin('audit-log')
setLog((current) =>
current.status === 'ok' ? { ...current, loadingMore: true, moreError: null } : current
)
void api
.listAuditLog({ ...queryFor(filters), cursor: after, limit: PAGE_SIZE })
.then((result) => {
if (!isCurrent()) {
return
}
if (!result.ok) {
setLog((current) =>
current.status === 'ok'
? { ...current, loadingMore: false, moreError: word(result) }
: current
)
return
}
cursor.current = result.data.nextCursor
// A new event can shift a page boundary; never show one twice.
const seen = new Set(loaded.current.map((event) => event.id))
loaded.current = [
...loaded.current,
...result.data.events.filter((event) => !seen.has(event.id))
]
setLog({
status: 'ok',
events: loaded.current,
hasMore: result.data.nextCursor !== null,
loadingMore: false,
moreError: null,
refreshing: false
})
})
}, [api, begin, filters, word])
const exportCsv = useCallback((): void => {
if (!api) {
return
}
const isCurrent = begin('audit-export')
setExportState({ status: 'exporting' })
void api.exportAuditLogCsv(queryFor(filters)).then((result) => {
if (!isCurrent()) {
return
}
if (!result.ok) {
setExportState({ status: 'error', message: word(result) })
return
}
saveAuditCsv(result.data.csv, Date.now())
setExportState({ status: 'done', truncated: result.data.truncated })
})
}, [api, begin, filters, word])
return {
log,
filters,
actors,
exportState,
now,
setFilters,
loadMore,
retry: () => void loadFirst(filters),
exportCsv
}
}
@@ -48,6 +48,7 @@ const SETTINGS_NAV_TARGETS = [
'orca-account',
'cloud-billing',
'cloud-team',
'cloud-enterprise',
'cloud-secrets',
'cloud-environments',
'cloud-mcp',
@@ -13,6 +13,7 @@ import {
} from '../../cloud-environment/CloudEnvironmentSection'
import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../OrcaSignInProgress'
import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts'
import { EnterpriseSettingsPane } from '../enterprise/EnterpriseSettingsPane'
import { CloudSecretsSection } from '../cloud-secrets/CloudSecretsSection'
import { SettingsGroup } from '../section-layout/SettingsGroup'
import { CloudPredictiveWakeSetting } from './CloudPredictiveWakeSetting'
@@ -104,6 +105,11 @@ function OrcaCloudAccountGate({
)
}
/** Settings › Enterprise: single sign-on, sign-in policies and the team audit log. */
export function OrcaCloudEnterprisePane(): React.JSX.Element {
return <OrcaCloudAccountGate>{() => <EnterpriseSettingsPane />}</OrcaCloudAccountGate>
}
export function OrcaCloudTeamPane(): React.JSX.Element {
const t = useTranslate()
return (
@@ -34,6 +34,24 @@ export const getOrcaCloudTeamSearchEntries = createLocalizedCatalog(() => [
}
])
export const getOrcaCloudEnterpriseSearchEntries = createLocalizedCatalog(() => [
{
title: translate('settings.enterprise.title', 'Enterprise'),
description: translate(
'settings.enterprise.searchDescription',
'Single sign-on and directory sync, and the audit log of changes across your team.'
),
keywords: [
...translateSearchKeyword('settings.enterprise.keywordAudit', 'audit log'),
...translateSearchKeyword('settings.enterprise.keywordActivity', 'activity'),
...translateSearchKeyword('settings.enterprise.keywordSso', 'sso'),
...translateSearchKeyword('settings.enterprise.keywordSaml', 'saml'),
...translateSearchKeyword('settings.enterprise.keywordScim', 'scim'),
...translateSearchKeyword('settings.enterprise.keywordCompliance', 'compliance')
]
}
])
export const getOrcaCloudSecretsSearchEntries = createLocalizedCatalog(() => [
{
title: translate('cloudSecrets.title', 'Cloud secrets'),
@@ -1,4 +1,5 @@
import {
OrcaCloudEnterprisePane,
OrcaCloudEnvironmentsPane,
OrcaCloudLocalCommandsSettingsPane,
OrcaCloudMcpPane,
@@ -23,8 +24,10 @@ export function renderOrcaCloudSettingsSections(
return null
}
// Titles and descriptions come from the nav metadata so the pane and the sidebar never disagree.
const sections = [
const sections: { id: string; pane: React.JSX.Element; navOnly?: boolean }[] = [
{ id: 'cloud-team', pane: <OrcaCloudTeamPane /> },
// Listed in the nav for team owners and admins only; without the entry there is no page.
{ id: 'cloud-enterprise', pane: <OrcaCloudEnterprisePane />, navOnly: true },
{ id: 'cloud-secrets', pane: <OrcaCloudSecretsPane /> },
{ id: 'cloud-skills', pane: <OrcaCloudSkillsSyncPane /> },
{ id: 'cloud-environments', pane: <OrcaCloudEnvironmentsPane /> },
@@ -33,8 +36,11 @@ export function renderOrcaCloudSettingsSections(
]
return (
<>
{sections.map(({ id, pane }) => {
{sections.map(({ id, pane, navOnly }) => {
const meta = navigation.navSections.find((section) => section.id === id)
if (navOnly && !meta) {
return null
}
return (
<SettingsSection
key={id}
@@ -116,7 +116,7 @@ function failureActions(
primary: [
{
key: 'plans',
label: t('auto.lib.orcaCloudSession.seePlans', 'See plans'),
label: t('auto.components.billing.comparePlans', 'Compare plans'),
onClick: actions.onSeePlans
},
// Paying elsewhere (another device, the portal) is picked up by trying again.
@@ -91,11 +91,11 @@ it('keeps a pending Connect GitHub focusable and ignores repeat clicks', () => {
expect(document.activeElement).toBe(button)
})
it('offers an owner See plans then Try again on no plan', () => {
it('offers an owner Compare plans then Try again on no plan', () => {
const handlers = actions()
render(<Sheet failure={{ kind: 'plan' }} handlers={handlers} />)
const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim())
expect(buttons.slice(0, 2)).toEqual(['See plans', 'Try again'])
expect(buttons.slice(0, 2)).toEqual(['Compare plans', 'Try again'])
fireEvent.click(screen.getByRole('button', { name: /Try again/ }))
expect(handlers.onRetry).toHaveBeenCalledOnce()
})
@@ -106,7 +106,7 @@ it.each(['plan', 'hoursUsed', 'spendCap', 'payment'] as const)(
const handlers = actions()
render(<Sheet failure={{ kind }} canManageBilling={false} handlers={handlers} />)
expect(
screen.queryByRole('button', { name: /See plans|Raise spend cap|Turn on overage/ })
screen.queryByRole('button', { name: /Compare plans|Raise spend cap|Turn on overage/ })
).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Use this computer instead/ }))
expect(handlers.onCreateLocally).toHaveBeenCalledOnce()
@@ -100,6 +100,6 @@ export const refusedPlan = refused('no_subscription')
export const refusedHoursUsed = refused('included_hours_exhausted')
export const refusedSpendCap = refused('overage_cap_reached')
export const refusedPayment = refused('payment_past_due')
// A member can't fix billing: the card names who can, with no "See plans".
// A member can't fix billing: the card names who can, with no "Compare plans".
export const refusedPlanMember = refused('no_subscription', false)
export const refusedHoursUsedMember = refused('included_hours_exhausted', false)
@@ -62,7 +62,7 @@ export function showVmBuildFailureToast(
...(failure.kind === 'plan'
? {
action: {
label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'),
label: translate('auto.components.billing.comparePlans', 'Compare plans'),
onClick: () => openPlansPage('sandbox')
}
}
@@ -15,6 +15,8 @@ export type SettingsNavigationBuildOptions = {
isLinearConnected: boolean
/** Orca Cloud programmatic access (API keys, routines): only builds with a control plane. */
isOrcaCloudApiAvailable?: boolean
/** Owner or admin of the active Orca team: Settings › Enterprise is theirs alone. */
isOrcaTeamAdmin?: boolean
repos: readonly Repo[]
projectGrouping?: ProjectGroupingModel
}
@@ -53,6 +53,33 @@ describe('Orca Cloud settings group', () => {
expect(new Set(sections.map((section) => section.group))).toEqual(new Set(['orca-cloud']))
})
it('adds Enterprise after Team for a team owner or admin only', () => {
const ids = (patch: Parameters<typeof options>[0]) =>
buildOrcaCloudSettingsSections(options(patch)).map((section) => section.id)
expect(ids({})).not.toContain('cloud-enterprise')
expect(ids({ isOrcaTeamAdmin: false })).not.toContain('cloud-enterprise')
const admin = ids({ isOrcaTeamAdmin: true })
expect(admin.slice(0, 4)).toEqual([
'cloud-billing',
'cloud-team',
'cloud-enterprise',
'cloud-secrets'
])
const section = buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })).find(
(entry) => entry.id === 'cloud-enterprise'
)!
expect(section).toMatchObject({ title: 'Enterprise', group: 'orca-cloud' })
// The audit log is found by searching for it; it no longer lives under Orca API keys.
const keywords = (id: string) =>
buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true }))
.find((entry) => entry.id === id)!
.searchEntries.flatMap((entry) => entry.keywords ?? [])
expect(keywords('cloud-enterprise')).toContain('audit log')
expect(keywords('cloud-api-keys')).not.toContain('audit log')
// Never without a control plane, whatever the role.
expect(ids({ isOrcaTeamAdmin: true, isOrcaCloudApiAvailable: false })).toEqual([])
})
it('is absent without a control plane and on the web client', () => {
expect(buildOrcaCloudSettingsSections(options({ isOrcaCloudApiAvailable: false }))).toEqual([])
expect(buildOrcaCloudSettingsSections(options({ isWebClient: true }))).toEqual([])
@@ -4,6 +4,7 @@ import {
} from '@/components/settings/cloud-api/cloud-api-search'
import {
getOrcaCloudBillingSearchEntries,
getOrcaCloudEnterpriseSearchEntries,
getOrcaCloudEnvironmentsSearchEntries,
getOrcaCloudLocalCommandsSearchEntries,
getOrcaCloudMcpSearchEntries,
@@ -13,11 +14,15 @@ import {
} from '@/components/settings/orca-cloud/orca-cloud-settings-search'
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { SettingsNavSection, SettingsNavTarget } from '@/components/settings/navigation/settings-navigation-types'
import type {
SettingsNavSection,
SettingsNavTarget
} from '@/components/settings/navigation/settings-navigation-types'
import type { OrcaProfileAuthStatus } from '../../../shared/orca-profiles'
import {
BookOpenText,
Boxes,
Building2,
CreditCard,
KeySquare,
LockKeyhole,
@@ -31,7 +36,7 @@ import { CLOUD_ROUTINES_UI_ENABLED } from '../../../shared/cloud-api/cloud-routi
/** One home for everything Orca Cloud; only builds with a control plane show it. */
export function buildOrcaCloudSettingsSections(
{ isWebClient, isOrcaCloudApiAvailable }: SettingsNavigationBuildOptions,
{ isWebClient, isOrcaCloudApiAvailable, isOrcaTeamAdmin }: SettingsNavigationBuildOptions,
t: Translate = translate
): SettingsNavSection[] {
if (isWebClient || !isOrcaCloudApiAvailable) {
@@ -61,6 +66,22 @@ export function buildOrcaCloudSettingsSections(
searchEntries: getOrcaCloudTeamSearchEntries(),
group
},
// Team owners and admins only; members and personal accounts never see the entry.
...(isOrcaTeamAdmin
? [
{
id: 'cloud-enterprise',
title: t('settings.enterprise.title', 'Enterprise'),
description: t(
'settings.enterprise.description',
'Single sign-on, sign-in policies and the audit log for your team.'
),
icon: Building2,
searchEntries: getOrcaCloudEnterpriseSearchEntries(),
group
}
]
: []),
{
id: 'cloud-secrets',
title: t('cloudSecrets.title', 'Cloud secrets'),
@@ -15,6 +15,7 @@ import {
useWindowsTerminalCapabilities
} from '@/runtime/hosts/capabilities/windows-terminal-capabilities'
import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host'
import { isActiveTeamAdmin } from '@/lib/orca-cloud/active-team-role'
import { useAppStore } from '@/store/index'
import { useProjectHostSetupProjection } from '@/store/projects/use-project-host-setup-projection'
import {
@@ -62,6 +63,7 @@ export function buildSettingsNavigationMetadata(
isDev = import.meta.env.DEV,
isLinearConnected = false,
isOrcaCloudApiAvailable = false,
isOrcaTeamAdmin = false,
repos,
projectGrouping
}: {
@@ -77,6 +79,7 @@ export function buildSettingsNavigationMetadata(
isDev?: boolean
isLinearConnected?: boolean
isOrcaCloudApiAvailable?: boolean
isOrcaTeamAdmin?: boolean
repos: readonly Repo[]
projectGrouping?: ProjectGroupingModel
},
@@ -112,6 +115,7 @@ export function buildSettingsNavigationMetadata(
isDev,
isLinearConnected,
isOrcaCloudApiAvailable,
isOrcaTeamAdmin,
repos,
projectGrouping
}
@@ -153,6 +157,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
const isWebClient = isWebClientLocation()
const isLinearConnected = useLinearProviderConnected()
const isOrcaCloudApiAvailable = useOrcaCloudApiAvailable()
const isOrcaTeamAdmin = useAppStore((state) => isActiveTeamAdmin(state.orcaProfileAuthStatus))
const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey(
settings?.activeRuntimeEnvironmentId
)
@@ -197,6 +202,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
isDev: import.meta.env.DEV,
isLinearConnected,
isOrcaCloudApiAvailable,
isOrcaTeamAdmin,
repos,
projectGrouping: {
projects: projectHostSetupProjection.projects,
@@ -218,6 +224,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] {
mobileEmulatorCreationEnabled,
isLinearConnected,
isOrcaCloudApiAvailable,
isOrcaTeamAdmin,
repos,
projectHostSetupProjection,
languageRevision,
+135 -12
View File
@@ -1478,6 +1478,124 @@
"title2": "Account & billing",
"accountOnly": "Account",
"accountOnlyDescription": "Your Orca account and what’s included with it."
},
"enterprise": {
"audit": {
"actor": {
"system": "Orca",
"youViaAgent": "Your agent",
"personViaAgent": "<person></person>’s agent",
"youViaRoutine": "Your routine",
"personViaRoutine": "<person></person>’s routine"
},
"filter": {
"categoryLabel": "Filter by activity",
"allActivity": "All activity",
"actorLabel": "Filter by person",
"everyone": "Everyone"
},
"title": "Audit log",
"description": "Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.",
"export": "Export CSV",
"upsellTitle": "Audit log is part of {{plan}}",
"upsellBody": "Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.",
"loading": "Loading the audit log",
"exportTruncated": "The file has the newest 5,000 events. Filter by activity or person to export the rest.",
"emptyFilteredTitle": "Nothing matches",
"emptyTitle": "Nothing recorded yet",
"emptyFilteredBody": "No recorded change matches these filters.",
"emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.",
"clearFilters": "Clear filters",
"showMore": "Show more",
"category": {
"members": "Members",
"security": "Security",
"apiKeys": "API keys",
"apps": "Connected apps",
"workspaces": "Workspaces",
"secrets": "Secrets",
"routines": "Routines",
"settings": "Settings",
"billing": "Billing",
"other": "Other"
},
"action": {
"keyCreateNamed": "Created API key “{{name}}”",
"keyRevokeNamed": "Revoked API key “{{name}}”",
"workspaceToken": "Gave an agent access to the Orca API",
"wsCreateNamed": "Started workspace “{{name}}”",
"wsDestroyNamed": "Deleted workspace “{{name}}”",
"wsSleepNamed": "Put workspace “{{name}}” to sleep",
"wsWakeNamed": "Woke workspace “{{name}}”",
"wsArchiveNamed": "Archived workspace “{{name}}”",
"wsUnarchive": "Restored a workspace",
"wsUnarchiveNamed": "Restored workspace “{{name}}”",
"wsRename": "Renamed a workspace",
"wsRenameNamed": "Renamed a workspace to “{{name}}”",
"wsShare": "Shared a link to a workspace port",
"wsShareNamed": "Shared a link to a port on workspace “{{name}}”",
"wsUnshare": "Turned off a workspace share link",
"wsUnshareNamed": "Turned off a share link on workspace “{{name}}”",
"wsAttachment": "Sent a file to an agent",
"sessionCreate": "Started an agent",
"sessionCancel": "Stopped an agent",
"sessionRename": "Renamed an agent session",
"sshAdd": "Added an SSH key to a workspace",
"sshAddNamed": "Added an SSH key to workspace “{{name}}”",
"sshRemove": "Removed an SSH key from a workspace",
"sshRemoveNamed": "Removed an SSH key from workspace “{{name}}”",
"projectCreateNamed": "Created project “{{name}}”",
"mcpAuthorize": "Connected an app",
"mcpRevoke": "Disconnected an app",
"mcpCodeReplay": "Blocked a reused app sign-in code and disconnected the app",
"mcpRefreshReplay": "Blocked a reused app token and disconnected the app",
"secretCreate": "Added a secret",
"secretCreateNamed": "Added secret {{name}}",
"secretUpdate": "Changed a secret",
"secretUpdateNamed": "Changed secret {{name}}",
"secretDelete": "Deleted a secret",
"secretDeleteNamed": "Deleted secret {{name}}",
"memberInvite": "Invited a teammate",
"memberInviteNamed": "Invited {{name}}",
"inviteRevoke": "Canceled an invite",
"inviteRevokeNamed": "Canceled the invite for {{name}}",
"memberRemove": "Removed a teammate",
"memberRemoveNamed": "Removed {{name}} from the team",
"memberLeft": "Left the team",
"roleChange": "Changed a teammate’s role",
"roleChangeNamed": "Changed the role of {{name}}",
"roleSync": "Updated a teammate’s API keys to match their role",
"teamCreate": "Created the team",
"teamRename": "Renamed the team",
"teamRenameNamed": "Renamed the team to “{{name}}”",
"routineCreate": "Created a routine",
"routineUpdate": "Edited a routine",
"routineDelete": "Deleted a routine",
"routineDisable": "Turned off a routine",
"routineRun": "Ran a routine",
"routineRunStart": "A routine run started",
"routineRunFinish": "A routine run finished",
"routineWebhook": "Replaced a routine’s webhook URL"
}
},
"sso": {
"upsellTitle": "Single sign-on is part of {{plan}}",
"upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM.",
"managedTitle": "Single sign-on is set up with us",
"managedBody": "To set up or change single sign-on, contact us."
},
"title": "Enterprise",
"searchDescription": "Single sign-on and directory sync, and the audit log of changes across your team.",
"keywordAudit": "audit log",
"keywordActivity": "activity",
"keywordSso": "sso",
"keywordSaml": "saml",
"keywordScim": "scim",
"keywordCompliance": "compliance",
"description": "Single sign-on, sign-in policies and the audit log for your team.",
"bannerTitle": "Single sign-on and the audit log are part of {{plan}}",
"bannerBody": "Talk to us about moving your team to {{plan}}.",
"contactUs": "Contact us"
}
},
"menu": {
@@ -2434,7 +2552,6 @@
"backgroundFailedCloud": "A new cloud workspace couldn’t start. Open it in the sidebar to continue."
},
"orcaCloudSession": {
"seePlans": "See plans",
"workspaceGone": "That workspace is no longer in Orca.",
"thisWorkspaceLower": "this workspace",
"sandboxRestartSoonDetail": "Cloud sandboxes restart after 24 hours. Files are kept and Orca resumes your agents after the restart.",
@@ -9018,19 +9135,26 @@
"adminOnly": "Only team owners and admins can change this."
},
"audit": {
"actionNamed": "{{action}} “{{name}}”",
"viaAgent": "Agent in a cloud workspace",
"youViaKey": "You via API key",
"personViaKey": "<person></person> via API key",
"youViaMcp": "You via MCP",
"personViaMcp": "<person></person> via MCP",
"you": "You",
"title": "Recent activity",
"description": "Key, workspace and settings changes across your team. Visible to admins only.",
"hide": "Hide",
"show": "Show activity",
"loading": "Loading activity",
"empty": "Nothing has happened yet."
"action": {
"keyCreate": "Created API key",
"keyRevoke": "Revoked API key",
"settings": "Changed agent access",
"wsCreate": "Started a workspace",
"wsDestroy": "Deleted a workspace",
"wsSleep": "Put a workspace to sleep",
"wsWake": "Woke a workspace",
"wsArchive": "Archived a workspace",
"wsMessage": "Messaged an agent",
"projectCreate": "Created a project",
"projectUpdate": "Edited a project",
"projectDelete": "Deleted a project",
"approval": "Made an app approval code"
}
},
"pageErrorDetail": "Keys, connected apps and team settings are unavailable until Orca Cloud answers.",
"retry": "Try again",
@@ -9104,7 +9228,8 @@
"rateLimited": "Too many requests. Try again in a minute.",
"network": "Couldn’t reach Orca Cloud. Check your connection and try again.",
"invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.",
"server": "Orca Cloud had a problem and nothing was changed. Try again in a moment."
"server": "Orca Cloud had a problem and nothing was changed. Try again in a moment.",
"planRequired": "Your team’s plan doesn’t include this."
},
"scope": {
"workspacesRead": "List and inspect workspaces",
@@ -9126,7 +9251,6 @@
"keywordToken": "token",
"keywordMcp": "mcp",
"keywordCli": "cli",
"keywordAudit": "audit log",
"keywordCloud": "cloud",
"sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets."
},
@@ -21332,7 +21456,6 @@
"upgrade": {
"askOwner": "Ask an owner or admin to upgrade.",
"bodyNoPrice": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop.",
"cta": "See plans",
"menuItem": "Upgrade to Orca Cloud",
"title": "Upgrade to use Orca Cloud",
"body3": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{price}} per seat per month."
@@ -120,11 +120,11 @@ export function installOrcaCloudSessionEvents(): () => void {
}
}
/** "See plans" on the create failure when the team has no plan with sandbox hours. */
/** "Compare plans" on the create failure when the team has no plan with sandbox hours. */
export function showOrcaCloudPlanUpsell(message: string): void {
toast.error(message, {
action: {
label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'),
label: translate('auto.components.billing.comparePlans', 'Compare plans'),
onClick: () => openPlansPage('sandbox')
}
})
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
import { activeTeamRole, isActiveTeamAdmin } from './active-team-role'
function status(patch: Partial<OrcaProfileAuthStatus> = {}): OrcaProfileAuthStatus {
return {
activeProfileId: 'p1',
configured: true,
state: 'connected',
persistence: 'encrypted',
cloud: {
cloudProfileId: 'cp',
userId: 'u1',
email: 'a@acme.dev',
activeOrgId: 'acme',
linkedAt: 1
},
organizations: [
{ orgId: 'acme', name: 'Acme', role: 'Owner' },
{ orgId: 'globex', name: 'Globex', role: 'member' }
],
...patch
}
}
describe('activeTeamRole', () => {
it("reads the role in the active team, whatever case auth's provider spells it in", () => {
expect(activeTeamRole(status())).toBe('owner')
expect(isActiveTeamAdmin(status())).toBe(true)
const admin = status({ organizations: [{ orgId: 'acme', name: 'Acme', role: ' ADMIN ' }] })
expect(activeTeamRole(admin)).toBe('admin')
expect(isActiveTeamAdmin(admin)).toBe(true)
})
it('is the role in the ACTIVE team, not any team the person owns', () => {
const inGlobex = status({ cloud: { ...status().cloud!, activeOrgId: 'globex' } })
expect(activeTeamRole(inGlobex)).toBe('member')
expect(isActiveTeamAdmin(inGlobex)).toBe(false)
})
it('treats an unknown or missing role as member, never as admin', () => {
for (const role of ['Billing manager', '', undefined]) {
const odd = status({ organizations: [{ orgId: 'acme', name: 'Acme', role }] })
expect(activeTeamRole(odd)).toBe('member')
expect(isActiveTeamAdmin(odd)).toBe(false)
}
expect(isActiveTeamAdmin(status({ organizations: [] }))).toBe(false)
})
it('has no team role on a personal account, signed out, or before auth has answered', () => {
expect(
activeTeamRole(status({ cloud: { ...status().cloud!, activeOrgId: undefined } }))
).toBeNull()
expect(activeTeamRole(status({ state: 'reconnect-required' }))).toBeNull()
expect(activeTeamRole(null)).toBeNull()
expect(isActiveTeamAdmin(undefined)).toBe(false)
})
})
@@ -0,0 +1,29 @@
import type { OrcaOrgRole, OrcaProfileAuthStatus } from '../../../../shared/orca-profiles'
/**
* The signed-in user's role in the team they are acting as; null when signed out or on a personal
* account. Auth reports provider role names, so anything but owner/admin reads as member. This
* only decides what the UI offers: the services check the role on every request.
*/
export function activeTeamRole(
status: OrcaProfileAuthStatus | null | undefined
): OrcaOrgRole | null {
if (status?.state !== 'connected') {
return null
}
const orgId = status.cloud?.activeOrgId
if (!orgId) {
return null
}
const raw = status.organizations
?.find((org) => org.orgId === orgId)
?.role?.trim()
.toLowerCase()
return raw === 'owner' || raw === 'admin' ? raw : 'member'
}
/** Owner or admin of the active team: who sees Settings › Enterprise. */
export function isActiveTeamAdmin(status: OrcaProfileAuthStatus | null | undefined): boolean {
const role = activeTeamRole(status)
return role === 'owner' || role === 'admin'
}
+70 -19
View File
@@ -84,27 +84,76 @@ export const CloudApiSettingsSchema = z.object({
})
export type CloudApiSettings = z.infer<typeof CloudApiSettingsSchema>
export const CloudApiAuditEntrySchema = z.object({
id: Id,
actor: z.object({
type: z.string().max(64),
id: z.string().max(128),
userId: z.string().max(128).nullable(),
userName: PersonName
}),
action: z.string().max(128),
target: z.object({ type: z.string().max(64), id: z.string().max(128).nullable() }).nullable(),
// Free-form per action (e.g. a key's name); only short strings are ever shown.
detail: z.record(z.string(), z.unknown()).nullable().optional(),
createdAt: Millis
})
export type CloudApiAuditEntry = z.infer<typeof CloudApiAuditEntrySchema>
// The team audit log (GET /v1/audit-log): every recorded change in one feed. Owners and admins of
// a team whose plan includes it. Unknown categories, actor kinds and actions from a newer control
// plane still parse; the renderer words what it knows and shows the rest by its machine name.
export const CLOUD_AUDIT_CATEGORIES = [
'members',
'security',
'api_keys',
'apps',
'workspaces',
'secrets',
'routines',
'settings',
'billing',
'other'
] as const
export type CloudAuditCategory = (typeof CLOUD_AUDIT_CATEGORIES)[number]
export const CloudApiAuditPageSchema = z.object({
entries: z.array(CloudApiAuditEntrySchema).max(500),
nextBefore: Millis.nullable()
export const CloudAuditActorSchema = z.object({
kind: z.enum(['person', 'former_member', 'system']).catch('person'),
userId: z.string().max(128).nullable(),
name: PersonName,
via: z.enum(['api_key', 'mcp', 'agent', 'routine']).nullable().catch(null)
})
export type CloudApiAuditPage = z.infer<typeof CloudApiAuditPageSchema>
export type CloudAuditActor = z.infer<typeof CloudAuditActorSchema>
export const CloudAuditEventSchema = z.object({
id: Id,
at: Millis,
category: z.enum(CLOUD_AUDIT_CATEGORIES).catch('other'),
action: z.string().max(128),
actor: CloudAuditActorSchema,
target: z
.object({
type: z.string().max(64),
id: z.string().max(256).nullable(),
label: z.string().max(256).nullable().optional()
})
.nullable(),
// Free-form per action; only short strings and numbers are ever shown. Never a secret value.
detail: z.record(z.string(), z.unknown()).nullable().optional()
})
export type CloudAuditEvent = z.infer<typeof CloudAuditEventSchema>
export const CloudAuditLogPageSchema = z.object({
events: z.array(CloudAuditEventSchema).max(500),
nextCursor: z.string().max(128).nullable(),
/** Everyone who appears in the log, for the person filter; sent with the first page only. */
actors: z.array(CloudAuditActorSchema).max(500).optional()
})
export type CloudAuditLogPage = z.infer<typeof CloudAuditLogPageSchema>
export const CloudAuditLogQuerySchema = z
.object({
category: z.enum(CLOUD_AUDIT_CATEGORIES).optional(),
/** A user id, or `system`. */
actor: z
.string()
.regex(/^[A-Za-z0-9_.:-]{1,128}$/)
.optional(),
cursor: z
.string()
.regex(/^\d{1,16}\.[A-Za-z0-9_-]{1,80}$/)
.optional(),
limit: z.number().int().min(1).max(200).optional()
})
.strict()
export type CloudAuditLogQuery = z.infer<typeof CloudAuditLogQuerySchema>
/** A CSV export: the text, and whether more events exist past the export's cap. */
export type CloudAuditLogExport = { csv: string; truncated: boolean }
export const CloudApiProjectSchema = z.object({
id: Id,
@@ -148,6 +197,8 @@ export const CLOUD_API_ERROR_CODES = [
'signed-out',
/** Signed in, but this action needs an org owner/admin. */
'admin-required',
/** The team's plan does not include this (detail: the entitlement, e.g. `audit_log`). */
'plan-required',
/** The key or token lacks a scope. */
'forbidden',
'not-found',