feat(oidc): add oauth_client_user_tenants assignments + admin API #126

Merged
YousifShkara merged 1 commit from feat/bunyip-61-oauth-client-user-tenants into main 2026-06-12 05:45:21 +02:00
Owner

Closes BUNYIP-61. First of three children under the BUNYIP-60 umbrella (per-RP tenant claim on bunyip's at+jwt).

Context. Today bunyip mints at+jwt with only the standard OIDC claims. mokosh-server (and every future PSA RP) falls back to OIDC_DEFAULT_TENANT_ID env (default Uuid::from_u128(1)) for every bunyip-issued token. Result: every user authenticated through bunyip lands in the same tenant and sees each other's data. The fix is to source a per-RP tenant claim from a bunyip-owned (user, oauth_client) -> tenant_id table; this PR lands that table and the admin API. BUNYIP-62 wires /authorize to gate on it; BUNYIP-63 emits the claim on the token mints.

Schema (migration 20260612000010_create_oauth_client_user_tenants.sql):

  • New oauth_client_user_tenants(id, oauth_client_id, user_id, tenant_id, role, created_at, created_by). UNIQUE on the (client, user, tenant) triple (a user can legitimately have multiple tenants per RP), composite index on (oauth_client_id, user_id) covers the /authorize hot path that BUNYIP-62 will run.
  • oauth_clients grows a nullable tenant_claim_name TEXT. NULL means "no tenant claim, no enforcement, identical to pre-BUNYIP-61 behaviour" so existing seeded clients are unchanged. The cutover from NULL to non-null (mokosh_tenant_id etc.) is a separate deploy step gated on the assignment table being backfilled.

Domain types (crates/bunyip-domain/src/models/oauth_client_user_tenant.rs):

  • OAuthClientUserTenant row model (sqlx FromRow + Serialize for the admin list response).
  • CreateUserTenantAssignment request DTO.

Repository (crates/bunyip-domain/src/repositories/oauth_client_user_tenant.rs):

  • assignments_for(client, user) is the hot path BUNYIP-62 consumes. Stable order so a tenant picker UI renders deterministically across reloads.
  • list_for_client powers the admin list endpoint. No pagination in v1; realistic order of magnitude is tens to low hundreds per client. Adding ?after=... later does not break the flat-array body shape.
  • assign(...) upserts (ON CONFLICT DO UPDATE on the role column). Retrying a seed script that crashed mid-batch does not 500.
  • unassign(id) is idempotent so a network retry after a successful delete returns 204, not 404.

Admin API (bunyip-api/src/handlers/admin_oauth_tenants.rs, wired in routes/admin.rs):

  • GET /v1/admin/oauth-clients/{client_id}/user-tenants -> list
  • POST /v1/admin/oauth-clients/{client_id}/user-tenants -> assign (upsert)
  • DELETE /v1/admin/oauth-clients/{client_id}/user-tenants/{assignment_id} -> unassign
  • Every mutation writes an audit-log row carrying the (user, tenant, role, assignment_id) so security review can reconstruct "who connected what when" even after the row itself is gone.

Audit (crates/bunyip-domain/src/models/audit.rs):

  • OauthUserTenantAssigned / OauthUserTenantUnassigned actions, registered as admin actions and serialized to oauth_user_tenant_assigned / oauth_user_tenant_unassigned.

No UI changes; v1 of the umbrella ships admin-API-only. A picker UI lives on bunyip-web later; for now an admin (or a one-off seed script) hits the API directly.

just check-container clean (modulo the pre-existing test_config_defaults parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-61
#BUNYIP-60

Closes BUNYIP-61. First of three children under the BUNYIP-60 umbrella (per-RP tenant claim on bunyip's at+jwt). Context. Today bunyip mints at+jwt with only the standard OIDC claims. mokosh-server (and every future PSA RP) falls back to `OIDC_DEFAULT_TENANT_ID` env (default `Uuid::from_u128(1)`) for every bunyip-issued token. Result: every user authenticated through bunyip lands in the same tenant and sees each other's data. The fix is to source a per-RP tenant claim from a bunyip-owned `(user, oauth_client) -> tenant_id` table; this PR lands that table and the admin API. BUNYIP-62 wires `/authorize` to gate on it; BUNYIP-63 emits the claim on the token mints. Schema (migration `20260612000010_create_oauth_client_user_tenants.sql`): - New `oauth_client_user_tenants(id, oauth_client_id, user_id, tenant_id, role, created_at, created_by)`. UNIQUE on the (client, user, tenant) triple (a user can legitimately have multiple tenants per RP), composite index on `(oauth_client_id, user_id)` covers the `/authorize` hot path that BUNYIP-62 will run. - `oauth_clients` grows a nullable `tenant_claim_name TEXT`. NULL means "no tenant claim, no enforcement, identical to pre-BUNYIP-61 behaviour" so existing seeded clients are unchanged. The cutover from NULL to non-null (`mokosh_tenant_id` etc.) is a separate deploy step gated on the assignment table being backfilled. Domain types (`crates/bunyip-domain/src/models/oauth_client_user_tenant.rs`): - `OAuthClientUserTenant` row model (sqlx FromRow + Serialize for the admin list response). - `CreateUserTenantAssignment` request DTO. Repository (`crates/bunyip-domain/src/repositories/oauth_client_user_tenant.rs`): - `assignments_for(client, user)` is the hot path BUNYIP-62 consumes. Stable order so a tenant picker UI renders deterministically across reloads. - `list_for_client` powers the admin list endpoint. No pagination in v1; realistic order of magnitude is tens to low hundreds per client. Adding `?after=...` later does not break the flat-array body shape. - `assign(...)` upserts (ON CONFLICT DO UPDATE on the role column). Retrying a seed script that crashed mid-batch does not 500. - `unassign(id)` is idempotent so a network retry after a successful delete returns 204, not 404. Admin API (`bunyip-api/src/handlers/admin_oauth_tenants.rs`, wired in `routes/admin.rs`): - GET `/v1/admin/oauth-clients/{client_id}/user-tenants` -> list - POST `/v1/admin/oauth-clients/{client_id}/user-tenants` -> assign (upsert) - DELETE `/v1/admin/oauth-clients/{client_id}/user-tenants/{assignment_id}` -> unassign - Every mutation writes an audit-log row carrying the (user, tenant, role, assignment_id) so security review can reconstruct "who connected what when" even after the row itself is gone. Audit (`crates/bunyip-domain/src/models/audit.rs`): - `OauthUserTenantAssigned` / `OauthUserTenantUnassigned` actions, registered as admin actions and serialized to `oauth_user_tenant_assigned` / `oauth_user_tenant_unassigned`. No UI changes; v1 of the umbrella ships admin-API-only. A picker UI lives on bunyip-web later; for now an admin (or a one-off seed script) hits the API directly. `just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean). #BUNYIP-61 #BUNYIP-60
feat(oidc): add oauth_client_user_tenants assignments + admin API
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m1s
f3969ecbff
Closes BUNYIP-61. First of three children under the BUNYIP-60 umbrella (per-RP tenant claim on bunyip's at+jwt).

Context. Today bunyip mints at+jwt with only the standard OIDC claims. mokosh-server (and every future PSA RP) falls back to `OIDC_DEFAULT_TENANT_ID` env (default `Uuid::from_u128(1)`) for every bunyip-issued token. Result: every user authenticated through bunyip lands in the same tenant and sees each other's data. The fix is to source a per-RP tenant claim from a bunyip-owned `(user, oauth_client) -> tenant_id` table; this PR lands that table and the admin API. BUNYIP-62 wires `/authorize` to gate on it; BUNYIP-63 emits the claim on the token mints.

Schema (migration `20260612000010_create_oauth_client_user_tenants.sql`):

- New `oauth_client_user_tenants(id, oauth_client_id, user_id, tenant_id, role, created_at, created_by)`. UNIQUE on the (client, user, tenant) triple (a user can legitimately have multiple tenants per RP), composite index on `(oauth_client_id, user_id)` covers the `/authorize` hot path that BUNYIP-62 will run.
- `oauth_clients` grows a nullable `tenant_claim_name TEXT`. NULL means "no tenant claim, no enforcement, identical to pre-BUNYIP-61 behaviour" so existing seeded clients are unchanged. The cutover from NULL to non-null (`mokosh_tenant_id` etc.) is a separate deploy step gated on the assignment table being backfilled.

Domain types (`crates/bunyip-domain/src/models/oauth_client_user_tenant.rs`):

- `OAuthClientUserTenant` row model (sqlx FromRow + Serialize for the admin list response).
- `CreateUserTenantAssignment` request DTO.

Repository (`crates/bunyip-domain/src/repositories/oauth_client_user_tenant.rs`):

- `assignments_for(client, user)` is the hot path BUNYIP-62 consumes. Stable order so a tenant picker UI renders deterministically across reloads.
- `list_for_client` powers the admin list endpoint. No pagination in v1; realistic order of magnitude is tens to low hundreds per client. Adding `?after=...` later does not break the flat-array body shape.
- `assign(...)` upserts (ON CONFLICT DO UPDATE on the role column). Retrying a seed script that crashed mid-batch does not 500.
- `unassign(id)` is idempotent so a network retry after a successful delete returns 204, not 404.

Admin API (`bunyip-api/src/handlers/admin_oauth_tenants.rs`, wired in `routes/admin.rs`):

- GET `/v1/admin/oauth-clients/{client_id}/user-tenants` -> list
- POST `/v1/admin/oauth-clients/{client_id}/user-tenants` -> assign (upsert)
- DELETE `/v1/admin/oauth-clients/{client_id}/user-tenants/{assignment_id}` -> unassign
- Every mutation writes an audit-log row carrying the (user, tenant, role, assignment_id) so security review can reconstruct "who connected what when" even after the row itself is gone.

Audit (`crates/bunyip-domain/src/models/audit.rs`):

- `OauthUserTenantAssigned` / `OauthUserTenantUnassigned` actions, registered as admin actions and serialized to `oauth_user_tenant_assigned` / `oauth_user_tenant_unassigned`.

No UI changes; v1 of the umbrella ships admin-API-only. A picker UI lives on bunyip-web later; for now an admin (or a one-off seed script) hits the API directly.

`just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-61
#BUNYIP-60
YousifShkara deleted branch feat/bunyip-61-oauth-client-user-tenants 2026-06-12 05:45:22 +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/bunyip!126
No description provided.