Skip to main content

Security model

Synced from bicycle-studio-api

Source: docs/architecture/security.md at origin/platform (c2ce9cf), synced 2026-09-26. Do not edit this page here; change the source and run yarn sync:studio.

Who can call what, as the code on platform enforces it, plus what preview is configured with and what is known to be open. Verified against Studio a78212f, data-app-runtime 0583b0c, studio-snapshot-worker 2241304 and the live preview deployments (read-only kubectl). No secret values appear here; secrets are named by Kubernetes secret and key.

1. Identities​

Every request to Studio resolves to exactly one principal (auth/resolver.py, in this order). Proxied headers such as X-Bicycle-Tenant-Id are never an identity: a mismatch with the resolved principal is logged as a security event.

PrincipalHow it is establishedUsed forNotes
User session (SessionPrincipal)the session cookie → tenant-service /api/session/validate; carries the session's own run-as credential (downstream_authorization)The UI, everything a person does; the only principal that counts as "a person" for gated publishes and on_success: sendCached BSA_SESSION_CACHE_TTL_SECONDS (30 s)
Platform token (PlatformTokenPrincipal)Authorization: Basic or Bearer → token-service /api/token/validateService-to-service calls, hand-made API tokens, the runtime's callbacksA token whose login is BSA_FUNCTIONS_RUNTIME_SERVICE_LOGIN (runtime-server) is the "runtime principal" that may call /api/internal/runtime-caps/*. A forwarded session credential is mapped back to the person through fwd-cred:{sha256} (12 h)
OAuth token (OAuthPrincipal, bso_…)Bearer → token-service /api/oauth/introspect; scopes = granted ∩ the user's roleRemote MCP clients (Claude Desktop, Claude Code)Never gets the staff view. The protected-resource metadata advertises only `apps:read
Tenant service identity (support session)Studio's own mounted service token (BSA_SERVICE_TOKEN_FILE, <mounted token directory>) → tenant-service POST /api/tenants/v2/support-login/{tenant} → a session that must resolve to support@bicycle.ai (BSA_SNAPSHOT_SUPPORT_LOGIN) in that tenantThe snapshot worker's render browser; every service-mode action Studio performs for a tenant: workflow emails, tool-service actions, wf-run/1 and rt-invoke/1 grant exchanges, the runner's query callbacks, invocation recordsIt is a SessionPrincipal of the support user, cached 240 s (runtime_broker/identity.py). It is excluded from the tenant-superuser rule, so it reads only what is shared with the whole tenant
Bicycle staffA SessionPrincipal whose tenant_domain == "bicycle" and login ends with @bicycle.ai (permissions/visibility.py)Cross-tenant read, the global library, agent-limits adminAn @bicycle.ai user logged into another tenant's domain is an ordinary user of that tenant

There is no ServicePrincipal class. The "runner principal gate" described in the design is implemented as: the runner's calls to Studio carry no tenant credential at all, only the run grant (next section).

2. Grants (signed by Studio, verified by everyone else)​

Every grant Studio mints is a JWS compact token, alg: EdDSA (Ed25519), header {kid, typ}, sorted-key JSON claims (auth/envelope.py). Only Studio holds the private key: BSA_GRANT_SIGNING_KEY_FILE from the grant-key secret (preview: generated by the chart; prod: no key is mounted, so nothing that needs a grant works in prod yet). The public keys are published at GET /.well-known/bicycle-grants.json. Studio itself verifies with its local key set plus BSA_GRANT_PUBLIC_KEYS_EXTRA (for rotation), not by fetching the JWKS.

typMinted whenAudienceCarried asLifetimeVerified by
wf-run/1A workflow run starts on the workflow-runnerworkflow-runnerX-Run-Grant on every runner → Studio internal call6 h + the longest approval timeout, capped at 35 d + 6 hStudio (workflows/runner_router.py): signature, exp, tenant and workflow in the path, and the jti bound to the run record (wf/{t}/{wf}/grants/{jti}.json). Claims carry scopes {queries, models, destinations, kinds, functions, nodes, actions}; a send to an unscoped destination or action is 403
rt-invoke/1The broker places a function invocationruntime-serverThe placement request; X-Invoke-Grant on service-mode capability callbackstimeout + 300 sruntime-server (via BICYCLE_RT_GRANT_JWKS_URL → Studio's JWKS); Studio replays are refused per jti:{jti}:{seq}
caller/1Every Studio → agent-service callagent-serviceX-Bicycle-Caller-Assertion60 s, one jti per requestNot verified by agent-service today (the verification MR was reverted for redesign; see §5)
agent-env/1Every agent run Studio submitsagent-serviceoptions.limits_grant in the run request300 sagent-service verifies and clamps the limits; an unverifiable envelope means defaults plus limits_unverified
app-grant/1, wf-render/1Reserved in the type list; nothing mints them yet————

Two older HMAC grants remain (X3 lets them stay until each is migrated):

  • Snapshot run grant (X-Snapshot-Grant, data_apps/grants.py): HMAC-SHA256 over {tenant_id, app_id, schedule_id, run_id, exp}; a cron grant lives BSA_SNAPSHOT_CRON_GRANT_DAYS (400 d) and is re-minted whenever the cron is recreated; a one-shot grant BSA_SNAPSHOT_RUN_GRANT_SECONDS (3600 s). The key is BSA_SNAPSHOT_GRANT_KEY or derived from the service token. The worker trades it at POST /api/snapshots/session for a support session; the route is rate-limited per grant and per IP.
  • Unsubscribe token (data_apps/unsubscribe.py): HMAC, BSA_UNSUBSCRIBE_TOKEN_TTL_DAYS (90 d).

The runner keeps each run's wf-run/1 grant in clear on runs.run_grant in its Postgres for the run's life; Studio's RunnerClient authenticates to the runner with Authorization: Bearer <token>, which the runner checks for presence only (contract v1).

3. Internal routes and their gates​

RouteGate
POST /api/snapshots/sessionNo Authorization; body {grant} (HMAC snapshot grant, or a wf-run/1 / rt-invoke/1 grant at /api/internal/runtime-session); the grant is checked against the tenant's own records before a session is returned
/api/internal/snapshots/{app}/… (links, artifacts, run.json, summary, deliver)A session of the support user and X-Snapshot-Grant naming the session's tenant, the path's app and (one-shot) the run
POST /api/internal/workflows/{wf}/ticksThe same gate; the cron grant's "app" is the workflow
POST /api/internal/runtime-sessionNo Authorization; the grant type decides: wf-run/1 → the run's tenant support session, rt-invoke/1 → the invocation's; rate-limited per grant jti
POST /api/internal/runtime-caps/{cap}A run-as platform token whose login is runtime-server and X-Invoke-Grant (jti-per-sequence replay check)
POST /api/internal/workflows/{wf}/runs/{run}/query, …/events, …/functions/{node}:submit, GET …/functions/{inv}, POST …/snapshots/{node}, POST …/actions/{approval}:send, POST /api/internal/workflows/approvals/{id}/notifyX-Run-Grant only; no Authorization header at all

4. Visibility and authorisation (#23)​

  • Scopes: apps:read|write|publish|admin and functions:read|write|publish|invoke (permissions/scopes.py), mapped from tenant roles (permissions/roles.py; a Viewer holds apps:read + functions:read + functions:invoke). Workflows ride the apps:* scopes.
  • may_see (permissions/visibility.py): staff see everything; another tenant's object is invisible (callers answer 404, never 403); an object without domain_id is tenant-wide; otherwise its domain must be in the caller's semantic catalog (semantic.list_models; an outage narrows, never widens).
  • Functions: private audience = owner or Admin; disabled functions are listed only with state=disabled.
  • Workflows: a foreign tenant, a foreign domain and a draft a read-only viewer may not see are all 404; write = any editor who can see the domain; publish = write + apps:publish and a person when the revision has gated changes; disable/enable = the creator, a tenant Admin or an @bicycle.ai editor.
  • Apps: require_app → scope → 404 → 403. Publishing grants read to the whole tenant.
  • Invocations: same tenant and (the caller, or functions:write, or anyone who may read the workflow for workflow runs); other tenants 404.
  • Tenant superuser (permissions/app_permissions.py): apps:admin, or apps:write with an @bicycle.ai login; the support login is excluded.
  • Agent limits: tenants read; only staff write (audited).

5. Allowlists and caps on preview​

ControlPreview valueEffect
BSA_WORKFLOW_MAIL_ALLOWLIST["<email>"]Every workflow send, approval mail, failure report and destination PUT: an unlisted address is withheld (send) or 422 (save). Prod: unset (no allowlist)
BSA_WORKFLOW_ACTIONS_ALLOWED[]No tool-service action runs without a person on this deployment, whatever a tenant admin marks in wf/{t}/_policy/actions.json. "*" hands the decision to the tenant policy. Default [] in code, so prod is closed until an operator opens it
Tenant mail policy wf/{t}/_policy/mail.jsondefault external: approveAn outside recipient needs a manual send and a gated publish; deny refuses at save; allow warns
BSA_FUNCTIONS_TENANTS, BSA_WORKFLOWS_TENANTS, BSA_AGENT_RUNS_TENANTS, BSA_ANALYSIS_TENANTS, BSA_EVALS_TENANTS, BSA_LOOKUPS_TENANTS["*"]Every tenant on preview; each is unset (feature off) in prod
BSA_FUNCTIONS_SYNC_TENANTS["*"]Synchronous :invoke allowed for every tenant (against the design's "on-demand nodes first")
BICYCLE_RT_UNSANDBOXED_EXEC / _TENANTS (runtime-server)false / [<internal test tenants>]The unsandboxed code executor is off; if ever on, only those internal tenants and never in prod (the code refuses)
Function quotas (functions/settings.py)CU per day: tenant 20 000, runtime 5 000, function 2 000; concurrency: tenant 8, runtime 16, function 8; package ≤ 8 MiB; fn.call depth ≤ 3Enforced by the broker in Redis (bsa-rt:conc:*, bsa-rt:cu:*)
Agent limits (agent_runs/limits.py)defaults 30 steps / 80 tool calls / $2.00 / 600 s; ceiling 100 / 300 / $10 / 1800 s; max_concurrent_runs 4 (ceiling 20); max_runs_per_day 200 (ceiling 5 000)Signed into every run as agent-env/1; Studio's slot and daily budget read the same doc. agent-service's own env: AGENT_RUNS_TENANT_INFLIGHT=4, AGENT_RUNS_GLOBAL_INFLIGHT=20, AGENT_RUNS_TENANT_DAILY=200
Agent queueBSA_AGENT_RUNS_QUEUE_MAX_DEPTH 50 per tenant, BSA_AGENT_RUNS_QUEUE_TTL_SECONDS 600Beyond the depth: 429 agent_queue_full; a queued run times out to failed: queue_timeout
Detect/Explainglobal 4 detect / 2 explain, per tenant 2 / 1, BSA_ANALYSIS_DAILY_BUDGET 50, BSA_ANALYSIS_MAX_QUEUED 20, timeout 1 800 sRedis slots and budget (studio:de:*)
Evalsbudgets llm $1 / agent $5 / code $1 per run, 10 runs per tenant per day, 2 live runsHidden in the UI (SHOW_EVALS=false); API, MCP and the publish gate stay live
Workflow sendsmax_sends_per_run per node; WF_RUNNER_SEND_DAILY_CAP 500 recipients per tenant per UTC day; ≤ 50 recipients per send; approval timeout ≤ P14DEnforced by the workflow-runner
App cache1 000 keys per app, 64 KiB per value, 300 ms per operationdata_apps/cache.py
Sandbox namespaces (fn-pool, wf-jobs-preview)ResourceQuota fn-pool-quota: 200 pods, requests 4 CPU / 8 GiB, limits 8 CPU / 16 GiB; PSA restricted; deny-all-egress plus allow-egress-runtime-server-and-dns (runtime-server:8080 and DNS only)Applied by SRE; the bicycle-runtime-server service account holds a RoleBinding to create/delete pods, jobs and configmaps there
MCPBSA_MCP_ALLOWED_HOSTS / _ORIGINS (preview host and the in-cluster service name)DNS-rebinding protection on the streamable-HTTP transport