feat(oidc): /authorize tenant gate + picker (BUNYIP-62) #127

Merged
YousifShkara merged 1 commit from feat/bunyip-62-authorize-tenant-gate-and-picker into main 2026-06-12 05:46:26 +02:00
Owner

Closes BUNYIP-62. Second of three children under the BUNYIP-60 umbrella. Stacks on BUNYIP-61.

After BUNYIP-61 the oauth_client_user_tenants table and the oauth_clients.tenant_claim_name column exist but /authorize does not consult either. This PR wires the enforcement and adds the tenant picker UI for the multi-tenant-per-user case.

Behaviour, as a function of oauth_clients.tenant_claim_name:

  • tenant_claim_name IS NULL -> existing single-tenant flow, no change. Backward compatible for every seeded client today (mokosh, drillmark, bunyip-web SPA).
  • tenant_claim_name IS NOT NULL:
    • 0 assignments for (user, client) -> 400 with error=access_denied, error_description="no tenant assigned for this client". The user has signed in to bunyip OK but has not been granted access to this RP for any tenant.
    • 1 assignment -> auto-select. The single tenant_id lands on the auth-code row's new selected_tenant_id column.
    • 2+ assignments AND selected_tenant_id query param absent -> render the tenant picker page (HTML, hand-rolled), preserve every AuthorizeQuery field as a hidden input, submit GET back to /oauth2/authorize with the chosen selected_tenant_id appended.
    • 2+ assignments AND selected_tenant_id query param present -> validate the pick is one of the user's assignments and continue. A forged pick returns 400 access_denied.

Schema (migration 20260612000020_oauth_codes_selected_tenant_id.sql):

  • oauth_authorization_codes grows a nullable selected_tenant_id UUID. Nullable so legacy clients (tenant_claim_name NULL) keep working. /token (BUNYIP-63) will read this column when minting at+jwt and id_token to emit the tenant claim.

Code changes:

  • crates/bunyip-oidc/src/handlers/oidc.rs::AuthorizeQuery: add selected_tenant_id: Option<Uuid> (#[serde(default)]). New render_tenant_picker(q, client, assignments, silent_cookies) helper produces the HTML picker page; preserves the silent-SSO Set-Cookie headers across the round-trip so a user who landed in the picker on first hit keeps their newly-minted session. Hand-rolled html_escape covers the five characters that matter for attribute + content contexts (client.name and assignment role values are user-controlled input that flows into the page).
  • crates/bunyip-oidc/src/handlers/oidc.rs::authorize: between client load and issue_authorization_code, branch on client.tenant_claim_name and assignments_for(client, user) count as described above. Existing seeded clients (tenant_claim_name NULL) skip the entire block.
  • crates/bunyip-oidc/src/services/oidc_provider.rs::OAuthClient: add pub tenant_claim_name: Option<String>, derive sqlx::FromRow. load_client and the in-rotation query_as switch from compile-time sqlx::query_as! to runtime sqlx::query_as::<_, OAuthClient> so future per-RP column additions do not force a workspace .sqlx/ cache regen on every change.
  • crates/bunyip-oidc/src/services/oidc_provider.rs::issue_authorization_code: gains a selected_tenant_id: Option<Uuid> parameter. Runtime query so the new column does not need a cache regen here either. Test-only test_client() updated to set tenant_claim_name: None.

No new dependencies. No UI changes beyond the picker HTML page (admin UI for managing assignments still goes through the BUNYIP-61 admin API).

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-62
#BUNYIP-60

Closes BUNYIP-62. Second of three children under the BUNYIP-60 umbrella. Stacks on BUNYIP-61. After BUNYIP-61 the `oauth_client_user_tenants` table and the `oauth_clients.tenant_claim_name` column exist but `/authorize` does not consult either. This PR wires the enforcement and adds the tenant picker UI for the multi-tenant-per-user case. Behaviour, as a function of `oauth_clients.tenant_claim_name`: - `tenant_claim_name IS NULL` -> existing single-tenant flow, no change. Backward compatible for every seeded client today (mokosh, drillmark, bunyip-web SPA). - `tenant_claim_name IS NOT NULL`: - 0 assignments for (user, client) -> 400 with `error=access_denied, error_description="no tenant assigned for this client"`. The user has signed in to bunyip OK but has not been granted access to this RP for any tenant. - 1 assignment -> auto-select. The single tenant_id lands on the auth-code row's new `selected_tenant_id` column. - 2+ assignments AND `selected_tenant_id` query param absent -> render the tenant picker page (HTML, hand-rolled), preserve every AuthorizeQuery field as a hidden input, submit GET back to `/oauth2/authorize` with the chosen `selected_tenant_id` appended. - 2+ assignments AND `selected_tenant_id` query param present -> validate the pick is one of the user's assignments and continue. A forged pick returns 400 `access_denied`. Schema (migration `20260612000020_oauth_codes_selected_tenant_id.sql`): - `oauth_authorization_codes` grows a nullable `selected_tenant_id UUID`. Nullable so legacy clients (tenant_claim_name NULL) keep working. /token (BUNYIP-63) will read this column when minting at+jwt and id_token to emit the tenant claim. Code changes: - `crates/bunyip-oidc/src/handlers/oidc.rs::AuthorizeQuery`: add `selected_tenant_id: Option<Uuid>` (`#[serde(default)]`). New `render_tenant_picker(q, client, assignments, silent_cookies)` helper produces the HTML picker page; preserves the silent-SSO Set-Cookie headers across the round-trip so a user who landed in the picker on first hit keeps their newly-minted session. Hand-rolled `html_escape` covers the five characters that matter for attribute + content contexts (client.name and assignment role values are user-controlled input that flows into the page). - `crates/bunyip-oidc/src/handlers/oidc.rs::authorize`: between client load and `issue_authorization_code`, branch on `client.tenant_claim_name` and `assignments_for(client, user)` count as described above. Existing seeded clients (tenant_claim_name NULL) skip the entire block. - `crates/bunyip-oidc/src/services/oidc_provider.rs::OAuthClient`: add `pub tenant_claim_name: Option<String>`, derive `sqlx::FromRow`. `load_client` and the in-rotation `query_as` switch from compile-time `sqlx::query_as!` to runtime `sqlx::query_as::<_, OAuthClient>` so future per-RP column additions do not force a workspace `.sqlx/` cache regen on every change. - `crates/bunyip-oidc/src/services/oidc_provider.rs::issue_authorization_code`: gains a `selected_tenant_id: Option<Uuid>` parameter. Runtime query so the new column does not need a cache regen here either. Test-only `test_client()` updated to set `tenant_claim_name: None`. No new dependencies. No UI changes beyond the picker HTML page (admin UI for managing assignments still goes through the BUNYIP-61 admin API). `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-62 #BUNYIP-60
feat(oidc): /authorize tenant gate + picker (BUNYIP-62)
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 1m13s
2e4bf6cd04
Closes BUNYIP-62. Second of three children under the BUNYIP-60 umbrella. Stacks on BUNYIP-61.

After BUNYIP-61 the `oauth_client_user_tenants` table and the `oauth_clients.tenant_claim_name` column exist but `/authorize` does not consult either. This PR wires the enforcement and adds the tenant picker UI for the multi-tenant-per-user case.

Behaviour, as a function of `oauth_clients.tenant_claim_name`:

- `tenant_claim_name IS NULL` -> existing single-tenant flow, no change. Backward compatible for every seeded client today (mokosh, drillmark, bunyip-web SPA).
- `tenant_claim_name IS NOT NULL`:
  - 0 assignments for (user, client) -> 400 with `error=access_denied, error_description="no tenant assigned for this client"`. The user has signed in to bunyip OK but has not been granted access to this RP for any tenant.
  - 1 assignment -> auto-select. The single tenant_id lands on the auth-code row's new `selected_tenant_id` column.
  - 2+ assignments AND `selected_tenant_id` query param absent -> render the tenant picker page (HTML, hand-rolled), preserve every AuthorizeQuery field as a hidden input, submit GET back to `/oauth2/authorize` with the chosen `selected_tenant_id` appended.
  - 2+ assignments AND `selected_tenant_id` query param present -> validate the pick is one of the user's assignments and continue. A forged pick returns 400 `access_denied`.

Schema (migration `20260612000020_oauth_codes_selected_tenant_id.sql`):

- `oauth_authorization_codes` grows a nullable `selected_tenant_id UUID`. Nullable so legacy clients (tenant_claim_name NULL) keep working. /token (BUNYIP-63) will read this column when minting at+jwt and id_token to emit the tenant claim.

Code changes:

- `crates/bunyip-oidc/src/handlers/oidc.rs::AuthorizeQuery`: add `selected_tenant_id: Option<Uuid>` (`#[serde(default)]`). New `render_tenant_picker(q, client, assignments, silent_cookies)` helper produces the HTML picker page; preserves the silent-SSO Set-Cookie headers across the round-trip so a user who landed in the picker on first hit keeps their newly-minted session. Hand-rolled `html_escape` covers the five characters that matter for attribute + content contexts (client.name and assignment role values are user-controlled input that flows into the page).
- `crates/bunyip-oidc/src/handlers/oidc.rs::authorize`: between client load and `issue_authorization_code`, branch on `client.tenant_claim_name` and `assignments_for(client, user)` count as described above. Existing seeded clients (tenant_claim_name NULL) skip the entire block.
- `crates/bunyip-oidc/src/services/oidc_provider.rs::OAuthClient`: add `pub tenant_claim_name: Option<String>`, derive `sqlx::FromRow`. `load_client` and the in-rotation `query_as` switch from compile-time `sqlx::query_as!` to runtime `sqlx::query_as::<_, OAuthClient>` so future per-RP column additions do not force a workspace `.sqlx/` cache regen on every change.
- `crates/bunyip-oidc/src/services/oidc_provider.rs::issue_authorization_code`: gains a `selected_tenant_id: Option<Uuid>` parameter. Runtime query so the new column does not need a cache regen here either. Test-only `test_client()` updated to set `tenant_claim_name: None`.

No new dependencies. No UI changes beyond the picker HTML page (admin UI for managing assignments still goes through the BUNYIP-61 admin API).

`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-62
#BUNYIP-60
YousifShkara deleted branch feat/bunyip-62-authorize-tenant-gate-and-picker 2026-06-12 05:46:26 +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!127
No description provided.