auth: additive roles — a principal keeps every mapped group's access - #40
Merged
Conversation
Closes #28. resolveOidcRole returned ONE role: an explicit users.role_id, else the highest group mapping. Harmless while roles nested by tier (viewer subset of editor subset of admin) because the winner contained the loser. It stops being harmless the moment roles are incomparable, which is exactly what named tool sets (#27) introduce: someone in Techs and Billing would get the higher-tier role and silently lose the other's surface. A Principal now carries every role it holds and the envelope is their UNION: PolicyService.allowsAny evaluates each role on its own grant, default tier and overrides, so being added to a group can only widen access. A per-role deny closes that role's path, not the others' — subtracting for everyone stays the global kill switch's job, which the tests pin. Splitting "the admin chose this" from "this came from a group" needed schema v5: users.role_source marks an explicit override (which still REPLACES groups, that being its purpose) and user_login_roles remembers what the last login's group claims mapped to, since the cookie and gateway-JWT paths see no group claims and previously leaned on the login path writing role_id. Existing non-null role_id rows migrate to role_source='admin' deliberately: on a live deployment they were either set from the Users tab or written back unchanged by the login path, so nobody's access moves on upgrade — db/migrate-v5.test.ts builds a v4 database by hand and asserts exactly that, plus idempotency. Also in this batch, all from today's diagnosis session: - GET /api/me/sessions — my connected clients and whether ANY holds the notification stream, rendered next to Apply now on /me. Without a stream the gateway cannot push list_changed at all, which is precisely when the button is the only cure; better shown than explained. - The /me hint spells out the whole model: a switch bites immediately, the list is fetched at connect time, change first then Apply, and if a client still disagrees it caches above the protocol — new chat or reconnect the connector. - [mcp] logging of tools/list size and tools/call decisions (names and verdicts only, never arguments). "Did the request even reach the gateway?" cost an afternoon today; now it is one grep. - gw_status reported the MODE in its `version` field, so it claimed to be running version "integrated". Both fields now, with a test. - /api/users reports loginRoles and effectiveRoles; /me shows every role. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
selic
pushed a commit
that referenced
this pull request
Aug 14, 2026
Minor (#40): additive roles (#28) — a principal keeps every mapped group's access instead of only the highest-tier role, with schema v5 separating an admin's explicit override from group-derived roles so no existing access moves on upgrade. Plus GET /api/me/sessions behind the Apply now button, [mcp] call logging (names and verdicts only), and gw_status reporting version and mode separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #28.
The problem
resolveOidcRolereturned one role: an explicitusers.role_id, else thehighest group mapping. That was harmless while roles nested by tier
(
viewer ⊂ editor ⊂ admin) — the winner contained the loser. It stops beingharmless the moment roles are incomparable, which is exactly what named tool sets
(#27) introduce: a person in Techs and Billing gets the higher-tier role and
silently loses the other's surface.
The model
A
Principalcarries every role it holds, and the envelope is their union.PolicyService.allowsAnyevaluates each role on its own grant, default tier andoverrides, so adding a group can only ever widen access.
A per-role
denyoverride closes that role's path, not the others' —subtracting for everyone remains the global kill switch's job. Both halves are
pinned by tests.
Schema v5
Splitting "the admin chose this" from "this came from a group" needed storage:
users.role_sourcemarks an explicit override, which still replacesgroup-derived roles (that is what an override is for);
user_login_rolesremembers what the last login's group claims mapped to,because the cookie and gateway-JWT paths see no group claims and previously
leaned on the login path writing
role_id.Upgrade safety: existing non-null
role_idrows migrate torole_source='admin'deliberately — on a live deployment they were either setfrom the Users tab or written back unchanged by the login path, so nobody's
access moves on upgrade.
db/migrate-v5.test.tsbuilds a v4-shaped database byhand and asserts exactly that, plus idempotency on a re-run.
Also in this batch (today's diagnosis session)
GET /api/me/sessions— my connected clients and whether any holds thenotification stream, rendered next to Apply now on
/me. With no streamthe gateway cannot push
list_changedat all, which is precisely when thebutton is the only cure; better shown than explained.
/mehint now spells out the whole model: a switch bites immediately, thelist is fetched at connect time, change first then Apply — and if a client
still disagrees it is caching above the protocol (new chat, or reconnect the
connector).
[mcp]logging oftools/listsize andtools/calldecisions — names andverdicts only, never arguments. "Did the request even reach the gateway?"
cost an afternoon today; now it is one grep.
gw_statusreported the MODE in itsversionfield, so it claimed to berunning version
"integrated". Both fields now, with a test./api/usersreportsloginRolesandeffectiveRoles;/meshows every role.Tests
domain/additive-roles.test.ts— two deliberately incomparable roles (techs:tickets, billing: invoices): the union keeps both surfaces, takes the most
permissive ceiling per upstream, a
nonegrant in one role doesn't drag theunion down, a per-role deny is escapable through the other role while the kill
switch is not,
isAdminis true if any role is, and the session binding keycovers the whole role set order-independently.
db/migrate-v5.test.ts— the upgrade path above.db/repo.test.ts—resolveOidcRolesunion, unmapped groups ignored, no-groupsfallback to stored login roles, replace-all semantics, override replaces, and
clearing the override falls back to the remembered groups.
npm run buildclean,npm test280 passed, suite run twice.🤖 Generated with Claude Code