Skip to content

feat: #7 S2b — scope resolution + enforcement primitives (JWT sgrants, Enforce*/Filter* helpers)#335

Merged
PaulDotterer merged 3 commits into
mainfrom
feat/7-s2b-scope-resolution-enforcement
Jun 10, 2026
Merged

feat: #7 S2b — scope resolution + enforcement primitives (JWT sgrants, Enforce*/Filter* helpers)#335
PaulDotterer merged 3 commits into
mainfrom
feat/7-s2b-scope-resolution-enforcement

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

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 direct user_role grants and grants inherited via user-group membership, carrying each grant's (scope_kind, scope_id). NULL scope = unscoped/global. DISTINCT collapses the same (permission, scope) from multiple roles. The cascade is a property of the grant — every permission a grant materializes inherits its scope.
  • The generated method is hand-maintained: migration 010 adds the scope columns inside a 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 .sql query is the source of truth; a NOTE in the generated file documents the sync contract.

Auth primitives (gate-then-narrow)

  • 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 (e.g. a 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. Self-contained (no relevant grant ⇒ deny); resolver errors fail closed as Internal.

JWT

  • Claims.ScopedGrants (sgrants, omitempty), embedded in the access token only (like permissions); the interceptor populates UserContext.ScopedGrants. The 4 token-gen sites (login / refresh / TOTP / SSO) resolve and embed via a shared resolveScopedGrants helper. Omitempty ⇒ an unscoped user's token is unchanged (backward compatible).

Tests

  • Store (testcontainer): direct/unscoped, direct device_group-scoped, inherited-via-user-group, user_group kind, DISTINCT dedup, scoped+unscoped coexistence, empty case.
  • Auth (unit): global / in-scope / out-of-scope / cross-kind / no-grant / resolver-error + the filter reductions.
  • JWT: scoped grants round-trip the access token, the claim is omitted when empty, and the refresh token carries neither permissions nor scoped grants.
  • go vet ./..., gofmt, full auth suite, and internal/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 these Enforce* helpers (the ScopeResolver is wired to the device-group/user-group projections at that point).

Closes part of #7.

Summary by CodeRabbit

  • New Features

    • Introduced scoped permissions system enabling administrators to assign permissions limited to specific device groups or user groups, rather than globally to users.
    • Access tokens now carry scope information to enforce fine-grained authorization at runtime.
  • Improvements

    • Authorization checks now validate both permission and scope boundaries during request processing.

…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.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d626d511-8f64-477d-b327-441a1fc3d761

📥 Commits

Reviewing files that changed from the base of the PR and between 296754c and 4207fe7.

⛔ Files ignored due to path filters (1)
  • internal/store/generated/roles.sql.go is excluded by !**/generated/**
📒 Files selected for processing (15)
  • internal/api/auth_handler.go
  • internal/api/control_boundary_test.go
  • internal/api/sso_handler.go
  • internal/api/totp_handler.go
  • internal/auth/context.go
  • internal/auth/interceptor.go
  • internal/auth/interceptor_test.go
  • internal/auth/jwt.go
  • internal/auth/jwt_test.go
  • internal/auth/scope.go
  • internal/auth/scope_test.go
  • internal/projectors/scoped_grants_resolution_test.go
  • internal/store/postgres/user.go
  • internal/store/queries/roles.sql
  • internal/store/user.go

📝 Walkthrough

Walkthrough

This 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.

Changes

Scoped Grant Authorization

Layer / File(s) Summary
Scoped Grant data model and storage
internal/store/user.go, internal/store/queries/roles.sql, internal/store/postgres/user.go
New ScopedGrant struct and UserRepo.ScopedGrants method load (permission, scope_kind, scope_id) tuples from database; SQL query aggregates direct role grants and user-group-inherited grants.
JWT Claims and token generation updates
internal/auth/jwt.go, internal/auth/jwt_test.go
Claims struct gains ScopedGrants field (omitted when empty); GenerateTokens signature now accepts scoped grants and embeds them in access tokens only; new tests validate round-trip, omission when absent, and exclusion from refresh tokens.
User context enrichment and interceptor
internal/auth/context.go, internal/auth/interceptor.go, internal/auth/interceptor_test.go
UserContext field added for scoped grants; AuthInterceptor populates it from JWT claims; all interceptor tests updated to match new GenerateTokens signature.
Scope-based authorization logic
internal/auth/scope.go, internal/auth/scope_test.go
New ScopeFilter, ScopedGrant, and ScopeResolver types; scopeFilterFor derives filters from user context; EnforceDeviceScope and EnforceUserScope validate authentication, compute filters, resolve group membership, and enforce scope intersection; comprehensive unit tests with in-memory test double.
Scoped grants resolution in token handlers
internal/api/auth_handler.go, internal/api/sso_handler.go, internal/api/totp_handler.go, internal/api/control_boundary_test.go
Login, RefreshToken, VerifyLoginTOTP, and SSOCallback now resolve scoped grants from database and pass them into token generation; resolveScopedGrants helper maps rows to []auth.ScopedGrant; test fixtures updated to pass nil scoped-grants argument.
Integration test for scoped grants projection
internal/projectors/scoped_grants_resolution_test.go
Validates UserRepo.ScopedGrants across direct/inherited grants, scope preservation, deduplication, and coexistence scenarios; includes test helpers for deterministic assertions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manchtools/power-manage-server#334: Adds and wires scope_kind/scope_id into role/user-group projection storage, which this PR reads via UserRepo.ScopedGrants to mint JWTs with embedded ScopedGrants.
  • manchtools/power-manage-server#333: Modifies AuthInterceptor.WrapUnary authorization behavior via procedureAlternatives, which this PR also updates to include UserContext.ScopedGrants from JWT claims.
  • manchtools/power-manage-server#268: Updates SSO/TOTP token-minting flows; this PR extends those flows to resolve and pass scoped grants into the new GenerateTokens signature.

Poem

🐰 A rabbit hops through scopes so grand,
Carving permissions, scope by scope and hand,
From database rows to JWT claims, they dance,
Device-groups and user-groups in sweet advance,
Authorization's path now measured with care—
Scoped grants blooming everywhere! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary changes: adding scope resolution and enforcement primitives along with JWT scoped grants support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/7-s2b-scope-resolution-enforcement

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant