Files
harmony/docs/adr/027-multi-tenant-identity.md
Jean-Gabriel Gill-Couture 3a6006846b
All checks were successful
Run Check Script / check (push) Successful in 1m52s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 8m3s
docs: align roadmap and review workflow
2026-07-14 23:52:04 -04:00

158 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architecture Decision Record: Multi-Tenant Cloud Identity
Initial Author: Jean-Gabriel Gill-Couture
Initial Date: 2026-06-20
Last Updated Date: 2026-06-20
## Status
Accepted. Phase 0 has been exercised by an initial tenant deployment.
## Context
NationTech runs an open cloud: NationTech staff hold roles in one Zitadel
org (`cloud-admin`, `cloud-billing`, …), and customers create **tenants**.
A tenant owns a slice of every backing service — secrets under
`secret/data/<tenant>/*` in OpenBao, a Harbor project `<tenant>`, Ceph RGW
buckets `<tenant>-*`. The hard constraint:
> A user (or CI identity) created in Zitadel must gain access to its tenant's
> secrets, registry, and storage **without adding a new auth method to any
> service, and without modifying any app, per tenant.**
The open question that triggered this ADR was Zitadel structure: one
**org per tenant**, or one shared **project** with per-tenant roles? Phrased
that way it looks like a service-integration choice. It is not.
## Decision
**Every backing service trusts the *one* Zitadel issuer + *one* shared
audience exactly once, then authorizes on claims. Tenant scoping lives
entirely in a frozen claim contract; the org/project structure is an
internal isolation choice that can change behind it.**
### The claim contract (frozen — apps bind to this, never to tenant structure)
| Claim | Meaning |
|---|---|
| issuer | `https://sso.nationtech.io` — the single root of trust. |
| `aud` | Resource Id of the **shared `cloud` project** — the single audience every service pins as `bound_audiences`. Never per-tenant. |
| `groups` | string array of `<tenant>:<capability>` entries, e.g. `acme:deployer`. The tenant slug is the universal resource prefix; the capability is a fixed tier. |
Capability tiers are a fixed vocabulary: `owner` (full tenant control),
`deployer` (CI — push images, read deploy secrets, apply workloads),
`viewer` (read-only). The **tenant slug** is a human handle NationTech
assigns (`acme`), decoupled from any Zitadel UUID, and is the literal prefix
in OpenBao paths, the Harbor project name, and the Ceph bucket prefix.
### Zitadel structure (Phase 0 — single org, shared project)
- **One org**: `nationtech`. NationTech creates all identities for now.
- **One shared project**, `cloud`. Its Resource Id is the audience for
*everything*. One API application (Private Key JWT) under it makes that id
a valid `aud`.
- **Per-tenant = roles, not structure.** Each tenant contributes roles
`<slug>:owner` / `<slug>:deployer` / `<slug>:viewer` to the shared project.
Granting a user or service user one of these roles is the entire
"add user to tenant" operation.
- One project-level **Action** flattens granted role keys into the `groups`
claim (Zitadel's roles claim is a map; a string-array `groups` is what the
groups model below consumes — same Action as ADR-025).
### Service integration (reuses ADR-025's groups model verbatim)
Each service is configured **once**, never per tenant:
- **OpenBao** — JWT auth configured against the one issuer; **one** shared
jwt role pinning `bound_audiences = <cloud project id>` and
`groups_claim = groups`. Per tenant+capability there is one OpenBao
*external group* `<slug>:<cap>` and one policy granting its subtree
(`secret/data/<slug>/*`), attached to that group. A token carrying
`acme:deployer` resolves the policy at request time — no role edit, no
re-auth config. (Identical mechanism and write-cost properties to
ADR-025's fleet device access.)
- **Harbor / Ceph RGW** — same shape: bind the one OIDC issuer + audience
once; map `groups` membership to the tenant's project / bucket-prefix
policy.
Onboarding tenant N is therefore pure **provisioning** — add roles + Action
entry, add one external group + policy per service — with **zero** app code,
auth-method, or audience change. This is the constraint, satisfied.
### Phasing (the structure evolves; the contract does not)
| Phase | Trigger | Change | Claim contract |
|---|---|---|---|
| **0 (now)** | tenant #1 | single org, shared `cloud` project; tenant encoded as `<slug>:<cap>` role keys flattened into `groups`. Zero new code. | unchanged |
| **1** | tenants #23 | add a first-class `tenant` claim (Zitadel Action) + OpenBao `claim_mappings` + **one** templated policy `secret/data/{{…metadata.tenant}}/*`, collapsing per-tenant groups into one policy. One-time, not per-tenant. | `tenant` becomes a distinct claim; `groups` keeps capability |
| **2** | self-service onboarding | **org per tenant** + Zitadel project grants (B2B); slug becomes org metadata; audience stays the shared `cloud` project so services still bind one `aud`. | unchanged — apps never notice |
## Rationale
- **Org-vs-project is not a service question.** Because every service
validates one issuer + one audience and reads claims, the Zitadel
structure is free to change behind the claim contract. Picking the
*minimal* structure now (single org) and deferring org-per-tenant to
when self-service actually demands it is YAGNI applied to identity.
- **One audience is the whole trick.** If audience were per-tenant, every
service would need a new `bound_audiences` per tenant — exactly the
per-tenant service change the constraint forbids. A single shared-project
audience is what makes onboarding pure provisioning.
- **Reuse, don't reinvent.** The group→policy authorization, its O(1)
per-change write cost, and the roles→`groups` Action already exist and are
argued in ADR-025. Multi-tenant cloud access and fleet device access are
the same problem (signed group claim gates a prefixed secret subtree); they
share one mechanism.
- **Slug as prefix, not UUID.** Binding resources to a human slug keeps
OpenBao paths, Harbor projects, and buckets legible and stable across the
Phase 2 org migration, where the UUID *does* change.
## Consequences
**Pros**
- New tenant: provisioning only — no app, auth-config, or audience change.
- One issuer + one audience across all services; one place to reason about
trust.
- Authorization is human-auditable: role grants in Zitadel, group→resource
policies on each service.
- The Phase 2 org migration is invisible to apps (they read claims).
**Cons / accepted trade-offs**
- **Phase 0 isolation is logical, not org-hard.** All tenants share one org
and project; a Zitadel-admin compromise spans tenants. Acceptable while
NationTech creates every identity; Phase 2's org-per-tenant closes it.
- **Zitadel Action required** until first-class groups/claims GA — small,
version-controlled server-side JS in the token path (shared with ADR-025).
- **Capability tiers are coarse** (`owner`/`deployer`/`viewer`). Finer grants
mean more group→policy pairs; revisit only at a real third instance.
## Alternatives considered
- **Org per tenant from day one (Phase 2 now).** Correct end state, but
Zitadel org creation + project grants are unbuilt Scores and self-service
isn't needed yet — premature. Deferred, not rejected.
- **Per-tenant audience (a project per tenant).** Forces every service to
add a `bound_audiences` per tenant — the precise per-service change the
constraint forbids. Rejected.
- **Tenant in OpenBao only (out-of-band mapping).** Drops Zitadel as the
source of truth for who-belongs-to-what; reintroduces a second place to
manage membership. Rejected.
## Additional Notes
Verified Score gaps as of 2026-06-20 (build order tracks the phases): the
Zitadel Score supports projects, roles, single-org user grants, machine
users + keys, API apps, and the `groups` flatten Action — it lacks org
creation, project grants, and arbitrary custom claims. The OpenBao Score
supports JWT config + the external-groups model (ADR-025) — it lacks
`claim_mappings` and templated-policy metadata population. Phase 0 needs none
of the gaps; Phase 1 needs the OpenBao ones; Phase 2 needs the Zitadel ones.
Relationship to other ADRs — **ADR-025**: identical groups-as-boundary
mechanism, reused not forked. **ADR-020/020-1**: `harmony_config` /
OpenBao+Zitadel as the single config+secret entry point. **ADR-023**: all
Zitadel/OpenBao configuration lands via Scores. **ADR-011**: multi-tenant
cluster isolation, the workload-side counterpart of this identity-side split.