Skip to content

refactor(store): wave B.5 — role repo (#253)#254

Merged
PaulDotterer merged 1 commit into
mainfrom
feat/253-role-repo
May 15, 2026
Merged

refactor(store): wave B.5 — role repo (#253)#254
PaulDotterer merged 1 commit into
mainfrom
feat/253-role-repo

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

`RoleRepo` — biggest single-domain migration so far. 10 methods, 22 call sites across 6 files. Covers both the role catalog and the per-user role membership graph; both share the projection pipeline.

Branches off main directly (independent of #250 / #252 — different files).

Methods

Catalog Membership graph
`Get(id)` `ListUserRoles(userID)`
`GetByName(name)` `UserHasRole(userID, roleID)`
`List(filter)` `CountUsersWithRole(roleID)`
`Count()` `CountGroupsWithRole(roleID)`
`ListUserIDsWithRole(roleID)`
`ListUserIDsWithGroupRole(roleID)`

Call sites migrated (22)

  • `role_handler.go` — 17 sites
  • `auth_handler.go` — 2 sites (login response role population)
  • `sso_handler.go` — 1 site
  • `totp_handler.go` — 1 site
  • `user_handler.go` — 1 site (`populateUserRoles`)
  • `cmd/control/admin_user.go` — 1 site (admin bootstrap)

`roleToProto` signature shifted from `generated.RolesProjection` → `store.Role`. `user_group_handler.go` still uses the sqlc shape (UserGroup domain stays on `Queries()` until its migration) and does a transitional inline conversion at the one `roleToProto` call.

Non-goals

  • `GetUserGroupRoles` / `UserGroupHasRole` — UserGroup domain.
  • `ListInheritedRolesByUserIDs` — bulk-page join shape handled when User migrates.
  • Write-side (`UserRoleAssigned` etc.) — flows through events.

Acceptance

  • Build / vet / staticcheck / gofmt: clean.
  • Targeted tests (`TestCreateRole`, `TestAssignRole`, `TestRevokeRole`, `TestLogin`, `TestSetupTOTP`, `TestSSOCallback`, etc.): 249s of coverage passes.

Part of #242. Closes #253.

Summary by CodeRabbit

  • Refactor
    • Unified role-loading and role-management internals for consistent behavior across login, SSO, TOTP, user/group views, and admin bootstrap; no changes to user-visible behavior.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

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: 7bf8c14a-f214-4a72-83a0-4da7e8437d2c

📥 Commits

Reviewing files that changed from the base of the PR and between eda4ff0 and 0d2476e.

📒 Files selected for processing (11)
  • cmd/control/admin_user.go
  • internal/api/auth_handler.go
  • internal/api/role_handler.go
  • internal/api/sso_handler.go
  • internal/api/totp_handler.go
  • internal/api/user_group_handler.go
  • internal/api/user_handler.go
  • internal/store/postgres/role.go
  • internal/store/postgres/wire.go
  • internal/store/repos.go
  • internal/store/role.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • internal/api/sso_handler.go
  • internal/api/user_group_handler.go
  • internal/store/postgres/wire.go
  • internal/api/totp_handler.go
  • internal/store/role.go
  • internal/api/auth_handler.go
  • internal/api/user_handler.go
  • internal/api/role_handler.go

📝 Walkthrough

Walkthrough

Adds a domain RoleRepo and Postgres implementation, wires it into Repos, and migrates handlers to use repository methods for role lookups, listings, counts, and membership across auth, SSO, TOTP, user, and role RPC flows.

Changes

Role Repository Abstraction & Migration

Layer / File(s) Summary
Role repository contract and PostgreSQL implementation
internal/store/role.go, internal/store/postgres/role.go
Defines store.Role, ListRolesFilter, and RoleRepo interface (Get/GetByName/List/Count and membership methods). Implements Postgres Role repo delegating to generated queries, mapping rows to domain types via roleFromRow.
Repository registration and dependency injection
internal/store/repos.go, internal/store/postgres/wire.go
Adds Role RoleRepo to exported Repos struct and wires Role: NewRole(q) in NewRepos.
Role handler RPCs migration
internal/api/role_handler.go
Migrates role RPCs to repository APIs: uniqueness checks with GetByName, reads with Get, listing with List + Count via ListRolesFilter, membership/counts with CountUsersWithRole/CountGroupsWithRole, assignment checks with UserHasRole, session bumps via ListUserIDsWithRole/ListUserIDsWithGroupRole, and changes roleToProto to accept store.Role.
Auth, SSO, TOTP, admin and user role loads
cmd/control/admin_user.go, internal/api/auth_handler.go, internal/api/sso_handler.go, internal/api/totp_handler.go, internal/api/user_handler.go, internal/api/user_group_handler.go
Migrate user role loading and default-role lookups to repository: admin bootstrap and CreateUser use Role.GetByName; Login/GetCurrentUser/SSO/TOTP/populateUserRoles use Role.ListUserRoles; userGroupToProto converts DB projection to store.Role before roleToProto.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through repo and query lands,
I stitched role types with careful hands,
From sqlc rows to domain delight,
Ten methods, many call sites take flight—
A nimble repo, ready to stand.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'refactor(store): wave B.5 — role repo (#253)' accurately summarizes the main change: introducing a RoleRepo for role read-side logic into the store layer.
Linked Issues check ✅ Passed The PR fully implements all 10 RoleRepo methods (catalog and membership), migrates 22 call sites across 6 files, updates roleToProto signature, and includes the transitional conversion in user_group_handler.go as specified in issue #253.
Out of Scope Changes check ✅ Passed All changes are within scope: new RoleRepo interface and implementation, call site migrations, and roleToProto signature update. Non-goals (UserGroup methods, ListInheritedRolesByUserIDs, write-side) are correctly excluded.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.

✏️ 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/253-role-repo

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

Caution

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

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

357-377: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Check membership before enforcing the last-admin guard.

If the target user does not have the Admin role, this path can still return ErrCannotRemoveLastAdmin when only one admin exists, even though no revoke would occur. Gate the guard on hasRole (or check membership first) to keep idempotent no-op revokes successful.

Proposed fix
-	// Prevent removing the last user from the Admin system role
-	if role.IsSystem && role.Name == "Admin" {
-		userCount, err := h.store.Repos().Role.CountUsersWithRole(ctx, req.Msg.RoleId)
-		if err != nil {
-			return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count users")
-		}
-		if userCount <= 1 {
-			return nil, apiErrorCtx(ctx, ErrCannotRemoveLastAdmin, connect.CodeFailedPrecondition, "cannot remove last user from Admin role")
-		}
-	}
-
 	userCtx, err := requireAuth(ctx)
 	if err != nil {
 		return nil, err
 	}
 
 	// Check if the user currently has the role — skip the event on retry/idempotent call
 	hasRole, 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")
 	}
+
+	// Prevent removing the last user from the Admin system role,
+	// but only when this request would actually revoke membership.
+	if hasRole && role.IsSystem && role.Name == "Admin" {
+		userCount, err := h.store.Repos().Role.CountUsersWithRole(ctx, req.Msg.RoleId)
+		if err != nil {
+			return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to count users")
+		}
+		if userCount <= 1 {
+			return nil, apiErrorCtx(ctx, ErrCannotRemoveLastAdmin, connect.CodeFailedPrecondition, "cannot remove last user from Admin role")
+		}
+	}
🤖 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 357 - 377, The last-admin guard
runs before confirming the target user actually has the Admin role, which can
incorrectly return ErrCannotRemoveLastAdmin for idempotent no-op revokes; first
call h.store.Repos().Role.UserHasRole(ctx, req.Msg.UserId, req.Msg.RoleId) (or
otherwise check membership) and only if hasRole is true enforce the block that
checks role.IsSystem && role.Name == "Admin" and calls
h.store.Repos().Role.CountUsersWithRole; if hasRole is false skip the last-admin
check so revoke remains a harmless no-op.
internal/api/sso_handler.go (1)

376-380: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle ListUserRoles errors explicitly in SSO callback.

This currently suppresses role-load failures and returns a degraded success response.

Suggested fix
- if roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID); err == nil {
- 	for _, r := range roles {
- 		protoUser.Roles = append(protoUser.Roles, roleToProto(r))
- 	}
- }
+ roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID)
+ if err != nil {
+ 	return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to load user roles")
+ }
+ for _, r := range roles {
+ 	protoUser.Roles = append(protoUser.Roles, roleToProto(r))
+ }

As per coding guidelines, "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

🤖 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/sso_handler.go` around lines 376 - 380, The SSO callback
currently swallows errors from h.store.Repos().Role.ListUserRoles which yields a
degraded success; change the block in the SSO callback handler to check the
error return and, on non-nil error, return an appropriate Connect error using
apiErrorCtx with a relevant code (e.g., internal or unavailable) and context
message instead of proceeding — keep the successful path that maps roles with
roleToProto into protoUser.Roles when err == nil, but explicitly handle and
return apiErrorCtx(ctx, connect.CodeInternal, "failed to list user roles", err)
(or another suitable connect.Code) so failures aren't silently ignored.
internal/api/auth_handler.go (1)

132-136: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not silently swallow ListUserRoles failures in auth responses.

Both blocks return success with empty roles when role loading fails, which hides repository errors and produces partial user payloads.

Suggested fix
- if roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID); err == nil {
- 	for _, r := range roles {
- 		protoUser.Roles = append(protoUser.Roles, roleToProto(r))
- 	}
- }
+ roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID)
+ if err != nil {
+ 	return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to load user roles")
+ }
+ for _, r := range roles {
+ 	protoUser.Roles = append(protoUser.Roles, roleToProto(r))
+ }

As per coding guidelines, "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

Also applies to: 255-259

🤖 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/auth_handler.go` around lines 132 - 136, The code currently
swallows errors from h.store.Repos().Role.ListUserRoles and returns success with
empty protoUser.Roles; instead, check the error returned by ListUserRoles and if
non-nil return an apiErrorCtx with an appropriate Connect error code (e.g.,
internal or unavailable) and a descriptive message rather than silently
continuing. Update both places that call h.store.Repos().Role.ListUserRoles to
propagate repository errors via apiErrorCtx and Connect error codes, ensuring
protoUser.Roles is only populated on success.
internal/api/totp_handler.go (1)

433-437: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not ignore role-repo errors in TOTP login completion.

If role loading fails here, the RPC still succeeds with incomplete role data.

Suggested fix
- if roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID); err == nil {
- 	for _, r := range roles {
- 		protoUser.Roles = append(protoUser.Roles, roleToProto(r))
- 	}
- }
+ roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.ID)
+ if err != nil {
+ 	return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to load user roles")
+ }
+ for _, r := range roles {
+ 	protoUser.Roles = append(protoUser.Roles, roleToProto(r))
+ }

As per coding guidelines, "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

🤖 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/totp_handler.go` around lines 433 - 437, The Role.ListUserRoles
call in the TOTP completion handler currently swallows errors and can return an
incomplete protoUser.Roles; change this to check the error from
h.store.Repos().Role.ListUserRoles and return an apiErrorCtx with an appropriate
connect error code (e.g., codes.Internal or codes.NotFound as appropriate)
instead of proceeding on nil handling, so the RPC fails on role-load errors;
keep the existing loop that appends roleToProto(r) when err == nil but move it
into the successful branch and handle the error branch by returning
apiErrorCtx(ctx, connect.CodeInternal, "failed to list user roles", err) (or the
correct connect.Code) from the TOTP login completion handler that constructs
protoUser.
🤖 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_handler.go`:
- Around line 813-816: The helper populateUserRoles currently swallows
repository errors; change its signature to return error and propagate any err
from h.store.Repos().Role.ListUserRoles (and any other failures) instead of
returning silently; update all call sites of populateUserRoles to check the
returned error and convert it to an API error using apiErrorCtx (e.g., return
nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to load user
roles")) so repository failures are surfaced and handled consistently.

---

Outside diff comments:
In `@internal/api/auth_handler.go`:
- Around line 132-136: The code currently swallows errors from
h.store.Repos().Role.ListUserRoles and returns success with empty
protoUser.Roles; instead, check the error returned by ListUserRoles and if
non-nil return an apiErrorCtx with an appropriate Connect error code (e.g.,
internal or unavailable) and a descriptive message rather than silently
continuing. Update both places that call h.store.Repos().Role.ListUserRoles to
propagate repository errors via apiErrorCtx and Connect error codes, ensuring
protoUser.Roles is only populated on success.

In `@internal/api/role_handler.go`:
- Around line 357-377: The last-admin guard runs before confirming the target
user actually has the Admin role, which can incorrectly return
ErrCannotRemoveLastAdmin for idempotent no-op revokes; first call
h.store.Repos().Role.UserHasRole(ctx, req.Msg.UserId, req.Msg.RoleId) (or
otherwise check membership) and only if hasRole is true enforce the block that
checks role.IsSystem && role.Name == "Admin" and calls
h.store.Repos().Role.CountUsersWithRole; if hasRole is false skip the last-admin
check so revoke remains a harmless no-op.

In `@internal/api/sso_handler.go`:
- Around line 376-380: The SSO callback currently swallows errors from
h.store.Repos().Role.ListUserRoles which yields a degraded success; change the
block in the SSO callback handler to check the error return and, on non-nil
error, return an appropriate Connect error using apiErrorCtx with a relevant
code (e.g., internal or unavailable) and context message instead of proceeding —
keep the successful path that maps roles with roleToProto into protoUser.Roles
when err == nil, but explicitly handle and return apiErrorCtx(ctx,
connect.CodeInternal, "failed to list user roles", err) (or another suitable
connect.Code) so failures aren't silently ignored.

In `@internal/api/totp_handler.go`:
- Around line 433-437: The Role.ListUserRoles call in the TOTP completion
handler currently swallows errors and can return an incomplete protoUser.Roles;
change this to check the error from h.store.Repos().Role.ListUserRoles and
return an apiErrorCtx with an appropriate connect error code (e.g.,
codes.Internal or codes.NotFound as appropriate) instead of proceeding on nil
handling, so the RPC fails on role-load errors; keep the existing loop that
appends roleToProto(r) when err == nil but move it into the successful branch
and handle the error branch by returning apiErrorCtx(ctx, connect.CodeInternal,
"failed to list user roles", err) (or the correct connect.Code) from the TOTP
login completion handler that constructs protoUser.
🪄 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: 43eb2a13-17a0-4dae-b35a-e8eed97a941e

📥 Commits

Reviewing files that changed from the base of the PR and between 8e6bf70 and eda4ff0.

📒 Files selected for processing (11)
  • cmd/control/admin_user.go
  • internal/api/auth_handler.go
  • internal/api/role_handler.go
  • internal/api/sso_handler.go
  • internal/api/totp_handler.go
  • internal/api/user_group_handler.go
  • internal/api/user_handler.go
  • internal/store/postgres/role.go
  • internal/store/postgres/wire.go
  • internal/store/repos.go
  • internal/store/role.go

Comment on lines +813 to 816
roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.Id)
if err != nil {
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Refactor populateUserRoles to return errors instead of silently returning.

Current helper behavior hides repository failures and blocks proper apiErrorCtx handling at handler boundaries.

Suggested refactor
-func (h *UserHandler) populateUserRoles(ctx context.Context, user *pm.User) {
+func (h *UserHandler) populateUserRoles(ctx context.Context, user *pm.User) error {
 	roles, err := h.store.Repos().Role.ListUserRoles(ctx, user.Id)
 	if err != nil {
-		return
+		return err
 	}
 	for _, r := range roles {
 		user.Roles = append(user.Roles, roleToProto(r))
 	}
+	return nil
}

Then at call sites:

if err := h.populateUserRoles(ctx, protoUser); err != nil {
    return nil, apiErrorCtx(ctx, ErrInternal, connect.CodeInternal, "failed to load user roles")
}

As per coding guidelines, "Errors must use apiErrorCtx with proper Connect error codes. Never silently ignore errors."

🤖 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_handler.go` around lines 813 - 816, The helper
populateUserRoles currently swallows repository errors; change its signature to
return error and propagate any err from h.store.Repos().Role.ListUserRoles (and
any other failures) instead of returning silently; update all call sites of
populateUserRoles to check the returned error and convert it to an API error
using apiErrorCtx (e.g., return nil, apiErrorCtx(ctx, ErrInternal,
connect.CodeInternal, "failed to load user roles")) so repository failures are
surfaced and handled consistently.

Biggest single-domain migration so far. RoleRepo covers both the
role catalog (Get / GetByName / List / Count) and the per-user
role-membership graph (ListUserRoles / UserHasRole /
CountUsersWithRole / CountGroupsWithRole / ListUserIDsWithRole /
ListUserIDsWithGroupRole). Both halves share the projection
pipeline and are queried together from the role handler.

Call sites migrated (22 across 6 files):
- role_handler.go (17)
- auth_handler.go (2) — login response role population
- sso_handler.go (1)
- totp_handler.go (1)
- user_handler.go (1) — populateUserRoles
- cmd/control/admin_user.go (1) — admin bootstrap

roleToProto's signature shifted from generated.RolesProjection to
store.Role. The single roleToProto consumer in user_group_handler.go
(still on Queries().GetUserGroupRoles until UserGroup migrates) uses
a transitional inline conversion.

Non-goals:
- GetUserGroupRoles / UserGroupHasRole — UserGroup domain, stays.
- ListInheritedRolesByUserIDs — bulk user-page join shape, handled
  when User migrates.

Closes #253.
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.

wave B.5: role repo

1 participant