Multi-tenancy
How one CID222 deployment serves several customers, teams or departments, how credentials resolve, and where the isolation actually holds.
- Version: 0.4
- Role: admin_user, normal_user, viewer
One CID222 deployment serves many tenants. A tenant is a principal — a customer, a department, a service account — with its own sign-in, its own provider credentials, its own data, and a policy that can differ from everyone else's.
The problem
Governance is per-organisation, but running a gateway per organisation is unaffordable: each copy carries its own database, its own model containers and its own 30 GB of memory. Sharing one deployment means a mistake in scoping is a cross-customer data leak, and shared provider keys make it impossible to say who spent what. The tenant model exists so a shared deployment behaves like separate ones on every path a customer can observe.
How CID222 does it
The tenant
A tenant carries its own sign-in credentials, its own provider credentials, its own sessions and
detections, and a role that decides what it may do. Roles are exactly superadmin, admin_user,
normal_user, viewer and auditor. The role is read from the database on every request, not
taken from the token, so a demotion takes effect at once.
Content policy is a separate axis. Filter rules are org-wide rows with no tenant column; what varies per tenant is the action each rule takes, set through an override.
Tenant groups
A tenant group collects tenants that should share something. A group has a name, an optional
description and an owner_tenant_id — the tenant that owns it, which is the authority checked
before anyone reads or writes that group's policy.
Groups do two things:
- Share a provider credential. One key serves every member, so a department does not need its own contract with the provider.
- Carry a policy scope. An override attached to a group applies to every member, which is how a department gets a different action for a rule without editing the rule.
Credential resolution
When a tenant calls a provider, the gateway resolves the credential in two tiers:
- The tenant's own credentials. An active credential on the tenant wins.
- The tenant's groups. Otherwise, an active credential on any group the tenant belongs to.
GET /models applies the same resolution to build the catalog, which is why a tenant with no
credential anywhere sees an empty model list rather than a list it cannot call.
A credential row belongs to exactly one of the two. The ai_credentials table carries a database
constraint that tenant_id is set and tenant_group_id is null, or the reverse — never both,
never neither.
How the isolation is enforced
Isolation is enforced in the application, on every path, by the same scope service:
- Query scoping. Reads resolve the caller's scope first and constrain the query to it. A tenant id supplied by the client is intersected with that scope, so naming someone else's tenant narrows your results rather than widening them.
- Ownership on groups. Reading a group's policy needs membership; changing it needs ownership. Deciding that a whole department stops being scanned is authority a team leader has over their own team and nobody has over another.
- Deliberate response polarity. An out-of-scope tenant answers 403 — a named user's existence is not the secret. An out-of-scope group answers 404 — a department's existence is.
Policy precedence
Where several scopes have an opinion about the same rule, they resolve in this order:
| Level | Scope | Precedence |
|---|---|---|
| Base rule | Every tenant | Lowest |
| Tenant group | The group's members | Middle |
| Tenant | One tenant | Highest |
A tenant-level override wins outright. Where a tenant belongs to several groups, the most
restrictive of their overrides applies, and the fold is order-independent. An override can only
change a rule's action — allow switches the rule off for that scope — and can never add a rule,
remove one, or change what it matches.
How secrets are stored
Provider API keys are encrypted at rest with AES-256-GCM and stored with a v1: prefix. Gateway
API keys are stored as an unsalted SHA-256 hash; the key itself is 32 bytes from a cryptographic
random source, so there is no low-entropy guess space for a salt to protect. A gateway API key may
carry an expires_at, and a null value means it never expires.
Limits and known gaps
- A group API key borrows a member's identity. A key issued to a tenant group acts as that group's earliest-added member. Removing that user does not revoke the key — it transfers the key's identity to the next-earliest member, and everything the key writes is then attributed to a different person. A service identity of its own is designed but not shipped. Until it is, audit a group key by the group, not by the name on its events.
- Isolation is application-level, not database-level. There is no PostgreSQL row-level security policy anywhere in the schema. Every query is scoped by the application, consistently and in one place, but a code path that forgets to ask the scope service is not stopped by the database. Treat "cross-tenant access is impossible" as a statement about the code, not about the storage engine.
- There are no per-tenant rate limits or token quotas. Nothing meters a tenant's requests or
spend at the gateway.
/chat/completionsand the analysis endpoints have no limiter at all, and a group does not pool a quota because there is no quota to pool. Cost is reported after the fact, not enforced ahead of it. - A policy override with no tenant is a no-op. The override pass is guarded on a resolved tenant. Two ingress paths still cannot resolve one, and on those the base action stands however the department was configured.
- Group membership is cached for 30 seconds. Moving a tenant between groups takes effect for policy resolution when the cache expires, unless the writer invalidated it. Do not assume a membership change is instantaneous across every path.
- A legacy credential row can still be plaintext. Encryption applies to values written since
the transformer landed; rows written before it pass through unchanged. Check
api_keyfor thev1:prefix before assuming a deployment is clean.
Related
- Architecture — where tenant resolution sits in the request path.
- Models and providers — what a tenant's credentials make visible.
- The content safety pipeline — the rules that overrides re-aim.
Last updated on