Skip to main content
CID222 Docs

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:

  1. The tenant's own credentials. An active credential on the tenant wins.
  2. 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:

LevelScopePrecedence
Base ruleEvery tenantLowest
Tenant groupThe group's membersMiddle
TenantOne tenantHighest

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/completions and 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_key for the v1: prefix before assuming a deployment is clean.

Last updated on

On this page

Download PDF