# Multi-tenancy

Single-tenant by default — every existing OMCP deployment continues to
work without setting any `OMCP_*TENANT*` env vars. Anonymous principals,
local users without a `tenant` field, OIDC sessions without
`OMCP_OIDC_TENANT_CLAIM`, and MCP credentials without
`OMCP_KEY_TENANTS` all land in the universal `default` tenant. That's
the pre-E7 world.

Once an operator opts in via any of those knobs, OMCP becomes
multi-tenant: per-identity rate-limiter buckets, token-budget buckets,
audit entries, and catalog enrichments all scope by tenant. Cross-
tenant data is invisible to non-admins through both the `/api/*`
surface and the MCP tool layer.

> **Active multi-tenancy is an entitled control.** The single-tenant
> default — anonymous, basic, api-key, or OIDC without a tenant claim,
> everyone in `default` — is open-source and bit-for-bit unchanged.
> Configuring **non-default** tenants (an OIDC tenant claim, or
> `OMCP_KEY_TENANTS` mapping a credential to a non-default tenant)
> requires the `tenancy` entitlement. Without it the gateway **refuses
> to start (fail-closed)** with a clear message — rather than silently
> collapsing isolated tenants into one, which could merge data across
> tenant boundaries. See [enterprise-gate.md](enterprise-gate.md).
>
> Note: the `google` and `microsoft-entra` OIDC profiles ship a built-in
> tenant claim (`hd` / `tid`), so they count as actively multi-tenant even
> with `OMCP_OIDC_TENANT_CLAIM` unset. To run single-tenant on those
> profiles, set `OMCP_OIDC_TENANT_CLAIM=""` explicitly (or use the
> `generic` profile).

## How identities resolve to a tenant

| Identity path | Tenant comes from | Default when unset |
|---|---|---|
| Anonymous | n/a | `default` |
| Basic-mode local user | `tenant` field on the user file entry | `default` |
| OIDC session | `OMCP_OIDC_TENANT_CLAIM` dotted-path claim | `default` |
| MCP bearer credential | `OMCP_KEY_TENANTS="name=tenant;..."` env | `default` |

The tenant identifier is normalised to `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` —
strictly lowercased, trimmed, traversal-safe. Invalid claims silently
fall back to `default` (the audit chain still records the actual
identity, but the tenant is sanitised). The regex is a strict superset
of RFC 1123 k8s namespace identifiers — any valid k8s namespace passes.

## Per-identity storage

Both per-identity surfaces use a composite `<tenant> <principalId>`
key internally:

- **Rate limiter** (`OMCP_TOOL_RATE_PER_MIN`): two credentials named
  `agent` in tenants `acme` and `bigco` get independent per-minute
  buckets.
- **Token budget** (`OMCP_TOOL_DAILY_TOKENS`): same.

The surface field stays unchanged: `/api/usage` continues to return
`actor: "agent"` (split out from the composite) plus a new
`tenant: "acme"` column. Existing tooling reading `actor`/`count`/
`limit`/`windowMs`/`tokens` is unaffected.

## Audit chain

Every recorded entry carries a `tenant` field. Pre-E7 entries default
to `"default"` when filtered. `/api/audit?tenant=acme` filters; non-
admins (without `users:delete`) are silently scoped to their own
tenant — they cannot read another tenant's audit history.

## Service catalog

`ServiceCatalogEntry.tenant` is optional. When set, the entry is only
surfaced to callers in that tenant — through `/api/catalog`,
`/api/services`, `/api/health{,/:service}`, and the `list_services` /
`get_service_health` MCP tool enrichers. Pre-E7 catalog files without
a `tenant` field on entries continue to enrich `default`-tenant
callers (i.e., pre-E7 deployments).

```yaml
# config/catalog.yaml — multi-tenant example
services:
  acme-payments:
    owner: team-payments
    tier: tier-1
    onCall: https://acme.pagerduty/team-payments
    tenant: acme
  bigco-payments:
    owner: bigco-platform
    tier: tier-2
    tenant: bigco
  shared-cdn:
    owner: infra
    # no tenant → default → visible to anonymous / single-tenant callers
```

## Cross-tenant API model

| Endpoint | Non-admin behaviour | Admin (`users:delete`) behaviour |
|---|---|---|
| `/api/audit` | scoped to own tenant | all tenants by default; `?tenant=X` to drill down |
| `/api/usage` | scoped to own tenant | all tenants by default; `?tenant=X` to drill down |
| `/api/catalog` | scoped to own tenant | all tenants by default; `?tenant=X` to drill down |
| `/api/services` | catalog enrichment scoped to own tenant | (same — admins don't get a special tool here yet) |
| `/api/health{,/:service}` | catalog enrichment scoped to own tenant | (same) |

All four endpoints return a `scopedTo` field — `null` for an admin
viewing all tenants, the tenant string otherwise. The UI uses this
to render a "scope: acme" / "scope: all tenants" hint above the
relevant data block.

## UI surface

- **User badge** (top-right) gains a tag chip when the user's tenant
  is non-default. Tooltip combines IdP issuer (OIDC) + tenant.
- **\<body data-tenant=…>** attribute is set on every identity sync
  so per-tenant CSS theming (brand colour bar, banner) drops in
  without per-tenant builds.
- **Dashboard usage strip** gains a "scope: …" hint and a Tenant
  column when an admin views all tenants unscoped.

## Migration from single-tenant

No migration is required. The smallest opt-in is one user gaining a
`tenant` field:

```diff
 // OMCP_USERS_FILE
 {
   "users": [
-    { "username": "alice", "name": "Alice", "passwordHash": "..." }
+    { "username": "alice", "name": "Alice", "tenant": "acme", "passwordHash": "..." }
   ]
 }
```

Alice's session now writes audit entries tagged `tenant: acme`. Other
users without the field stay in `default`. Pre-E7 audit entries
continue to surface under `?tenant=default`. No data migration step,
no schema change, no replay.

## OIDC integration

```yaml
env:
  OMCP_OIDC_TENANT_CLAIM: "app.tenant_id"   # dotted path; default ""
```

When the claim is absent / empty / non-string in a given session, the
session lands in `default` (least-privilege fallback). Array-valued
claims take the first string entry — multi-tenant per-session
identities aren't supported; an operator wanting per-call switching
should mint distinct tokens.

## MCP credentials

```yaml
env:
  OMCP_API_KEYS: "agent:tok_acme,agent:tok_bigco"
  OMCP_KEY_TENANTS: "agent-acme=acme;agent-bigco=bigco"
  # Note: the credential NAME has to be unique per tenant for the
  # tracker buckets to map cleanly. Use distinct names.
```

Unlisted keys default to `default`. The same OMCP server can serve
multiple tenants over the same `/mcp` endpoint as long as each
credential has a unique name.

## What's not (yet) tenant-scoped

- **Connector configurations** (`config/sources.yaml`) can now be
  tagged with an optional `tenant:` field. Untagged sources stay
  global (visible to every tenant — preserves the pre-E7 default);
  tagged sources are visible only inside their named tenant.
  `list_sources`, `list_services`, `query_metrics` / `query_logs`,
  `get_service_health`, `detect_anomalies`, `get_topology` /
  `get_blast_radius` all consult `getByTenant(ctx.tenant)`, and a
  cross-tenant probe via `query_metrics({ source: "acme-only" })`
  from tenant `bigco` resolves to "no such source" — same posture as
  the rest of the tenancy layer (no existence leak).
- **Helm chart** doesn't yet split deployments per tenant. The
  documented model is "one Helm release per tenant" if you need full
  network-level isolation; in-process multi-tenancy is for the
  policy / audit / quota / catalog surfaces.
- **OPA policy package** runs process-wide, but the query input
  shape is `{ roles, resource, action, tenant }` — the active
  tenant reaches the Rego evaluator on every decision. Authors can
  write tenant-conditional rules directly, e.g.
  `allow { input.tenant == "acme"; input.action == "read" }`.
  Decisions are cached per `(roles, resource, action, tenant)` so
  cross-tenant verdicts never share a cache slot. If you want full
  package-level isolation (separate Rego bundle per tenant) you can
  still run one OPA instance per tenant; the in-input form is the
  zero-extra-infra path.
- **Per-process self-metrics** (`/metrics`) are not labelled by
  tenant. Anyone scraping the Prometheus endpoint sees aggregate
  counts across every tenant. Operators that need per-tenant
  Prometheus dashboards should currently run one OMCP per tenant
  and let upstream Prometheus do the labelling.
- **MCP credential bucket-key uniqueness** — the rate-limiter and
  token-budget tracker key on `<tenant> <credential-name>`. Two
  credentials sharing a name across tenants get isolated buckets,
  but operators are still advised to use distinct names per tenant
  for log clarity (see the MCP credentials section above).

## See also

- [access-control.md](access-control.md) — RBAC + audit + redaction
  + quotas; the layers tenant-scoping plugs into.
- [auth-oidc.md](auth-oidc.md) — OIDC session bootstrap, claim
  mapping, the `OMCP_OIDC_TENANT_CLAIM` env var.
- [auth-basic.md](auth-basic.md) — local-user file, the `tenant`
  field on user entries.
