feat(auth): resolve SSO users to a per-org tenant via the Bunyip org claim (PMS-240) #175

Merged
longjacksonle merged 1 commit from feat/mapps-240-bunyip-org-tenant into main 2026-06-11 17:51:28 +02:00

What

Implements PMS-240: resolve each SSO user to their own org tenant instead of funnelling everyone into the shared default tenant (the root cause behind PMS-239).

Design (the agreed contract)

  • Mapping: Bunyip emits an opaque org id; mokosh maps it to a tenant via a new nullable tenants.bunyip_org_id column with a partial unique index (legacy tenants stay NULL; each org maps to exactly one tenant).
  • Provisioning: auto-create on first login. TenantService::ensure_tenant_for_bunyip_org is select-or-provision: inserts an org-kind tenant ON CONFLICT (bunyip_org_id) DO NOTHING (concurrent first-logins converge on one tenant - the race loser re-reads the winner), initialises ticket/invoice sequences, and runs copy_default_config so statuses/priorities/queues/SLA + notification templates exist out of the box. The SSO user is JIT-mirrored into the resolved tenant.

Forward-compatible and inert until Bunyip ships the claim

AtClaims.bunyip_org_id is #[serde(default)], so current tokens deserialize it to None and keep falling back to default_bunyip_tenant_id() - byte-for-byte the pre-PMS-240 behaviour. The tenant service is threaded into AuthMiddleware via with_tenants; when the claim or the service is absent, the default-tenant fallback runs. A failed org→tenant resolution drops the bunyip path rather than landing the user in the wrong tenant.

Security

The claim rides the already-verified Bunyip at+jwt (iss/aud/signature checked by BunyipVerifier), so it is as authoritative as sub / bunyip_role.

Out of scope (follow-ups)

  • Bunyip must actually emit bunyip_org_id (cross-system; this PR is the consuming half).
  • Backfilling users already mirrored into the default tenant.
  • Human-facing tenant name/slug (auto-provisioned tenants get Org <id> + a uuid-derived slug, renamable later).

Verification

  • cargo check --all-targets, cargo clippy --all-targets: clean.
  • tests/tenants.rs: new ensure_tenant_for_bunyip_org_provisions_then_is_idempotent (provision -> same-org idempotent -> distinct-org distinct -> default config copied). Full tenants suite (6) and auth suite (14) green.
  • Migration 034_tenant_bunyip_org.sql is additive (nullable column + partial unique index).

🤖 Generated with Claude Code

## What Implements PMS-240: resolve each SSO user to their own org tenant instead of funnelling everyone into the shared default tenant (the root cause behind PMS-239). ## Design (the agreed contract) - **Mapping**: Bunyip emits an opaque org id; mokosh maps it to a tenant via a new nullable `tenants.bunyip_org_id` column with a **partial unique index** (legacy tenants stay NULL; each org maps to exactly one tenant). - **Provisioning**: auto-create on first login. `TenantService::ensure_tenant_for_bunyip_org` is select-or-provision: inserts an `org`-kind tenant `ON CONFLICT (bunyip_org_id) DO NOTHING` (concurrent first-logins converge on one tenant - the race loser re-reads the winner), initialises ticket/invoice sequences, and runs `copy_default_config` so statuses/priorities/queues/SLA + notification templates exist out of the box. The SSO user is JIT-mirrored into the resolved tenant. ## Forward-compatible and inert until Bunyip ships the claim `AtClaims.bunyip_org_id` is `#[serde(default)]`, so **current tokens deserialize it to `None` and keep falling back to `default_bunyip_tenant_id()` - byte-for-byte the pre-PMS-240 behaviour**. The tenant service is threaded into `AuthMiddleware` via `with_tenants`; when the claim or the service is absent, the default-tenant fallback runs. A failed org→tenant resolution drops the bunyip path rather than landing the user in the wrong tenant. ## Security The claim rides the already-verified Bunyip at+jwt (iss/aud/signature checked by `BunyipVerifier`), so it is as authoritative as `sub` / `bunyip_role`. ## Out of scope (follow-ups) - **Bunyip must actually emit `bunyip_org_id`** (cross-system; this PR is the consuming half). - Backfilling users already mirrored into the default tenant. - Human-facing tenant name/slug (auto-provisioned tenants get `Org <id>` + a uuid-derived slug, renamable later). ## Verification - `cargo check --all-targets`, `cargo clippy --all-targets`: clean. - `tests/tenants.rs`: new `ensure_tenant_for_bunyip_org_provisions_then_is_idempotent` (provision -> same-org idempotent -> distinct-org distinct -> default config copied). Full tenants suite (6) and auth suite (14) green. - Migration `034_tenant_bunyip_org.sql` is additive (nullable column + partial unique index). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): resolve SSO users to a per-org tenant via the Bunyip org claim (PMS-240)
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 15s
E2E (staging) / Playwright against staging (pull_request) Successful in 47s
Build OCI container / Build and push mokosh-api image (push) Successful in 6m49s
Create release / Create release from merged PR (pull_request) Has been skipped
6b8ed51e9a
Replaces the v1 stopgap where every Bunyip/OIDC user JIT-landed in one shared default tenant (the root cause behind PMS-239). The bunyip path now reads an opaque `bunyip_org_id` claim and resolves it to a dedicated mokosh tenant, auto-provisioning one on first login of a new org.

Design (per the agreed contract): Bunyip emits an org id, mokosh maps it to a tenant via a new nullable `tenants.bunyip_org_id` column (partial unique index, so legacy tenants stay NULL while each org maps to exactly one tenant). `TenantService::ensure_tenant_for_bunyip_org` does select-or-provision: on first sight it inserts an `org`-kind tenant `ON CONFLICT (bunyip_org_id) DO NOTHING` (concurrent first-logins converge on one tenant - the race loser re-reads the winner), initialises the ticket/invoice sequences, and runs `copy_default_config` so statuses/priorities/queues/SLA + notification templates exist out of the box. The SSO user is JIT-mirrored into the resolved tenant as before.

Forward-compatible and inert until Bunyip ships the claim: `AtClaims.bunyip_org_id` is `#[serde(default)]`, so current tokens deserialize it to `None` and fall back to `default_bunyip_tenant_id()` - byte-for-byte the pre-PMS-240 behaviour. The tenant service is threaded into `AuthMiddleware` via `with_tenants`; when it (or the claim) is absent the default-tenant fallback is used. A failed org→tenant resolution drops the bunyip path rather than landing the user in the wrong tenant.

Security: the claim rides the already-verified Bunyip at+jwt (iss/aud/signature checked by `BunyipVerifier`), so it is as authoritative as `sub` / `bunyip_role`.

Out of scope (follow-ups): the Bunyip side must actually emit `bunyip_org_id`; backfilling users already mirrored into the default tenant; and a human-facing tenant name/slug (auto-provisioned tenants get `Org <id>` + a uuid-derived slug, renamable later).

Verification: `cargo check --all-targets` + `cargo clippy --all-targets` clean; `tests/tenants.rs` adds `ensure_tenant_for_bunyip_org_provisions_then_is_idempotent` (provision -> same-org idempotent -> distinct-org distinct -> default config copied), full tenants suite (6) and auth suite (14) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/mapps-240-bunyip-org-tenant 2026-06-11 17:51:29 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!175
No description provided.