|
| 1 | +# Design: Canonical role constants |
| 2 | + |
| 3 | +## GitHub Issue |
| 4 | + |
| 5 | +[#3 — ROLE_ADMIN constant is "ADMIN" but the canonical app-admin role is "APP-ADMIN" (frontend/backend mismatch)](https://github.com/OpenElementsLabs/nextjs-app-layer/issues/3) |
| 6 | + |
| 7 | +## Summary |
| 8 | + |
| 9 | +The role constants exported from `src/lib/roles.ts` do not match the canonical |
| 10 | +role names used across the Open Elements ecosystem. `ROLE_ADMIN` resolves to |
| 11 | +the string `"ADMIN"`, but the admin role issued by the OIDC provider and |
| 12 | +enforced by the backend is `"APP-ADMIN"` (backend authority `ROLE_APP-ADMIN` |
| 13 | +via the shared `spring-services` `@RequiresAppAdmin`). |
| 14 | + |
| 15 | +Because `hasRole()` performs an exact, case-sensitive match, any downstream app |
| 16 | +calling `hasRole(session, ROLE_ADMIN)` to gate admin UI gets `false` even for |
| 17 | +real admins — admin-only controls stay hidden. This fixes the constant values, |
| 18 | +aligns the constant naming to a consistent `APP_`/`IT_` prefix scheme, and adds |
| 19 | +the missing `APP-USER` role constant. It is a **breaking change** to the public |
| 20 | +API and warrants a version bump. |
| 21 | + |
| 22 | +## Reproduction |
| 23 | + |
| 24 | +- **Given** an OIDC provider that issues the roles claim `["APP-ADMIN", "APP-USER", "IT-ADMIN"]` |
| 25 | +- **And** a downstream app that gates admin UI with `hasRole(session, ROLE_ADMIN)` |
| 26 | +- **When** a real admin (holding `APP-ADMIN`) signs in |
| 27 | +- **Then** `session.roles` contains `"APP-ADMIN"` (copied 1:1 from the JWT), but |
| 28 | + `ROLE_ADMIN === "ADMIN"`, so `session.roles.includes("ADMIN")` is `false` |
| 29 | +- **Result:** admin-only UI (e.g. delete buttons) stays disabled/hidden even |
| 30 | + though the backend correctly authorizes the same user. |
| 31 | + |
| 32 | +## Root cause analysis |
| 33 | + |
| 34 | +The bug is a **wrong constant value**, not a logic error. |
| 35 | + |
| 36 | +- `src/lib/roles.ts` declares `export const ROLE_ADMIN = "ADMIN";` — the string |
| 37 | + literal `"ADMIN"` never matches the canonical `"APP-ADMIN"` role name. |
| 38 | +- `hasRole()` is correct: `session?.roles?.includes(role)` is an exact match, |
| 39 | + and roles line up exactly once the constant holds the right value. No |
| 40 | + normalization is needed. |
| 41 | +- `src/server/auth.ts` copies the OIDC `roles` claim into `session.roles` |
| 42 | + unchanged — also correct; the session carries the true role strings. |
| 43 | + |
| 44 | +The frontend was simply the outlier: the IdP, `session.roles`, and the backend |
| 45 | +all agree on `"APP-ADMIN"`; only this library's constant disagreed. |
| 46 | + |
| 47 | +Note: `ROLE_ADMIN` is **not used internally** anywhere in this library — every |
| 48 | +admin page (`users`, `audit-logs`, `status`, `token`, `api-keys`, `webhooks`) |
| 49 | +gates on `ROLE_IT_ADMIN`. `ROLE_ADMIN` is only re-exported from `src/index.ts` |
| 50 | +for downstream consumers, so the practical breakage is entirely downstream. |
| 51 | + |
| 52 | +## Fix approach |
| 53 | + |
| 54 | +Correct the canonical role strings and align the constant names to a consistent |
| 55 | +prefix scheme. `ROLE_IT_ADMIN` already held the correct value `"IT-ADMIN"` and |
| 56 | +keeps it. |
| 57 | + |
| 58 | +`src/lib/roles.ts`: |
| 59 | + |
| 60 | +```ts |
| 61 | +export const ROLE_APP_ADMIN = "APP-ADMIN"; |
| 62 | +export const ROLE_APP_USER = "APP-USER"; |
| 63 | +export const ROLE_IT_ADMIN = "IT-ADMIN"; |
| 64 | + |
| 65 | +export function hasRole(session: Session | null | undefined, role: string): boolean { |
| 66 | + return !!session?.roles?.includes(role); |
| 67 | +} |
| 68 | +``` |
| 69 | + |
| 70 | +Changes: |
| 71 | + |
| 72 | +1. **Rename** `ROLE_ADMIN` → `ROLE_APP_ADMIN` and **fix its value** from |
| 73 | + `"ADMIN"` to `"APP-ADMIN"`. |
| 74 | +2. **Add** `ROLE_APP_USER = "APP-USER"` (the third supported role, previously |
| 75 | + missing). |
| 76 | +3. **Keep** `ROLE_IT_ADMIN = "IT-ADMIN"` unchanged. |
| 77 | +4. **Keep** `hasRole()` as an exact, case-sensitive `includes()` match. Roles |
| 78 | + now match the claim exactly, so no normalization is introduced (it would |
| 79 | + only mask future mismatches). |
| 80 | + |
| 81 | +Update the public export barrel `src/index.ts`: |
| 82 | + |
| 83 | +```ts |
| 84 | +export { ROLE_APP_ADMIN, ROLE_APP_USER, ROLE_IT_ADMIN, hasRole } from "./lib/roles"; |
| 85 | +``` |
| 86 | + |
| 87 | +Update the test `src/lib/__tests__/roles.test.ts` to assert the new names and |
| 88 | +values, and add coverage for `ROLE_APP_USER`. |
| 89 | + |
| 90 | +Update documentation in `README.md`: |
| 91 | + |
| 92 | +- The exported-symbols list (currently `ROLE_ADMIN, ROLE_IT_ADMIN, hasRole`). |
| 93 | +- The "OE conventions this lib assumes" line (currently |
| 94 | + `OIDC role names: IT-ADMIN and ADMIN (hardcoded)`) to reflect |
| 95 | + `APP-ADMIN`, `APP-USER`, and `IT-ADMIN`. |
| 96 | + |
| 97 | +**Rationale for the naming decision:** since the fix is already a breaking |
| 98 | +change (the `ROLE_ADMIN` value changes), it is the right moment to also rename |
| 99 | +the constant so all three exports follow the same `<AREA>_<ROLE>` convention |
| 100 | +(`ROLE_APP_ADMIN`, `ROLE_APP_USER`, `ROLE_IT_ADMIN`). This mirrors the actual |
| 101 | +role strings (`APP-`/`IT-` prefixes) and removes the mismatch between a bare |
| 102 | +`ROLE_ADMIN` name and an `"APP-ADMIN"` value. |
| 103 | + |
| 104 | +**Rationale for keeping `hasRole()` exact:** the OIDC IdP, `session.roles`, and |
| 105 | +the backend all use the exact canonical strings. An exact match keeps the |
| 106 | +contract simple and surfaces any real future mismatch loudly instead of |
| 107 | +silently coercing it. |
| 108 | + |
| 109 | +## Versioning |
| 110 | + |
| 111 | +This is a **breaking change** to the public API: |
| 112 | + |
| 113 | +- `ROLE_ADMIN` is removed (renamed to `ROLE_APP_ADMIN`), so any consumer |
| 114 | + importing `ROLE_ADMIN` will fail to compile. |
| 115 | +- The value of the admin role constant changes from `"ADMIN"` to `"APP-ADMIN"`, |
| 116 | + breaking anyone who still issues an `"ADMIN"` claim to match the old value. |
| 117 | + |
| 118 | +Recommendation: bump the package from `0.5.0` to **`0.6.0`** (minor bump on a |
| 119 | +pre-1.0 line signals a breaking change per this project's convention) and add a |
| 120 | +clear changelog / release note describing the rename and the new value, with a |
| 121 | +migration hint (`ROLE_ADMIN` → `ROLE_APP_ADMIN`, ensure the IdP issues |
| 122 | +`APP-ADMIN`). A release/upgrade note can be produced with `/release-doc`. |
| 123 | + |
| 124 | +## Non-goals |
| 125 | + |
| 126 | +- **Configurable / per-app role mapping** — remains deferred follow-up work (see |
| 127 | + README "Deferred follow-up work"). This spec keeps role names hardcoded. |
| 128 | +- **Changing `hasRole()` semantics** (case-insensitivity, prefix stripping, |
| 129 | + `ROLE_`-authority handling) — explicitly out of scope. |
| 130 | +- **Transforming the roles claim in `src/server/auth.ts`** — the claim is |
| 131 | + already carried through correctly. |
| 132 | + |
| 133 | +## Regression risk |
| 134 | + |
| 135 | +- **Internal:** low. `ROLE_ADMIN` is unused internally; admin pages use |
| 136 | + `ROLE_IT_ADMIN`, which is unchanged. No internal call sites depend on the old |
| 137 | + admin value. |
| 138 | +- **Downstream:** intentional breakage. Consumers importing `ROLE_ADMIN` must |
| 139 | + rename to `ROLE_APP_ADMIN`; consumers relying on the `"ADMIN"` string must |
| 140 | + update their IdP role claim to `"APP-ADMIN"`. This is the point of the fix and |
| 141 | + is communicated via the version bump + changelog. |
| 142 | +- **Tests:** `roles.test.ts` hard-codes the old name/value and must be updated |
| 143 | + in the same change, or the build fails. |
| 144 | + |
| 145 | +## Open questions |
| 146 | + |
| 147 | +None. Supported roles confirmed via issue #3 comment: `APP-ADMIN`, `IT-ADMIN`, |
| 148 | +`APP-USER`. `IT-ADMIN` intentionally has no `APP-` prefix. |
0 commit comments