id-RFC-015 — Authorization groups & conditional scope grant
| Field | Value |
|---|---|
| Status | Draft (proposed 2026-05-30) |
| Author(s) | Rodrigo (with Claude as scribe) |
| Target module | services/foundation/id |
| Related | gate-RFC-002 (IAP consumer), jet#189, jet#191; id-RFC-004 (OAuth/OIDC), id-RFC-006 (identity), id-RFC-007 (admin) |
| Origin | IAP Phase 3 (2026-05-30): closing jet#189 needs Koder ID to grant a scope by group membership, which the current model can't do |
1. Summary
Add a user-groups model to Koder ID plus conditional scope granting: a "gated" OAuth scope (e.g. dev-access) is issued to a token only when the authenticated user is a member of the group bound to that scope. Today scopes are client-requested and subset-checked against the client's allowed scopes — they gate by client, not by user identity. This RFC lets relying parties (the Koder Jet Identity-Aware Proxy first) authorize by identity/group, closing jet#189 and giving the Stack real RBAC at the IdP.
2. Problem
Discovered implementing the IAP (gate-RFC-002):
- Scopes aren't identity-gated. A token's
scope= requested ∩client-allowed (
services/oauth/.../http.go#115). Ifdev-accessis a scope on thekoder-jet-iapclient, every user who logs in via that client receives it. There is no way to say "only members of group dev getdev-access". - Introspect returns
sub+scope, not email/groups. A consumer canonly key on the verified
subor onscope. So the grant decision must happen at the IdP and surface throughscope. - Consequence: the IAP currently runs MVP-open (any active Koder ID
session = dev access), no worse than the email gate, but not real authz.
3. Goals / Non-goals
Goals
- A first-class group entity with membership, scoped per tenant.
- A scope → required-group binding so the OAuth authorize/token flow
grants a gated scope only to members.
- Introspection of a member's token carries the gated scope; a non-member's
does not.
- Admin surface (CLI + admin API) to manage groups & membership.
Non-goals
- Full hierarchical RBAC / permission trees (groups + scope-binding is enough
for the IAP and most needs; revisit if a richer model is required).
- Per-resource ACLs.
4. Design
Reuse-first correction (2026-05-30). Walking the codebase BEFORE building revealed the identity service already has groups + full RBAC:
model.Group/model.GroupMemberwithgroup_repo(CreateAddMemberRemoveMember/ListMembers, both PG and kdb-next), andmodel.{Permission,Role,RoleAssignment}(assignable to a user or a group, scoped tenantorgproject) withrbac_repo+ seededBuiltinRoles. So the "build a user-groups model" half of this RFC is already done — do NOT create newgroups/group_memberstables (would duplicate infrastructure, violatingreuse-first.kmd). Only the scope→group binding + grant filter are new.
4.1 What's reused vs new
Reused (already in services/identity): groups + membership (group_repo), RBAC rolespermissionsassignments (rbac_repo). Membership already keys on the user id (= the OAuth sub = auth-service id, per §5 P1).
New — only one policy table + a small lookup:
scope_grants(tenant_id, scope, required_group_id) -- "dev-access" -> group "dev"A scope listed here is gated — granted only to members of required_group_id. Scopes not listed keep today's behavior (client-requested + subset-checked). (Alternatively bind to an RBAC permission instead of a raw group; group is simpler for the IAP — decide at impl.)
4.2 Grant decision (authorize/token flow)
When building the granted scope set for an authorization code / token (OAuthService.CreateAuthorizationCode / ExchangeAuthorizationCode):
- Start from the requested ∩ client-allowed scopes (unchanged).
- For each scope that appears in
scope_grants: keep it only if theuser is a member of the bound group; otherwise drop it (silently — no error; the RP decides what a missing scope means).
- The resulting scopes flow into the code/token and into introspection as
today.
This is purely additive: gated scopes are filtered out for non-members; no existing scope behavior changes.
4.3 IAP integration (closes jet#189)
- Register
dev-accessas a scope on thekoder-jet-iapclient + ascope_grants("dev-access" -> "dev")row; add the owner to groupdev. - jet sets
KODER_JET_IAP_REQUIRED_SCOPE=dev-access. The IAP already gatesDevAccessonscopeContains(introspect.scope, RequiredScope)(internal/middleware/iap/oauth.go) — so no jet code change is needed; only id-side provisioning + the env flip.
4.4 Admin surface
id-cli groups {create,list},id-cli group-members {add,remove,list},id-cli scope-grant {bind,list}(id-RFC-007 admin service).- Admin API endpoints under
/v1/admin/groups*(bearer-gated, tenant-scoped).
5. Open questions / prerequisites
- P1 — canonical identity/account model — RESOLVED (Phase 0, 2026-05-30).
Traced the
subminting:LoginSubmit→authClient.Login(...)returnsuserID(the Auth service user id) → that becomes the auth code's user_id → the access-tokenSubject(oauth/.../http.go:267/291) → introspectionsub. So the OAuthsubis the Auth-service user id, NOT the identity-serviceusers.id— which is whyrodrigo@koder.devisn't found inkoder_id_identityby sub. Consequences for the implementation:group_members.user_idMUST key on the Auth-service user id (= theOAuth
sub), and the grant filter must resolve membership by it.- Owner's canonical sub =
01KQ5HHNYMS84SRF29X5DM30W6— proven by twoindependent email-gate introspects of his real token (19:11 + 19:50, 2026-05-30); introspect
sub≡ tokenSubject≡ auth-service userID. - ⚠️ *credentials/koder-id.txt
lists01KPXDTSRRSYQ2M53BDAG7WCG8` as the"User ID" — that is the identity-service profile id, NOT the OAuth
sub. Do not gate on it.*(Worth correcting the creds file.) - id DBs are FORCE-RLS multi-tenant; plain
SELECTs as rolekoder_idreturn nothing without a tenant context. Group/membership reads must run in-service with the tenant set (the IAP operates in tenant
koder). - The password/credential store is not an obvious
koder_id_authtable(auth holds
user_identitiesfor social +magic_links); Login's backing store is via the Auth gRPC service — membership management should likewise go through the Auth/identity service, not raw SQL.
- P2 — which service owns groups (identity vs a new authz service)?
Lean: identity service (id-RFC-006), beside users.
- P3 — multi-tenant: groups are tenant-scoped; the IAP operates in tenant
koder. Confirm the IAP's authorize uses that tenant. - P4 — caching: the grant decision adds a membership lookup on the hot
authorize path; cache membership per (tenant, user) with short TTL.
6. Phases
- Phase 0 — DONE (2026-05-30). P1 resolved: the OAuth
subis theAuth-service user id (not identity
users.id); owner sub =01KQ5HHNYMS84SRF29X5DM30W6; DBs are FORCE-RLS multi-tenant; membership must key on the Auth-service id and be managed in-service (§5 P1). - Phase 1 (re-scoped by reuse) — DONE (2026-05-30). groups + RBAC already exist in
services/identity(group_repo,rbac_repo); do NOT rebuild. NEW only: (a) thescope_grantspolicy (one small table or static config), and (b) an identity-service membership lookup exposed to the oauth service over gRPC (GetUserGroups/IsMember—group_repoalready has the data; add the RPC + handler + an identityClient method). Real-PG tests on the id dev VM. - Phase 2 — grant filter: in the oauth scope-build (authorize/login), for
a scope in
scope_grants, keep it only if the user is a member of the bound group (Phase-1 lookup); else drop. Introspect then carries the gated scope only for members. Tests. - Phase 3 — DONE (2026-05-30): deployed + validated live; jet#189 CLOSED. See the "FOURTH pass" note below.
Done (2026-05-30): group
dev=01KSXENXRQ0NBH2MV2QG9WS6G5+ owner added (tools/provision-iap-group);dev-accessallowed on thekoder-jet-iapclient; oauth binary with the grant filter DEPLOYED to id +KODER_ID_SCOPE_GRANTS=dev-access:01KSXENX…set. Normal login verified intact (FlowChatHub authorize/introspect 200). Positive path verified LIVE: owner (member) login → token scopeopenid profile email dev-access. Two bugs found + fixed (committed, not yet redeployed): (1) jet IAP requested scope didn't includeRequiredScope→ would deny everyone (fixed inserver/iap.go); (2) the grant filter was only on the non-MFA login path (fixed: MFASubmit too). The jetRequiredScopeflip was reverted to MVP-open for safety pending these fixes. Remaining: redeploy id oauth (MFA fix) + deploy jet (scope fix) + re-flipKODER_JET_IAP_REQUIRED_SCOPE=dev-access+ a clean negative test (non-member → nodev-access). Then jet#189 closes. Current prod is safe: login intact + IAP MVP-open.
2026-05-30 second attempt — INCONCLUSIVE, reverted to safe. Redeployed both binaries (MFA fix + jet scope fix), re-flipped, and ALSO found+fixed a tenant mismatch: login operates in tenant koder-id (DEFAULT_TENANT), but the group was first provisioned in tenant koder — so membership never matched. Re-provisioned group dev in koder-id (01KSXG7YERW3CYGMW2R542MSHT). The jet scope-fix is confirmed working (auth.koder.dev now redirects with scope=openid profile email dev-access). BUT the grant-filter could not be validated end-to-end: both member and non-member curl logins returned a token carrying dev-access, AND the server logs show scope_count=1 on the auth code while the token/introspect report 4 scopes — an unexplained code-scope vs token-scope mismatch. The curl login harness is itself unreliable (manual flow_params mis-parses the space-delimited scope → scope_count=1 always), so the negative result is not trustworthy either. Root cause is NOT consent (no auto-grant in the login/token path) and NOT the tenant (fixed). Needs an instrumented debug session: add temporary logs in filterGrantedScopes, CreateAuthorizationCode, and the token-issue path to see the real scope at each hop; or validate via the device (real IAP flow) instead of curl. Reverted to SAFE: jet RequiredScope removed (MVP-open), KODER_ID_SCOPE_GRANTS env removed (filter is a total no-op), owner re-added to the koder-id dev group, login verified intact. jet#189 remains OPEN. The deployed binaries (with the filter + jet scope fix) are harmless: nothing requests dev-access while MVP-open.
2026-05-30 third pass — ROOT CAUSE FOUND + FIXED (code-level blocker cleared). The "code-scope1 vs token-scope4" mismatch was NOT a curl artifact and NOT in the grant filter — it was a scope-splitting bug in the oauth login handler. splitScopes split on '+' (services/oauth/internal/handler/login.go), but by the time it runs the scope value is already form/query-decoded — parseFlowParams → url.ParseQuery (and ParseForm) turn both '+' and %20 into a literal space. So splitScopes("openid profile email dev-access") returned a single element (the whole string), which:
- logged as
scope_count=1on the auth code (1 slice element); - made
filterGrantedScopesa complete no-op — it never saw a baredev-accesselement to gate, so both members and non-members kept it; - then
strings.Join(scopes, " ")in the token endpoint re-expanded that oneelement into a 4-scope token string → the phantom "token-scope=4".
Fix: splitScopes now splits on whitespace (strings.Fields), per RFC 6749 §3.3 (scope is space-delimited). Dead splitString helper removed. Added a deterministic regression suite — services/oauth/internal/handler/scope_grant_e2e_test.go — that drives the real pipeline (splitScopes → filterGrantedScopes → CreateAuthorizationCode → /token) with in-memory stores + a fake membership checker and asserts: member keeps dev-access (4-scope token), non-member drops it (3-scope token), and the JWT scope claim equals the auth code's stored scope (no code/token mismatch). This replaces the unreliable manual-curl validation that blocked Phase 3. Built + all oauth tests green on dev-linux-id. Commit: see jet#189/jet#191 session note.
2026-05-30 FOURTH pass — DEPLOYED + VALIDATED LIVE, jet#189 CLOSED. With the root cause fixed, the deploy + flip was done with canaries and proved end-to-end against production:
- Built the fixed oauth binary on
dev-linux-id, deployed to the prodidLXC (backup
koder-id-oauth.bak-pre-splitscopes-*), restartedkoder-id@oauth. Login-SPOF canary green (authorize 200, discovery 200, services connected). - Set
KODER_ID_SCOPE_GRANTS=dev-access:01KSXG7YERW3CYGMW2R542MSHTvia aper-oauth systemd drop-in (
koder-id@oauth.service.d/scope-grants.conf) — NOT the shared env. (Owner sub01KQ5HHNYMS84SRF29X5DM30W6confirmed a member of groupdevin tenantkoder-idvia PG.) - id-layer validation via a real owner authorize→login→token flow
(flow_params taken from the server's own HTML, passed back with
--data-urlencode— eliminating the old harness mis-parse): member token carriedopenid profile email dev-access; a reversible negative (point the grant at a non-existent group) dropped it toopenid profile email. - Flipped
KODER_JET_IAP_REQUIRED_SCOPE=dev-accesson jet (drop-inkoder-jet.service.d/iap.conf, backup.bak-pre-reqscope), restarted jet. Edge healthy (kds/id 200). - Full live IAP round-trip (no device needed): owner (member) → IAP →
login → callback exchange+introspect → edge cookie →
auth.koder.dev200 (admitted). Reversible end-to-end negative (owner made a non-member via the id grant trick) → IAP callback 403 "Your account does not have development access",auth.koder.dev302 (denied). Restored to the correct group; final positive re-confirmed 200.
Production state: id oauth has the splitScopes fix + KODER_ID_SCOPE_GRANTS; jet enforces KODER_JET_IAP_REQUIRED_SCOPE=dev-access on the lone mode=sso vhost (auth.koder.dev). jet#189 CLOSED. Note: the OAuth authorize path does NOT subset-check requested scopes against client-allowed scopes, so the gate works specifically because dev-access is a gated scope dropped for non-members — a non-gated scope would always pass (documented so future gates use a gated scope, never a bare REQUIRED_SCOPE).
Residual Phase 3 polish (non-blocking, tracked in jet#192): /k-housekeep drift audit should check auth_gate.mode (not just enabled), and an operational runbook (auth-gate) for the gate.
7. Trade-offs
- Groups + scope-binding is a lean RBAC, not a full permission system —
deliberately (Quality without over-engineering). Richer models can layer on later via additional
scope_grantssemantics. - The grant filter touches the OAuth hot path — mitigated by membership
caching (P4).
8. References
rfcs/gate-RFC-002-identity-aware-proxy.kmd(the consumer)rfcs/id-RFC-004-oauth2-oidc-service.md,id-RFC-006-identity-service.md,id-RFC-007-admin-service-and-cli.mdspecs/auth/oauth-flow.kmd- jet#189 (the gap), jet#191 (IAP — live, MVP-open)