Skip to content

feat: #7 S5 — scope-aware role assignment + revocation (AssignRole/RevokeRole scope tuple, escalation prevention)#337

Merged
PaulDotterer merged 3 commits into
mainfrom
feat/7-s5-role-grant-scope-handlers
Jun 10, 2026
Merged

feat: #7 S5 — scope-aware role assignment + revocation (AssignRole/RevokeRole scope tuple, escalation prevention)#337
PaulDotterer merged 3 commits into
mainfrom
feat/7-s5-role-grant-scope-handlers

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Fourth slice of #7 — the grant-authoring half: AssignRoleTo{User,UserGroup} and RevokeRoleFrom{User,UserGroup} now consume the (scope_kind, scope_id) tuple. Server-only — the proto fields, RoleGrantScopeKind enum, AssignRoleScope permission, PermissionTargetKind, and the scope-aware projector writes all already existed (S1/#333, S2/#334); the handlers ignored scope until now. Builds on S2b (#335)'s UserContext.ScopedGrants.

Assign validation (validateAssignGrantScope, shared by both handlers)

  • paired-or-neitherscope_kind/scope_id set together or not at all;
  • AssignRoleScope gate for any scoped grant;
  • scope-group existence (device_group / user_group);
  • per-role target_kind match — every permission in the role must accept the scope kind (device_group scope ⇒ all perms TargetDevice). Derived self-discoveringly from the permission registry (TargetKindFor), not a hardcoded list, so a new permission can't silently slip the check.

Escalation prevention (auth, over the actor's own ScopedGrants)

  • EnforceGrantScopeAuthority — a scope-limited admin may attach only a scope its own AssignRoleScope authority covers (global ⇒ any; scoped ⇒ equal-id match; sub-group "narrower" containment is a documented future refinement).
  • EnforceUnscopedGrantAuthority — a scope-limited admin may not mint an unscoped, fleet-wide grant.

In V1 AssignRoleScope is org-tier (held only by unscoped admins), so the bounded path isn't reachable yet — this is defense-in-depth ahead of per-scope admins.

Revoke

Targets a SPECIFIC (subject, role, scope) grant via new NULL-aware existence checks (UserHasScopedRole / UserGroupHasScopedRole, hand-written like S2's scope queries — sqlc can't read migration 010's DO-block columns, see #336). Revoking a grant that exists only at a different scope is rejected (FailedPrecondition) rather than silently no-op'd; nothing-there stays an idempotent no-op.

Test fidelity fix

AdminContext now carries every admin permission as an unscoped grant, mirroring production — where the flat permission list is derived from grants — so the scope-authority checks see an org admin as globally unrestricted rather than as having no scope authority. (Additive; existing tests unaffected.)

Tests

  • auth units: target-kind match (incl. cross-kind + TargetUnspecified rejection), both escalation bounds.
  • handler integration (user + user-group paths): scoped grant end-to-end, AssignRoleScope gate, target_kind mismatch, missing scope group, paired-or-neither, escalation out-of-scope + unscoped denial, scope-targeted revoke + wrong-scope rejection.
  • go vet ./..., gofmt, go build, internal/auth, internal/projectors, all targeted scope tests green. Full internal/api: 312 pass / 0 fail (local wall-clock exceeded the default timeout under heavy back-to-back container churn; CI's -timeout 20m fresh runner ran the identical-size suite in 562s at S2b). Local CodeRabbit: no findings.

Follow-ups

The enforcement-consuming half — per-device/-user handlers calling EnforceDeviceScope/EnforceUserScope with a ScopeResolver wired to the projections (S6), the per-scope reconciler, and the TTY-gated web scope picker (S9).

Closes part of #7.

Summary by CodeRabbit

  • New Features

    • Scoped role assignments and revocations: roles can be granted/revoked limited to specific device or user group scopes; assignment/revoke operations are scope-aware and include scope details in audit events.
    • Stronger validation and authority checks to prevent invalid or unauthorized scoped grants.
  • Tests

    • Expanded end-to-end and unit tests covering scoped grant/revoke semantics, authority enforcement, and scope-edge cases.

Wires the (scope_kind, scope_id) tuple through AssignRoleTo{User,UserGroup}
and RevokeRoleFrom{User,UserGroup} — the grant-authoring half of #7. The
proto fields, RoleGrantScopeKind enum, AssignRoleScope permission, and the
scope-aware projector writes already existed (S1/S2); the handlers ignored
them until now.

Assign validation (validateAssignGrantScope, shared by both handlers):
- paired-or-neither (scope_kind and scope_id set together or not at all);
- AssignRoleScope gate for any scoped grant;
- scope-group existence (device_group / user_group);
- per-role target_kind match — every permission in the role must accept
  the scope kind (device_group scope => all perms TargetDevice). Derived
  self-discoveringly from the permission registry (TargetKindFor), not a
  hardcoded list, so a new permission can't silently slip through.

Escalation prevention (auth, gate-then-narrow over the actor's own
ScopedGrants from S2b):
- EnforceGrantScopeAuthority: a scope-limited admin may attach ONLY a
  scope its own AssignRoleScope authority covers (global => any; scoped =>
  equal-id match; sub-group 'narrower' containment is a future refinement);
- EnforceUnscopedGrantAuthority: a scope-limited admin may not mint an
  unscoped (fleet-wide) grant.

Revoke targets a SPECIFIC (subject, role, scope) grant via new NULL-aware
existence checks (UserHasScopedRole / UserGroupHasScopedRole, hand-written
like S2's scope queries — sqlc can't see the DO-block columns). Revoking a
grant that exists only at a different scope is rejected (FailedPrecondition)
rather than silently no-op'd; nothing-there stays an idempotent no-op.

Test fidelity: AdminContext now carries every admin permission as an
unscoped grant, mirroring production where the flat permission list is
DERIVED from grants — so the scope-authority checks see an org admin as
globally unrestricted rather than as having no scope authority.

Tests: auth helper units (target-kind match, both escalation bounds);
handler integration (scoped grant end-to-end, AssignRoleScope gate,
target_kind mismatch, missing scope group, paired-or-neither, escalation
out-of-scope + unscoped denial, scope-targeted revoke) for both the user
and user-group paths.

Refs #7.
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PaulDotterer, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 15 minutes and 43 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 056bcc0a-ad25-4643-802b-5158764b86d5

📥 Commits

Reviewing files that changed from the base of the PR and between 6e8cd29 and beec632.

📒 Files selected for processing (4)
  • internal/api/role_handler.go
  • internal/api/role_scope_handler_test.go
  • internal/api/user_group_handler.go
  • internal/api/user_group_handler_test.go
📝 Walkthrough

Walkthrough

This PR adds scope tuple normalization and validation, permission scopability checks, and scope-authority enforcement; it applies these to user and user-group role assign/revoke handlers, emits scope-aware events and stream IDs, and adds scoped existence SQL queries and tests.

Changes

Scoped Role Grants

Layer / File(s) Summary
Permission target-kind and scope authority
internal/auth/permissions.go, internal/auth/scope.go, internal/auth/scope_test.go
Adds cached permission target-kind lookup and exported auth helpers to validate permission scopability and whether a caller may grant scoped or unscoped role grants.
Scope tuple validation and role scopability
internal/api/scope_grant.go
Normalizes proto scope-kind enums, enforces paired-or-neither scope params, checks role permission scopability with the scope kind, enforces caller authority for scoped/unscoped grants, and verifies the referenced scope group exists.
Test auth context with scoped grants
internal/testutil/factories_user.go
Adds AuthContextScoped and updates AdminContext to produce auth contexts containing flat permissions and scoped grants for testing scope enforcement.
User role assign/revoke with scopes
internal/api/role_handler.go, internal/store/queries/roles.sql, internal/api/role_scope_handler_test.go
User role handlers validate and normalize optional scope tuples, reject unscopable roles for a scope kind, adjust idempotency semantics for scoped assigns, emit UserRoleAssigned/UserRoleRevoked with ScopeKind/ScopeID and scope-aware stream IDs, and use a new UserHasScopedRole query to check scoped grant existence. Tests cover scoped assign/revoke and error cases.
User-group role assign/revoke with scopes
internal/api/user_group_handler.go, internal/store/queries/user_groups.sql, internal/api/user_group_scope_handler_test.go
User-group role handlers accept optional scope tuples, validate scopability and authority, emit scope-aware events/stream IDs, use UserGroupHasScopedRole to target specific scoped grants on revoke, and include tests verifying persistence and revoke semantics for device-group scoped grants.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

"🐰 I hopped through scopes with justice bright,
Bound grants to groups and kept IDs tight,
Permissions checked where they must reside,
Events now carry scope with pride,
Hooray — no more grants that roam at night!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% 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 main change: implementing scope-aware role assignment and revocation with escalation prevention, which is the core focus across all modified handler files.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/7-s5-role-grant-scope-handlers

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/api/role_handler.go (1)

454-457: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Session invalidation should be conditional on actual revocation.

The session version is bumped unconditionally, even when the grant doesn't exist (idempotent no-op case at lines 439-452). This is inconsistent with AssignRoleToUser (lines 362-369), which only bumps when assignedAny is true. Unnecessary session bumps force users to re-login even when no state changed.

🔒 Proposed fix to make session bump conditional
 	if hasScoped {
 		streamID := req.Msg.UserId + ":" + req.Msg.RoleId
 		if scopeKind != "" {
 			streamID += ":" + scopeID
 		}
 		if err := appendEvent(ctx, h.store, h.logger, store.Event{
 			StreamType: "user_role",
 			StreamID:   streamID,
 			EventType:  string(eventtypes.UserRoleRevoked),
 			Data: payloads.UserRoleRevoked{
 				UserID:    req.Msg.UserId,
 				RoleID:    req.Msg.RoleId,
 				ScopeKind: sk,
 				ScopeID:   si,
 			},
 			ActorType: "user",
 			ActorID:   userCtx.ID,
 		}, "failed to revoke role"); err != nil {
 			return nil, err
 		}
+
+		// Bump user's session version to invalidate cached permissions
+		if err := h.bumpUserSessionVersion(ctx, req.Msg.UserId, userCtx.ID); err != nil {
+			return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to invalidate user session after role revocation")
+		}
 	} else {
 		// The targeted grant doesn't exist. If the role IS assigned at a
 		// different scope, the caller targeted the wrong grant — surface
 		// that rather than silently no-op (`#7` S5). If nothing exists at
 		// all, fall through as an idempotent no-op.
 		hasAny, err := h.store.Repos().Role.UserHasRole(ctx, req.Msg.UserId, req.Msg.RoleId)
 		if err != nil {
 			return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to check role assignment")
 		}
 		if hasAny {
 			return nil, apiErrorCtx(ctx, ErrRoleNotFound, connect.CodeFailedPrecondition,
 				"role is not assigned at the specified scope (it is assigned at a different scope)")
 		}
 	}

-	// Bump user's session version to invalidate cached permissions
-	if err := h.bumpUserSessionVersion(ctx, req.Msg.UserId, userCtx.ID); err != nil {
-		return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to invalidate user session after role revocation")
-	}
-
 	return connect.NewResponse(&pm.RevokeRoleFromUserResponse{}), nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/role_handler.go` around lines 454 - 457, The session bump is
happening unconditionally after attempting to revoke a role; change it to only
bump when the revocation actually removed a grant (like AssignRoleToUser does).
Locate the revoke flow in role_handler.go where bumpUserSessionVersion(ctx,
req.Msg.UserId, userCtx.ID) is called and make it conditional on the flag that
tracks whether any grant was revoked (e.g., revokedAny or similar variable set
in the revocation loop). Only call h.bumpUserSessionVersion(...) when that flag
is true to avoid forcing logouts on idempotent no-ops.
🧹 Nitpick comments (2)
internal/api/role_handler.go (2)

449-450: ⚡ Quick win

Error enum name doesn't match the failure mode.

ErrRoleNotFound is used for a scope mismatch case, but the role itself exists—just not at the specified scope. This could confuse debugging and error handling code that expects ErrRoleNotFound to mean the role entity doesn't exist. Consider using a more specific error enum like ErrScopeMismatch or ErrGrantNotFoundAtScope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/role_handler.go` around lines 449 - 450, The return uses
ErrRoleNotFound for a scope-mismatch case; update this to a more accurate error
enum (e.g., ErrScopeMismatch or ErrGrantNotFoundAtScope) so callers can
distinguish "role exists but wrong scope" from "role missing." Replace
ErrRoleNotFound in the apiErrorCtx call that returns
connect.CodeFailedPrecondition (the return line invoking apiErrorCtx) with the
new enum, and ensure the new error is defined and used consistently where
scope-mismatch semantics are required.

321-359: ⚖️ Poor tradeoff

Note: Scoped grant re-assigns bypass idempotency pre-check.

For unscoped grants, UserHasRole pre-checks and skips redundant assigns (lines 326-337), so assignedAny stays false and the session isn't bumped. For scoped grants, no pre-check is performed; the event is always appended (relying on the projector's ON CONFLICT DO NOTHING), and assignedAny is set to true (line 359), causing the session to be bumped even for redundant scoped assigns. This asymmetry means idempotent scoped assign retries will unnecessarily invalidate sessions, contrary to the goal stated at lines 363-364.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/role_handler.go` around lines 321 - 359, The code skips the
idempotency pre-check only for scoped grants, causing redundant scoped
re-assigns to set assignedAny and bump sessions; add a scoped pre-check like the
unscoped branch: when scopeKind != "" call the appropriate DB check (e.g.,
reuse/implement q.UserHasRole with scope parameters or add a new query such as
UserHasRoleWithScope) using req.Msg.UserId, roleID, scopeKind/scopeID (use
scopePtrs(scopeKind, scopeID) values if needed) and if it already exists
continue without appending the event; only appendEvent and set assignedAny =
true when the scoped grant did not already exist (refer to scopePtrs,
appendEvent, assignedAny, and UserHasRole/UserHasRoleWithScope).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/scope_grant.go`:
- Around line 14-24: scopeKindString currently returns "" for any unknown enum
which makes malformed/future scope_kind silently unscoped; change
scopeKindString(pm.RoleGrantScopeKind) to return (string, error) and map
ROLE_GRANT_SCOPE_KIND_UNSPECIFIED -> ("", nil) and the two known kinds to their
auth.ScopeKind strings, but return an error (InvalidArgument) for any other enum
value; update all callers (the grant-parsing logic around the existing handling
at lines ~46-60 that expect "" to mean unscoped) to handle the error path and
propagate/return InvalidArgument when scopeKindString returns an error.
- Around line 68-74: The current code calls scopeGroupExists(ctx, q, scopeKind,
scopeID) before auth.EnforceGrantScopeAuthority(ctx, scopeKind, scopeID), which
leaks existence information to scope-limited callers; swap the checks so you
call auth.EnforceGrantScopeAuthority first and return its error immediately if
any, then call scopeGroupExists; update the block around scopeGroupExists and
EnforceGrantScopeAuthority to enforce scope authority before checking group
existence (functions: auth.EnforceGrantScopeAuthority and scopeGroupExists)
while preserving the existing return values and error handling.

---

Outside diff comments:
In `@internal/api/role_handler.go`:
- Around line 454-457: The session bump is happening unconditionally after
attempting to revoke a role; change it to only bump when the revocation actually
removed a grant (like AssignRoleToUser does). Locate the revoke flow in
role_handler.go where bumpUserSessionVersion(ctx, req.Msg.UserId, userCtx.ID) is
called and make it conditional on the flag that tracks whether any grant was
revoked (e.g., revokedAny or similar variable set in the revocation loop). Only
call h.bumpUserSessionVersion(...) when that flag is true to avoid forcing
logouts on idempotent no-ops.

---

Nitpick comments:
In `@internal/api/role_handler.go`:
- Around line 449-450: The return uses ErrRoleNotFound for a scope-mismatch
case; update this to a more accurate error enum (e.g., ErrScopeMismatch or
ErrGrantNotFoundAtScope) so callers can distinguish "role exists but wrong
scope" from "role missing." Replace ErrRoleNotFound in the apiErrorCtx call that
returns connect.CodeFailedPrecondition (the return line invoking apiErrorCtx)
with the new enum, and ensure the new error is defined and used consistently
where scope-mismatch semantics are required.
- Around line 321-359: The code skips the idempotency pre-check only for scoped
grants, causing redundant scoped re-assigns to set assignedAny and bump
sessions; add a scoped pre-check like the unscoped branch: when scopeKind != ""
call the appropriate DB check (e.g., reuse/implement q.UserHasRole with scope
parameters or add a new query such as UserHasRoleWithScope) using
req.Msg.UserId, roleID, scopeKind/scopeID (use scopePtrs(scopeKind, scopeID)
values if needed) and if it already exists continue without appending the event;
only appendEvent and set assignedAny = true when the scoped grant did not
already exist (refer to scopePtrs, appendEvent, assignedAny, and
UserHasRole/UserHasRoleWithScope).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9bc553f6-cd5f-4c25-96c4-777debf58d79

📥 Commits

Reviewing files that changed from the base of the PR and between ce8ed65 and 78d8c85.

⛔ Files ignored due to path filters (2)
  • internal/store/generated/roles.sql.go is excluded by !**/generated/**
  • internal/store/generated/user_groups.sql.go is excluded by !**/generated/**
📒 Files selected for processing (11)
  • internal/api/role_handler.go
  • internal/api/role_scope_handler_test.go
  • internal/api/scope_grant.go
  • internal/api/user_group_handler.go
  • internal/api/user_group_scope_handler_test.go
  • internal/auth/permissions.go
  • internal/auth/scope.go
  • internal/auth/scope_test.go
  • internal/store/queries/roles.sql
  • internal/store/queries/user_groups.sql
  • internal/testutil/factories_user.go

Comment thread internal/api/scope_grant.go
Comment thread internal/api/scope_grant.go Outdated
…xistence (#7 S5)

CodeRabbit review of #337:

1. (Major) scopeKindString mapped any out-of-range / future RoleGrantScopeKind
   enum to "", so a malformed scope_kind with an empty scope_id slipped
   through paired-or-neither and became an unscoped, fleet-wide grant. It now
   returns (string, ok); only the explicit UNSPECIFIED maps to "" and every
   unknown value is rejected as InvalidArgument. Applied to all three call
   sites — the assign validator and both revoke handlers.

2. (Minor) validateAssignGrantScope checked scope-group existence (NotFound)
   before scope authority (PermissionDenied), letting a scope-limited caller
   use group existence as an oracle for ids outside its authority. Authority
   is now enforced first, so an out-of-scope id is PermissionDenied whether or
   not the group exists.

Regression tests pin both: an unknown enum yields InvalidArgument and creates
no grant; a scope-limited caller targeting an id outside its authority gets
PermissionDenied regardless of the group's existence.

Refs #7.
@PaulDotterer

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/api/role_handler.go (1)

452-454: 💤 Low value

Semantic mismatch: ErrRoleNotFound used for scope-mismatch error.

The error sentinel ErrRoleNotFound is used when the role IS assigned but at a different scope. Consider introducing a dedicated sentinel (e.g., ErrScopeMismatch) or reusing a more accurate one, since the role was found — just not at the targeted scope. The Connect code (CodeFailedPrecondition) and message are correct, so this is purely a semantic clarity concern for internal error tracking/metrics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/role_handler.go` around lines 452 - 454, The current return uses
the ErrRoleNotFound sentinel while the role exists but at a different scope;
introduce a new sentinel ErrScopeMismatch (or pick an existing more appropriate
sentinel) and replace ErrRoleNotFound with ErrScopeMismatch in the apiErrorCtx
call in the role validation branch (the code that currently returns
apiErrorCtx(ctx, ErrRoleNotFound, connect.CodeFailedPrecondition, "...")). Add
the ErrScopeMismatch declaration alongside other error sentinels, update any
usages or tests that expect ErrRoleNotFound for this path, and keep the existing
connect.CodeFailedPrecondition and message unchanged.
internal/api/user_group_scope_handler_test.go (1)

92-97: ⚡ Quick win

Consider adding post-revoke assertion to verify grant removal.

The test verifies the revoke call succeeds, but doesn't assert that the grant is actually removed from the projection. Adding a verification would make the test more robust.

🧪 Suggested addition
 	// The exact scoped revoke succeeds.
 	_, err = h.RevokeRoleFromUserGroup(ctx, connect.NewRequest(&pm.RevokeRoleFromUserGroupRequest{
 		GroupId: group, RoleId: role, ScopeKind: deviceGroupScope, ScopeId: dg,
 	}))
 	require.NoError(t, err)
+
+	// Verify the grant is actually removed.
+	var count int
+	err = st.TestingPool().QueryRow(context.Background(),
+		"SELECT COUNT(*) FROM user_group_roles_projection WHERE group_id=$1 AND role_id=$2",
+		group, role).Scan(&count)
+	require.NoError(t, err)
+	assert.Equal(t, 0, count, "grant should be removed after revoke")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/user_group_scope_handler_test.go` around lines 92 - 97, After
calling h.RevokeRoleFromUserGroup, add an assertion that the grant no longer
exists in the projection by querying the projection/store for the specific grant
(GroupId: group, RoleId: role, ScopeKind: deviceGroupScope, ScopeId: dg) and
asserting it's absent; e.g. call the projection's read method (GetGrant or
ListGrants equivalent) and use require.NoError for the query and
require.Nil/require.False/require.Empty to confirm the grant is removed. Ensure
the check targets the same identifiers used in the test (group, role,
deviceGroupScope, dg) so the revocation is validated end-to-end.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/user_group_handler.go`:
- Around line 599-615: The RevokeRoleFromUserGroup handler currently returns
CodeNotFound when neither the scoped grant nor any assignment exists, which is
inconsistent with RevokeRoleFromUser's idempotent no-op; update
RevokeRoleFromUserGroup so that after calling q.UserGroupHasRole
(UserGroupHasRole) if hasAny is false it does not return apiErrorCtx with
ErrRoleNotFound/CodeNotFound but instead treats it as a successful no-op (same
behavior as RevokeRoleFromUser) and continues to the normal post-revoke flow
(e.g., bumping session), removing or replacing the ErrRoleNotFound CodeNotFound
branch to align semantics.

---

Nitpick comments:
In `@internal/api/role_handler.go`:
- Around line 452-454: The current return uses the ErrRoleNotFound sentinel
while the role exists but at a different scope; introduce a new sentinel
ErrScopeMismatch (or pick an existing more appropriate sentinel) and replace
ErrRoleNotFound with ErrScopeMismatch in the apiErrorCtx call in the role
validation branch (the code that currently returns apiErrorCtx(ctx,
ErrRoleNotFound, connect.CodeFailedPrecondition, "...")). Add the
ErrScopeMismatch declaration alongside other error sentinels, update any usages
or tests that expect ErrRoleNotFound for this path, and keep the existing
connect.CodeFailedPrecondition and message unchanged.

In `@internal/api/user_group_scope_handler_test.go`:
- Around line 92-97: After calling h.RevokeRoleFromUserGroup, add an assertion
that the grant no longer exists in the projection by querying the
projection/store for the specific grant (GroupId: group, RoleId: role,
ScopeKind: deviceGroupScope, ScopeId: dg) and asserting it's absent; e.g. call
the projection's read method (GetGrant or ListGrants equivalent) and use
require.NoError for the query and require.Nil/require.False/require.Empty to
confirm the grant is removed. Ensure the check targets the same identifiers used
in the test (group, role, deviceGroupScope, dg) so the revocation is validated
end-to-end.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8de4faf3-24d6-40b3-97f5-b915561dd565

📥 Commits

Reviewing files that changed from the base of the PR and between ce8ed65 and 6e8cd29.

⛔ Files ignored due to path filters (2)
  • internal/store/generated/roles.sql.go is excluded by !**/generated/**
  • internal/store/generated/user_groups.sql.go is excluded by !**/generated/**
📒 Files selected for processing (11)
  • internal/api/role_handler.go
  • internal/api/role_scope_handler_test.go
  • internal/api/scope_grant.go
  • internal/api/user_group_handler.go
  • internal/api/user_group_scope_handler_test.go
  • internal/auth/permissions.go
  • internal/auth/scope.go
  • internal/auth/scope_test.go
  • internal/store/queries/roles.sql
  • internal/store/queries/user_groups.sql
  • internal/testutil/factories_user.go

Comment thread internal/api/user_group_handler.go
…S5)

CodeRabbit re-review of #337 flagged a pre-existing inconsistency that S5
made adjacent: revoking a role grant that doesn't exist behaved two ways —
RevokeRoleFromUser fell through as a no-op (success) while
RevokeRoleFromUserGroup returned NotFound.

Aligned both to an idempotent no-op (success), matching the project's
desired-state/ABSENT idempotency (assign uses ON CONFLICT DO NOTHING) and
keeping revoke retry-safe. The "assigned at a different scope" case still
returns FailedPrecondition — that ambiguity is deliberately surfaced. The
no-op path now also skips the session-version bump (nothing changed), so a
no-op revoke no longer needlessly invalidates sessions (the group variant
would otherwise have re-logged-in every group member).

Tests: TestRevokeRoleFromUserGroup_NotAssigned flipped from NotFound to
success; added TestRevokeRoleFromUser_NotAssignedIsNoop for parity.

Refs #7.
@PaulDotterer
PaulDotterer merged commit 7b5e381 into main Jun 10, 2026
5 checks passed
@PaulDotterer
PaulDotterer deleted the feat/7-s5-role-grant-scope-handlers branch June 10, 2026 12:12
PaulDotterer added a commit that referenced this pull request Jun 10, 2026
The Integration Tests job starts a fresh Postgres testcontainer per test;
with the #7 scope/TerminalAdmin slices the api + store integration
packages now run near the 20m ceiling (#337 used 19m20s; #345 landed at
~20m). Bump to 30m as the interim unblock the issue recommends, ahead of
the durable container-reuse fix (testcontainers Reuse / per-package
TestMain + TRUNCATE).

Refs #338.
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