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):

  1. Scopes aren't identity-gated. A token's scope = requested ∩

    client-allowed (services/oauth/.../http.go #115). If dev-access is a scope on the koder-jet-iap client, every user who logs in via that client receives it. There is no way to say "only members of group dev get dev-access".

  2. Introspect returns sub + scope, not email/groups. A consumer can

    only key on the verified sub or on scope. So the grant decision must happen at the IdP and surface through scope.

  3. 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.GroupMember with group_repo (CreateAddMemberRemoveMember/ListMembers, both PG and kdb-next), and model.{Permission,Role,RoleAssignment} (assignable to a user or a group, scoped tenantorgproject) with rbac_repo + seeded BuiltinRoles. So the "build a user-groups model" half of this RFC is already done — do NOT create new groups/group_members tables (would duplicate infrastructure, violating reuse-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):

  1. Start from the requested ∩ client-allowed scopes (unchanged).
  2. For each scope that appears in scope_grants: keep it only if the

    user is a member of the bound group; otherwise drop it (silently — no error; the RP decides what a missing scope means).

  3. 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-access as a scope on the koder-jet-iap client + a

    scope_grants("dev-access" -> "dev") row; add the owner to group dev.

  • jet sets KODER_JET_IAP_REQUIRED_SCOPE=dev-access. The IAP already gates

    DevAccess on scopeContains(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 sub minting: LoginSubmitauthClient.Login(...) returns userID (the Auth service user id) → that becomes the auth code's user_id → the access-token Subject (oauth/.../http.go:267/291) → introspection sub. So the OAuth sub is the Auth-service user id, NOT the identity-service users.id — which is why rodrigo@koder.dev isn't found in koder_id_identity by sub. Consequences for the implementation:

    • group_members.user_id MUST key on the Auth-service user id (= the

      OAuth sub), and the grant filter must resolve membership by it.

    • Owner's canonical sub = 01KQ5HHNYMS84SRF29X5DM30W6 — proven by two

      independent email-gate introspects of his real token (19:11 + 19:50, 2026-05-30); introspect sub ≡ token Subject ≡ auth-service userID.

    • ⚠️ *credentials/koder-id.txt lists 01KPXDTSRRSYQ2M53BDAG7WCG8` 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 role koder_id

      return 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_auth table

      (auth holds user_identities for 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 sub is the

    Auth-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) the scope_grants policy (one small table or static config), and (b) an identity-service membership lookup exposed to the oauth service over gRPC (GetUserGroups/IsMembergroup_repo already 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-access allowed on the koder-jet-iap client; 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 scope openid profile email dev-access. Two bugs found + fixed (committed, not yet redeployed): (1) jet IAP requested scope didn't include RequiredScope → would deny everyone (fixed in server/iap.go); (2) the grant filter was only on the non-MFA login path (fixed: MFASubmit too). The jet RequiredScope flip was reverted to MVP-open for safety pending these fixes. Remaining: redeploy id oauth (MFA fix) + deploy jet (scope fix) + re-flip KODER_JET_IAP_REQUIRED_SCOPE=dev-access + a clean negative test (non-member → no dev-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 — parseFlowParamsurl.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=1 on the auth code (1 slice element);
  • made filterGrantedScopes a complete no-op — it never saw a bare

    dev-access element to gate, so both members and non-members kept it;

  • then strings.Join(scopes, " ") in the token endpoint re-expanded that one

    element 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 (splitScopesfilterGrantedScopesCreateAuthorizationCode/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:

  1. Built the fixed oauth binary on dev-linux-id, deployed to the prod id

    LXC (backup koder-id-oauth.bak-pre-splitscopes-*), restarted koder-id@oauth. Login-SPOF canary green (authorize 200, discovery 200, services connected).

  2. Set KODER_ID_SCOPE_GRANTS=dev-access:01KSXG7YERW3CYGMW2R542MSHT via a

    per-oauth systemd drop-in (koder-id@oauth.service.d/scope-grants.conf) — NOT the shared env. (Owner sub 01KQ5HHNYMS84SRF29X5DM30W6 confirmed a member of group dev in tenant koder-id via PG.)

  3. 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 carried openid profile email dev-access; a reversible negative (point the grant at a non-existent group) dropped it to openid profile email.

  4. Flipped KODER_JET_IAP_REQUIRED_SCOPE=dev-access on jet (drop-in

    koder-jet.service.d/iap.conf, backup .bak-pre-reqscope), restarted jet. Edge healthy (kds/id 200).

  5. Full live IAP round-trip (no device needed): owner (member) → IAP →

    login → callback exchange+introspect → edge cookie → auth.koder.dev 200 (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.dev 302 (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_grants semantics.

  • 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.md

  • specs/auth/oauth-flow.kmd
  • jet#189 (the gap), jet#191 (IAP — live, MVP-open)