Ctrl K
ComunidadGuías…

docs/adr/0081-operator-role-and-team-invitations.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0081: Platform operator role, SSO tenant resolution and team invitations

## Metadata

| Field          | Value                                                      |
| -------------- | ---------------------------------------------------------- |
| **Status**     | Accepted                                                   |
| **Date**       | 2026-10-08                                                 |
| **Authors**    | Alessio Brillo                                             |
| **Deciders**   | Alessio Brillo                                             |
| **Supersedes** | N/A                                                        |
| **Relates to** | ADR-0032, ADR-0034, ADR-0038, ADR-0053, ADR-0057, ADR-0062 |
| **Project**    | DOMINUS                                                    |

## Context

Three problems met in the v1.1.0 audit of the Cloud edition:

1. **Every tenant admin was a platform operator.** Self-signup minted an API key
   with role `admin`, and the same role gated the cross-tenant `/api/v1/admin`
   surface (all tenants, suspend, plan override). Anyone who registered could
   read and change other tenants. A suspended tenant's own admin also bypassed
   the suspension gate, which keyed on `admin`.
2. **Team seats were decorative.** `team_seats.user_id` held opaque strings,
   "inviting" wrote a pending row nobody could accept, nothing was delivered,
   and the OIDC login put the IdP's `org_id` claim (or nothing) in the session
   instead of the tenant the user actually belongs to. Pending invitations did
   not count against the plan, so seat limits could be exceeded.
3. **No email.** There was no way to tell a person they had been invited.

## Decision Drivers

1. Least privilege: the cross-tenant surface must be unreachable by any
   self-service flow.
2. The team seat is the source of truth for humans; the IdP only proves identity.
3. €0 community edition: email must be optional, never a requirement.
4. No new runtime dependency unless the feature needs it.

## Considered Options

### Option A: Reuse `admin` and add a `platform` flag on the tenant

Mark the operator's tenant (`tenant_admin_flags`) and let only that tenant's
admins into `/admin`.

**Advantages:** no new role; reuses the flags table.

**Disadvantages:** any admin of the operator tenant is an operator; membership
changes silently change who can suspend customers; the operator tenant is a
privileged tenant that billing and usage logic would have to special-case.

**Cost Implications:** small. **Risk Assessment:** medium, privilege tied to a mutable tenant.

---

### Option B: New `operator` role granted only by an environment allowlist (chosen)

`OPERATOR_SUBJECTS` lists OIDC/JWT subjects and `key:<id>` database key ids.
The auth middleware resolves the effective role; an `operator` claimed by a key
row or a token is ignored. `operator` satisfies every role check, `admin` stays
tenant-scoped.

**Advantages:** privilege is explicit, auditable and outside the data a tenant
can edit; empty allowlist fails closed; no schema change.

**Disadvantages:** changing operators needs a config change and restart.

**Cost Implications:** small. **Risk Assessment:** low.

---

### Option C: A separate operator application or database

**Advantages:** hard separation.

**Disadvantages:** a second deployable for a handful of read/suspend actions;
the existing admin router and tests already work.

**Cost Implications:** high. **Risk Assessment:** low, disproportionate.

## Decision Outcome

Chosen option: **B**.

- `OPERATOR_SUBJECTS` (comma-separated). The operator panel and the
  suspended-tenant bypass require `operator`. `GET /api/v1/me` and
  `/auth/oidc/me` report the effective role so the SPA shows the Admin link
  only to operators.
- OIDC sign-in resolves the tenant from the user's most recently joined active
  team seat and derives the role from the seat (`owner`/`admin` -> `admin`).
  Without a seat, an IdP `org_id` is honoured; otherwise a tenant is created
  with the user as admin when `OIDC_AUTO_PROVISION_TENANTS` is on (opt-in; the
  tenant id derives from the user so concurrent first sign-ins converge).
- Invitations are email-addressed single-use bearer links (`team_invitations`,
  only the SHA-256 stored, 7-day default lifetime). A pending invitation holds a
  seat. Acceptance requires the signed-in user's **verified** ID-token email to
  equal the invited address (checked before the token is claimed, so a wrong
  person cannot burn it), then claims the token atomically, creates the seat and
  re-issues the session for the team's tenant (`POST /auth/oidc/accept-invitation`).
- Email is a `Mailer` provider (ADR-0004): `SmtpMailer` (nodemailer, loaded only
  when `SMTP_URL` is set) or `NullMailer`. Without SMTP the API returns the link
  for the admin to share.
- Cookie-authenticated, state-changing requests must come from a trusted
  `Origin`/`Referer` (CSRF guard); Bearer callers are unaffected.

### Consequences

**Positive:** self-signup can no longer reach other tenants; seats mean what the
plan says; invitations work with or without SMTP.

**Negative / limits:**

- No tenant switcher: a user in several teams lands in the most recently joined
  one at the next sign-in. A switcher is a follow-up.
- Acceptance needs an identity provider that returns a verified `email` claim
  (Auth0 does with the default scope). A session without one cannot accept.
- Existing deployments must set `OPERATOR_SUBJECTS` to keep using `/admin`
  (documented in the 1.1.0 upgrade notes).
- Sessions are re-checked against the team seat on every request: a removed or
  pending seat ends the session at once and a role change applies immediately.
  Sessions that came from an IdP `org_id` claim have no seat and keep trusting
  the JWT until it expires (8 hours by default).