# KodeMed — Roles & Permissions Canonical reference for how role and group claims travel from the OIDC IDP through the KodeMed backend into UI feature gating and Spring Security `@PreAuthorize` checks. If you change any cell in the table, change all three columns and update the integration test in `KodeMed.Server` (`SecurityRoleMappingIT`). Otherwise the system drifts the way it did before #641 — UI rendered a form, backend returned 403. ## Naming convention — kodemed-prefixed is the norm **In the KodeMed-managed Keycloak realm at `sso.kodemed.ch`, every role carries the `kodemed-` prefix.** That's the rule, not a recommendation: - ✅ `kodemed-admin`, `kodemed-coder`, `kodemed-approver`, `kodemed-auditor`, `kodemed-viewer`, `kodemed-data-admin` - ❌ Plain `admin` / `coder` / `approver` do NOT exist in the KodeMed realm — assigning them would have no effect. The "plain alternative" column in the table below exists for **partner SSO scenarios only** — a hospital that points their existing OIDC IdP (Entra ID, Okta, Azure AD, etc.) at KodeMed and cannot easily prefix their tenant-wide roles. The dual-emit role mapper accepts either form so KodeMed Server doesn't need to know which IdP the partner runs. Inside the KodeMed-managed realm, do not use the plain form. --- ## Canonical role table | Realm role (Keycloak) | Spring authority (after dual-emit) | UI `hasRole(...)` accepts | Backend `@PreAuthorize` accepts | What it unlocks | |---|---|---|---|---| | `kodemed-admin` | `ROLE_KODEMED_ADMIN` + `ROLE_ADMIN` | `'admin'`, `'kodemed-admin'` | `hasRole('ADMIN')`, `hasRole('KODEMED_ADMIN')` | All admin endpoints: `/api/v1/admin/settings/feedback`, integration profiles, webhook failures, audit log | | `kodemed-coder` | `ROLE_KODEMED_CODER` + `ROLE_CODER` | `'coder'`, `'kodemed-coder'`, `isAdmin()` (implied) | n/a — backend doesn't guard coder-only paths today | UI: case editing, code suggestions, layouts. `canEdit()` returns true | | `kodemed-approver` | `ROLE_KODEMED_APPROVER` + `ROLE_APPROVER` | `'approver'`, `'kodemed-approver'`, `isAdmin()` (implied) | n/a today | UI: `canApprove()` returns true; reserved for approve-and-close workflow | | `kodemed-auditor` | `ROLE_KODEMED_AUDITOR` + `ROLE_AUDITOR` | `'auditor'`, `'kodemed-auditor'`, `isAdmin()` (implied) | `hasRole('ADMIN') or hasRole('AUDITOR')` on `/api/v1/audit/**` | Read-only access to the audit log | | `admin` (plain — partner IdP) | `ROLE_ADMIN` | `'admin'` | `hasRole('ADMIN')` | Same as `kodemed-admin` — for partner SSO realms that use plain role names | | `coder` (plain) | `ROLE_CODER` | `'coder'`, `isAdmin()` | n/a | Same as `kodemed-coder` | | `approver` (plain) | `ROLE_APPROVER` | `'approver'`, `isAdmin()` | n/a | Same as `kodemed-approver` | | `auditor` (plain) | `ROLE_AUDITOR` | `'auditor'`, `isAdmin()` | `hasRole('ADMIN') or hasRole('AUDITOR')` | Same as `kodemed-auditor` | Notes on the table: - "Dual-emit" means the JWT converter emits BOTH the prefixed authority AND a stripped variant. Implementation in `SecurityConfig.OidcRealmRoleConverter` and `ConfigurableJwtRolesConverter` — keep both in sync. - `isAdmin()` in `authProvider.ts:812` returns true for either `admin` OR `kodemed-admin`. The same component then also acts as a permission super-set in `canEdit()` / `canApprove()` (admins can do anything a coder/approver can). - Plain role names (`admin`, `coder`, ...) are for partner SSO setups where adding a `kodemed-` prefix is impractical. The dual-emit means a partner can choose either naming and the system honours both. --- ## Where each role gates today ### Server-side `@PreAuthorize` | File | Guard | Resolved authority needed | |---|---|---| | `AdminFeedbackSettingsController` | `hasRole('ADMIN')` | `ROLE_ADMIN` | | `AdminIntegrationProfileController` | `hasRole('ADMIN')` | `ROLE_ADMIN` | | `AdminWebhookFailureController` | `hasRole('ADMIN')` | `ROLE_ADMIN` | | `AuditController` | `hasRole('ADMIN') or hasRole('AUDITOR') or hasAuthority('SCOPE_audit:read')` | `ROLE_ADMIN`, `ROLE_AUDITOR`, or the OAuth2 scope | ### UI-side gating | File | Check | Behaviour | |---|---|---| | `authProvider.ts:805` | `canEdit()` = `hasRole('coder') ‖ hasRole('kodemed-coder') ‖ isAdmin()` | Required to edit a case in the browser | | `authProvider.ts:808` | `canApprove()` = `hasRole('approver') ‖ hasRole('kodemed-approver') ‖ isAdmin()` | Required to finalize a coding session | | `authProvider.ts:812` | `isAdmin()` = `hasRole('admin') ‖ hasRole('kodemed-admin')` | Required to render admin sections in `/settings` | | `SettingsPage.tsx:251` | `isAdmin()` | Feedback transport settings section | | `SettingsPage.tsx:259` | `isAdmin()` | Imported data sources section | The previous `isAdmin() ‖ isDemoMode()` workaround in `SettingsPage.tsx` is retired after the d173c9b dual-emit fix — demo users with the actual `kodemed-admin` role now satisfy `isAdmin()` honestly. --- ## How the JWT looks on the wire (Keycloak default) ```json { "sub": "0123...", "preferred_username": "ricardo.mieres", "email": "ricardo.mieres@kodemed.ch", "realm_access": { "roles": ["kodemed-admin", "default-roles-kodemed", "uma_authorization"] }, "iss": "https://sso.kodemed.ch/realms/kodemed" } ``` After the role converter runs in `KodeMed.Server`: ``` ROLE_KODEMED_ADMIN ROLE_ADMIN ← dual-emit ROLE_DEFAULT_ROLES_KODEMED ROLE_UMA_AUTHORIZATION ``` Spring Security `hasRole('ADMIN')` checks for the unprefixed `ROLE_ADMIN` and matches. --- ## Partner-IdP scenarios The `ConfigurableJwtRolesConverter` is wired per `IntegrationProfile` and supports two claim paths: 1. **`realm_access.roles`** (Keycloak default). 2. **`groups`** (Entra ID / Okta / Auth0 default). Both go through the same dual-emit normalisation. A partner whose Entra ID emits `["KodeMed Admin"]` in the `groups` claim ends up with `ROLE_KODEMED_ADMIN` + `ROLE_ADMIN` after the spaces-to-underscore replacement. A partner that uses `["admin"]` plain in `realm_access.roles` gets `ROLE_ADMIN` directly. This is how the same controller `@PreAuthorize` works for both Keycloak-prefixed roles and partner-IdP plain roles without per-deploy code changes. --- ## Anti-patterns to avoid - **Don't add `@PreAuthorize("hasRole('KODEMED_ADMIN')")` directly to a controller.** Use `hasRole('ADMIN')` — the dual-emit makes both prefixed and stripped variants match. Hardcoding `KODEMED_ADMIN` locks the endpoint to KodeMed-prefixed partners only. - **Don't gate UI features on `isDemoMode()` as a substitute for role checks.** The 14d2009 workaround was a release-window hack — the proper fix landed in d173c9b. - **Don't add a third role-name variant (e.g. `km-admin`, `mieres-admin`) without updating the table above + both converters.** Drift between code, docs, and Keycloak realm config is exactly what #641 set out to fix. --- ## Verification Integration test contract (planned — `KodeMed.Server/src/test/java/.../SecurityRoleMappingIT.java`): For each row of the canonical table, given a Keycloak Testcontainer realm with one test user assigned exactly that role: | Endpoint | Expected response | |---|---| | `GET /api/v1/admin/settings/feedback` | 200 if `ADMIN`, 403 otherwise | | `GET /api/v1/audit/events` | 200 if `ADMIN` or `AUDITOR`, 403 otherwise | | `POST /api/v1/coding/session` (UI source) | 200 if `CODER`/`APPROVER`/`ADMIN`, 403 otherwise | When the contract test goes green for both Keycloak-prefixed and plain role names, this doc is "correct". When it goes red, fix the canonical table above first, then make the test match. --- ## Change log | Date | Commit | Change | |---|---|---| | 2026-05-14 | d173c9b | `OidcRealmRoleConverter` switched to dual-emit (fixes #635) | | 2026-05-14 | (#641 follow-up) | `ConfigurableJwtRolesConverter` aligned to same dual-emit; this doc created; `SettingsPage.tsx` demoMode workaround retired |