Security model
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.
| Principal | How it is established | Used for | Notes |
|---|---|---|---|
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: send | Cached BSA_SESSION_CACHE_TTL_SECONDS (30 s) |
Platform token (PlatformTokenPrincipal) | Authorization: Basic or Bearer → token-service /api/token/validate | Service-to-service calls, hand-made API tokens, the runtime's callbacks | A 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 role | Remote 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 tenant | The 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 records | It 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 staff | A SessionPrincipal whose tenant_domain == "bicycle" and login ends with @bicycle.ai (permissions/visibility.py) | Cross-tenant read, the global library, agent-limits admin | An @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.
typ | Minted when | Audience | Carried as | Lifetime | Verified by |
|---|---|---|---|---|---|
wf-run/1 | A workflow run starts on the workflow-runner | workflow-runner | X-Run-Grant on every runner → Studio internal call | 6 h + the longest approval timeout, capped at 35 d + 6 h | Studio (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/1 | The broker places a function invocation | runtime-server | The placement request; X-Invoke-Grant on service-mode capability callbacks | timeout + 300 s | runtime-server (via BICYCLE_RT_GRANT_JWKS_URL → Studio's JWKS); Studio replays are refused per jti:{jti}:{seq} |
caller/1 | Every Studio → agent-service call | agent-service | X-Bicycle-Caller-Assertion | 60 s, one jti per request | Not verified by agent-service today (the verification MR was reverted for redesign; see §5) |
agent-env/1 | Every agent run Studio submits | agent-service | options.limits_grant in the run request | 300 s | agent-service verifies and clamps the limits; an unverifiable envelope means defaults plus limits_unverified |
app-grant/1, wf-render/1 | Reserved 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 livesBSA_SNAPSHOT_CRON_GRANT_DAYS(400 d) and is re-minted whenever the cron is recreated; a one-shot grantBSA_SNAPSHOT_RUN_GRANT_SECONDS(3600 s). The key isBSA_SNAPSHOT_GRANT_KEYor derived from the service token. The worker trades it atPOST /api/snapshots/sessionfor 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
| Route | Gate |
|---|---|
POST /api/snapshots/session | No 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}/ticks | The same gate; the cron grant's "app" is the workflow |
POST /api/internal/runtime-session | No 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}/notify | X-Run-Grant only; no Authorization header at all |
4. Visibility and authorisation (#23)
- Scopes:
apps:read|write|publish|adminandfunctions:read|write|publish|invoke(permissions/scopes.py), mapped from tenant roles (permissions/roles.py; a Viewer holdsapps:read+functions:read+functions:invoke). Workflows ride theapps:*scopes. may_see(permissions/visibility.py): staff see everything; another tenant's object is invisible (callers answer 404, never 403); an object withoutdomain_idis 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:publishand a person when the revision has gated changes; disable/enable = the creator, a tenant Admin or an@bicycle.aieditor. - Apps:
require_app→ scope → 404 → 403. Publishing grantsreadto 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, orapps:writewith an@bicycle.ailogin; the support login is excluded. - Agent limits: tenants read; only staff write (audited).
5. Allowlists and caps on preview
| Control | Preview value | Effect |
|---|---|---|
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.json | default external: approve | An 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 ≤ 3 | Enforced 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 queue | BSA_AGENT_RUNS_QUEUE_MAX_DEPTH 50 per tenant, BSA_AGENT_RUNS_QUEUE_TTL_SECONDS 600 | Beyond the depth: 429 agent_queue_full; a queued run times out to failed: queue_timeout |
| Detect/Explain | global 4 detect / 2 explain, per tenant 2 / 1, BSA_ANALYSIS_DAILY_BUDGET 50, BSA_ANALYSIS_MAX_QUEUED 20, timeout 1 800 s | Redis slots and budget (studio:de:*) |
| Evals | budgets llm $1 / agent $5 / code $1 per run, 10 runs per tenant per day, 2 live runs | Hidden in the UI (SHOW_EVALS=false); API, MCP and the publish gate stay live |
| Workflow sends | max_sends_per_run per node; WF_RUNNER_SEND_DAILY_CAP 500 recipients per tenant per UTC day; ≤ 50 recipients per send; approval timeout ≤ P14D | Enforced by the workflow-runner |
| App cache | 1 000 keys per app, 64 KiB per value, 300 ms per operation | data_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 |
| MCP | BSA_MCP_ALLOWED_HOSTS / _ORIGINS (preview host and the in-cluster service name) | DNS-rebinding protection on the streamable-HTTP transport |