feat: #7 S2b — scope resolution + enforcement primitives (JWT sgrants, Enforce*/Filter* helpers)#335
Conversation
…es (#7 S2b) Adds GetUserScopedGrants: unnests role permissions across BOTH direct user_role grants and grants inherited via user-group membership, carrying each grant's (scope_kind, scope_id). NULL scope = unscoped (global). The cascade is a property of the grant — every permission a grant materializes inherits the grant's scope. The generated method is HAND-MAINTAINED: migration 010 adds the scope columns inside a DO $$ block that sqlc's schema parser does not read, so it can't resolve them as SELECT outputs (sqlc is lenient on INSERT/DELETE column refs, which is why S2's writes generate, but a SELECT output needs the column type from the catalog). The .sql query is the source of truth; keep the generated method in sync by hand. Testcontainer coverage: direct/unscoped, direct device_group-scoped, inherited-via-user-group, user_group kind, DISTINCT dedup of the same (permission, scope) via two roles, scoped+unscoped coexistence, and the no-grants empty case. Refs #7.
Adds the gate-then-narrow primitives the S5 handlers will consume:
- ScopedGrant / ScopeFilter types + UserContext.ScopedGrants.
- ScopeResolver interface (DeviceGroupsForDevice / UserGroupsForUser) so
the Enforce* helpers stay free functions (like EnforceSelfScope) and
unit-test with a fake.
- DeviceScopeFilterFor / UserScopeFilterFor: reduce the caller's grants
for a permission to {Global | GroupIDs} for list/query narrowing. An
unscoped grant ⇒ Global; a cross-kind scope (user_group scope on a
device permission) grants no access to that target type.
- EnforceDeviceScope / EnforceUserScope: allow when the permission is
held unscoped OR scoped to a group containing the target; deny
otherwise (including held-only-via-cross-kind-scope). Self-contained —
no relevant grant ⇒ deny. Resolver errors fail closed as Internal.
Unit tests cover global / in-scope / out-of-scope / cross-kind /
no-grant / resolver-error and the filter reductions.
Refs #7.
- Claims.ScopedGrants (`sgrants`, omitempty) + GenerateTokens takes the grants and embeds them in the ACCESS token only (like permissions); the interceptor populates UserContext.ScopedGrants from the claim. - The 4 token-gen sites (login, refresh, TOTP, SSO) resolve User.ScopedGrants and pass them, via the shared resolveScopedGrants helper (store.ScopedGrant -> auth.ScopedGrant). An unscoped user's token is byte-identical to before (claim omitted), so this is fully backward compatible. Round-trip tests pin: grants survive the access token, the claim is omitted when empty, and the refresh token carries neither permissions nor scoped grants. Refs #7.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (15)
📝 WalkthroughWalkthroughThis PR introduces scope-based authorization by embedding scoped grants (permission + scope tuple) into JWT claims, passing them through request context, and enforcing scope-based access control. Scoped grants are resolved from the database during token generation and embedded in access tokens for use by authorization handlers. ChangesScoped Grant Authorization
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Third slice of #7 — the scope resolution + enforcement primitives S5's handlers will consume. Purely additive: no existing handler changes its behavior, and an unscoped user's JWT is byte-identical to before. Builds on S2 (#334)'s
(scope_kind, scope_id)columns.Store
UserRepo.ScopedGrants(ctx, userID)+GetUserScopedGrants: unnests role permissions across both directuser_rolegrants and grants inherited via user-group membership, carrying each grant's(scope_kind, scope_id). NULL scope = unscoped/global.DISTINCTcollapses the same(permission, scope)from multiple roles. The cascade is a property of the grant — every permission a grant materializes inherits its scope.DO $$block that sqlc's schema parser doesn't read, so it can't resolve them as SELECT outputs (sqlc is lenient on INSERT/DELETE column refs — why S2's writes generate — but a SELECT output needs the column type from the catalog). The.sqlquery is the source of truth; aNOTEin the generated file documents the sync contract.Auth primitives (gate-then-narrow)
ScopedGrant/ScopeFiltertypes +UserContext.ScopedGrants.ScopeResolverinterface (DeviceGroupsForDevice/UserGroupsForUser) so theEnforce*helpers stay free functions (likeEnforceSelfScope) and unit-test with a fake.DeviceScopeFilterFor/UserScopeFilterFor: reduce the caller's grants for a permission to{Global | GroupIDs}for list/query narrowing. An unscoped grant ⇒ Global; a cross-kind scope (e.g. auser_groupscope on a device permission) grants no access to that target type.EnforceDeviceScope/EnforceUserScope: allow when the permission is held unscoped OR scoped to a group containing the target; deny otherwise. Self-contained (no relevant grant ⇒ deny); resolver errors fail closed asInternal.JWT
Claims.ScopedGrants(sgrants, omitempty), embedded in the access token only (like permissions); the interceptor populatesUserContext.ScopedGrants. The 4 token-gen sites (login / refresh / TOTP / SSO) resolve and embed via a sharedresolveScopedGrantshelper. Omitempty ⇒ an unscoped user's token is unchanged (backward compatible).Tests
DISTINCTdedup, scoped+unscoped coexistence, empty case.go vet ./...,gofmt, full auth suite, andinternal/api(562s testcontainer) green. Local CodeRabbit: no findings.Follow-ups
S5 —
AssignRoleTo*/RevokeRoleFrom*accept and validate the scope tuple end-to-end, and per-device/-user handlers call theseEnforce*helpers (theScopeResolveris wired to the device-group/user-group projections at that point).Closes part of #7.
Summary by CodeRabbit
New Features
Improvements