---
title: "Tenancy and identity"
description: "How workspace_id isolates tenants, and why the credential that authenticates to Obol is never the credential that authenticates to a vendor."
---
Obol has three separate identities that are never interchangeable, and one scoping key that appears on every stored row. Getting these two ideas right explains most of the system's shape.
## The tenancy hierarchy
```text Hierarchy
organization plan, Stripe customer
└── workspace slug, env (dev | stage | prod)
├── virtual keys ob_test_… / ob_live_…
├── connectors → connector bundles → connections
├── policies Cedar source → compiled revisions
├── approval policies who may review a held call
├── memberships human operators and their roles
└── receipts, usage, audit log
```
The organization is a logical level, not a table: `workspaces.org_id` is a plain column, and org-level commercial state lives on `billing_accounts`, anchored to a designated billing workspace.
A workspace carries an environment: `dev`, `stage`, or `prod`. The environment is part of both visibility and authorization, and the rule is absolute — **a dev key can never reach a prod connection.**
## `workspace_id` is the isolation boundary
Every Postgres row and every Redis key carries `workspace_id`. That is tenant isolation; there is no second mechanism (ADR-0006).
```text Scoping
Postgres virtual_keys.workspace_id, connections.workspace_id,
receipts.workspace_id, receipt_revisions.workspace_id,
usage_events.workspace_id, audit_log.workspace_id, …
Redis key:{sha256} → KeyContext (carries workspace_id)
spend:{key_id}:{yyyyMM}
policy:{workspace_id}
snap:workspace:{id}
idem:{workspace_id}:{connection_or_unresolved}:{tool_hash}:{key}
stream:invocation-audit:{workspace_id}
```
Two consequences follow:
- **Snapshots are per-workspace.** Control compiles one bundle of keys, connections, tools, and the Cedar policy set for each workspace. The gateway and control never read another workspace's activation, even for a shared publisher artifact: under ADR-0020 enabling one materializes an immutable, content-addressed bundle row and snapshot entry for *that* workspace. That materialization is unbuilt — there is no `connector_bundles` table, and control reads a reviewed pack off disk instead.
- **Control's API is not an oracle for workspace ids.** On `/api/v1/workspaces/{workspace_id}/…`, membership resolves before the action, so a non-member receives the same `404` that a non-existent workspace returns. A `403` is only ever seen by a member whose role lacks the action.
## Three authentications
A Clerk session identifies an operator in the browser (ADR-0033). Account creation is invitation-gated by the Clerk instance.
After authentication, control still owns workspaces, membership, and every `workspace_id`: each `/api/v1` route verifies the session against the issuer's published JWKS, resolves membership, and applies one named action — never a role, org, or claim taken from the browser.
A dashboard session never becomes a virtual key or an MCP JWT, never reaches the gateway, and never carries an authorization decision. Clerk holds no vendor secret and no key-encryption material.
With no issuer configured, control's operator routes answer `503` and the dashboard refuses to serve. Fail-closed is the specified behavior.
A virtual key or an MCP OAuth 2.1 JWT identifies a caller at the gateway.
Virtual keys are `ob_test_` or `ob_live_` followed by 32 random bytes. Obol stores the SHA-256 hash only — high entropy plus rate limiting makes that sufficient — and shows the secret once.
IDE clients use MCP OAuth 2.1 with PKCE and dynamic client registration. Control, or a fronted identity provider, is the authorization server; the gateway only validates the JWT's `iss`, `aud`, and scopes.
A vaulted customer credential authenticates to the vendor, and it is unwrapped only inside the gateway process.
Native custody uses envelope encryption: an AES-256-GCM data key per connection, wrapped by a key-encryption key held in AWS or GCP KMS, or in age plus SOPS for [Enterprise self-host](/get-started/self-hosting). Ciphertext lives in Postgres; the plaintext token goes onto the outbound socket and is dropped. Logs carry the connector id and last four characters only.
Federated custody stores no vendor token at all — only a workspace-scoped broker credential and an upstream reference.
These three never convert into one another. A browser session cannot be exchanged for a virtual key; a virtual key cannot be exchanged for a vendor token; a vendor token never travels back toward the caller.
## Inbound versus outbound, concretely
```text Credential flow
agent ──Bearer ob_live_…──▶ gateway ──Authorization: ──▶ vendor
│
└── unwrap DEK (KMS-wrapped KEK) in-process,
inject on the outbound hop, drop the plaintext
```
| | Inbound | Outbound |
|---|---|---|
| Held by | The agent, an IDE, an application | The vault; unwrapped only in the gateway |
| Form | `ob_live_…` virtual key, or an MCP OAuth JWT | Customer OAuth token, restricted vendor key, BYOK model key |
| Stored as | SHA-256 hash | AES-256-GCM ciphertext plus a wrapped data key |
| Scoped to | Workspace, models, tools, budget, RPM/TPM, env, expiry | One connection |
| Revoked by | Deleting the key; both planes fail closed within ~60s | Revoking the connection, or revoking at the vendor |
| Ever visible to the web app | Yes (the agent's own key is its own) | Never |
| Ever visible to a worker | Never — a worker would see a per-call service JWT, not an `ob_live_` key | Never under ADR-0024; **on the shipped ADR-0014 path, yes** — see below |
ADR-0024 requires that a worker never see a vendor credential and never call a vendor: it returns a bounded, bundle-declared request plan, and the gateway validates it, injects the credential, and performs the HTTP itself. **That protocol is not implemented** — the shipped `WorkerExecutor` still runs ADR-0014's hand-off and would send the credential to a worker in `x-obol-upstream-authorization`. No worker exists to receive it, so no credential crosses that hop today. The tier is parked (ADR-0058). See [Trusted workers](/connectors/trusted-workers).
## Custody modes
A connection records how the vendor credential is held.
| Custody | Where the vendor credential lives | Evidence ceiling |
|---|---|---|
| `obol` (native custody) | Obol's vault, encrypted per connection | `gateway_observed`, upgradeable by direct readback or a signed vendor webhook |
| `federated` | The catalog broker or remote MCP server owns it; Obol never imports or forwards it | `untrusted` or `broker_attested`; can never reach a `verified` conclusion |
Even under federated custody, invariant 8 holds on the vendor-facing identity: every broker account and broker OAuth client is workspace-scoped. Pooled platform-wide broker keys and a shared broker OAuth app are forbidden.
Hybrid custody — Obol vaulting a read-only credential alongside a broker-held write credential, which is the only path by which a federated write reaches `verified` — is explicitly deferred to phase 2 and requires amending the connection contract. It does not ship today.
## What a virtual key carries
A key is not merely an authenticator; it is the scope object.
| Field | Effect |
|---|---|
| `allowed_models[]` | CEL prefilter for model visibility, then a Cedar `complete` decision |
| `allowed_tools[]` | CEL prefilter for tool visibility; the tool never appears in `tools/list` if it is not callable |
| `budget_usd_month` | Redis spend counter; admission fails closed when exhausted |
| `rpm`, `tpm` | Redis rate counters |
| `expires_at`, `revoked_at` | Admission checks; revocation purges `key:{hash}` and propagates in ~60s |
| `scopes` | Which surfaces the key may use |
Keys can also run in **progressive mode**, exposing only `search_tools` and `call_tool` instead of the full catalog, which keeps large tool schemas out of the model's context window.
See [Virtual keys](/security/virtual-keys) for minting, rotation, and revocation.
## Human roles are control-owned, not Cedar
Cedar authorizes agent traffic. Operator permissions in the dashboard are a separate, control-owned mechanism over a closed role vocabulary (ADR-0037).
| Role | Adds |
|---|---|
| `viewer` | Reads only |
| `developer` | Key management, policy drafting |
| `admin` | Connection management, policy and routing publication, approval decisions |
| `owner` | Membership management and approval-policy management |
An unknown stored role fails closed. Every workspace-scoped operation applies exactly one of eleven named actions — `workspace.read`, `key.manage`, `policy.draft`, `policy.publish`, `connection.manage`, `routing.publish`, `approval.decide`, `approval.policy.manage`, `audit.read`, `usage.read`, `membership.manage` — and the test inventory is derived from the generated OpenAPI paths rather than hand-listed, so a new route fails the suite until someone assigns it an action.
A matching approval policy can further restrict whether an admin or owner may decide a particular held call. Approval permission never substitutes for Cedar tool permission.
Minting, scoping, and revocation.
Envelope encryption and KEK providers.
Native custody, federated custody, and what each one can prove.
Rules 1, 5, and 8 in full.