mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge cloud/integration into SEC-6 (KMS unwrapper; kms_unavailable log via secretLog)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user