Skip to content

feat(user): add support for super_admin role, service account update and soft-delete#1903

Open
subratsahilgupta wants to merge 2 commits into
flexprice:developfrom
subratsahilgupta:feat/service-accounts-crud
Open

feat(user): add support for super_admin role, service account update and soft-delete#1903
subratsahilgupta wants to merge 2 commits into
flexprice:developfrom
subratsahilgupta:feat/service-accounts-crud

Conversation

@subratsahilgupta

@subratsahilgupta subratsahilgupta commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Optional name field for user profiles (set on create, returned in responses, and editable)
    • PUT /users/{id} to update service account properties
    • DELETE /users/{id} to remove (soft-delete/archive) service accounts
  • Security

    • Added super_admin role with unrestricted permissions
  • Chores

    • Database schema extended with nullable name column to support the new field

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds a name field to the User entity and enables management of service accounts through new update and delete endpoints. The ent schema and generated code support the field end-to-end, the domain and repository layers handle persistence with soft-delete semantics, the service layer enforces service-account-only updates, and new API endpoints expose the operations.

Changes

User Name Field and Service Account Management

Layer / File(s) Summary
Ent schema and generated code for name field
ent/schema/user.go, ent/migrate/schema.go, ent/runtime.go, ent/user.go, ent/mutation.go, ent/user/user.go, ent/user/where.go, ent/user_create.go, ent/user_update.go
The name field is defined as an optional, nillable varchar(255) column in the ent schema. All generated code—entity struct, mutation builders, create/update builders, query predicates, and runtime field descriptors—is updated to support the field.
Domain model and repository layer
internal/domain/user/model.go, internal/domain/user/repository.go, internal/repository/ent/user.go, internal/testutil/inmemory_user_store.go
The domain User model gains an optional Name field mapped from ent entities via FromEnt. Repository interface adds Delete method. Ent repository implementation conditionally persists name on create/update and soft-archives users (status to archived) on delete only when current status is published. InMemory store gains Delete for tests.
Service layer user operations
internal/service/user.go, internal/service/user_test.go
UserService interface adds id parameter to UpdateUser (with empty string meaning authenticated user from context) and adds DeleteUser method. Implementation resolves target user id, validates that explicit id updates are only allowed for service accounts, applies name updates with audit field tracking, and delegates persistence to repository. Test updated to match new signature.
API DTOs and HTTP handlers
internal/api/dto/user.go, internal/api/v1/user.go, internal/api/router.go
DTOs extend with optional Name fields in CreateUserRequest, UserResponse, and UpdateUserRequest (Metadata tag changed to omitempty). Two new endpoints added: PUT /users/{id} (UpdateServiceAccount) and DELETE /users/{id} (DeleteUser), both authenticated and write-protected. UpdateUser Swagger documentation updated.
RBAC super-admin role and test support
internal/config/rbac/roles.json, internal/rbac/rbac.go
Adds super_admin role with wildcard permissions (*: [*]). HasPermission refactored for nil-safe step-by-step permission lookups supporting explicit wildcard and entity-specific checks.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Handler
  participant UserService
  participant UserRepository
  participant EntClient
  
  Client->>Handler: PUT /users/{id} + UpdateUserRequest
  Handler->>UserService: UpdateUser(ctx, id, request)
  UserService->>UserRepository: Get user by id
  UserRepository->>EntClient: Query user
  EntClient-->>UserRepository: User (check type)
  UserRepository-->>UserService: domain.User
  UserService->>UserService: Validate ServiceAccount type
  UserService->>UserRepository: Update name + audit fields
  UserRepository->>EntClient: Update mutation
  EntClient-->>UserRepository: updated ent.User
  UserRepository-->>UserService: domain.User
  UserService-->>Handler: dto.UpdateUserResponse
  Handler-->>Client: 200 + UserResponse
  
  Client->>Handler: DELETE /users/{id}
  Handler->>UserService: DeleteUser(ctx, id)
  UserService->>UserRepository: Get user by id
  UserRepository-->>UserService: domain.User
  UserService->>UserService: Validate ServiceAccount type
  UserService->>UserRepository: Delete (soft-archive)
  UserRepository->>EntClient: Update status=archived
  EntClient-->>UserRepository: affected count
  UserRepository-->>UserService: nil or error
  UserService-->>Handler: nil or error
  Handler-->>Client: 204 No Content or error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • flexprice/flexprice#1868: Both PRs modify UsersColumns and update the idx_user_tenant_status index to account for shifted column positions from new user field insertions.

Suggested reviewers

  • nkmishra1997
🚥 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 title follows the required format (feat(user): ...) and clearly describes the main changes—adding super_admin role support, service account update capabilities, and soft-delete functionality—all of which are present throughout the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 92.59% which is sufficient. The required threshold is 80.00%.
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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="Running error: context loading failed: no go files to analyze: running go mod tidy may solve the problem"


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
Contributor

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/testutil/inmemory_user_store.go (1)

30-34: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Store keyed by Email breaks for service accounts.

InMemoryUserStore.Create stores users in r.users[user.Email]; if service accounts are created with an empty Email, multiple service accounts created in the same store instance will collide, causing ErrAlreadyExists and making GetByEmail("") lookups ambiguous.

internal/service/user_test.go has service-account test cases, but it recreates s.userRepo = testutil.NewInMemoryUserStore() per subtest, so the current tests may not cover the “multiple service accounts in one store instance” scenario. Consider keying by ID (or otherwise using a non-empty unique key for service accounts) instead of Email.

🤖 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/testutil/inmemory_user_store.go` around lines 30 - 34, The store
currently keys users by email in InMemoryUserStore.Create (r.users[user.Email])
which collides for service accounts with empty Email; change the store to key by
a non-empty unique field (use user.ID) and update all related methods (e.g.,
InMemoryUserStore.Create, GetByEmail, any direct r.users accesses) to maintain a
map keyed by ID while keeping a secondary email->ID index or scanning for
GetByEmail lookups so GetByEmail("") and multiple service accounts work
correctly; ensure Create still checks uniqueness (by ID and/or email) and that
tests referencing GetByEmail continue to behave as expected.
🧹 Nitpick comments (3)
internal/repository/ent/user.go (1)

110-112: 💤 Low value

Name cannot be cleared via Update.

Because SetName is only applied when user.Name != "", an explicit request to clear a service account's display name (empty string) is silently ignored. If clearing is a supported operation, distinguish "unset" from "set to empty" (e.g., a *string or an explicit flag) at the service/DTO boundary.

🤖 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/repository/ent/user.go` around lines 110 - 112, The update currently
ignores explicit requests to clear Name because it only calls
updateQuery.SetName when user.Name != "", so change the service/DTO to
distinguish "unset" vs "set to empty" (e.g., make Name a *string or add an
explicit flag), then update this code to check for nil rather than empty string:
if the DTO's Name pointer is non-nil call updateQuery.SetName(*user.Name) (which
allows setting an empty string), otherwise leave the field unchanged; ensure you
update any callers and converters to populate nil vs pointer values accordingly.
internal/testutil/inmemory_user_store.go (1)

112-124: 💤 Low value

Delete diverges from prod soft-delete semantics (idempotency/tenant).

The ent repository only archives a published user (scoped by tenant) and returns ErrNotFound on a second delete. This in-memory Delete re-archives unconditionally and ignores tenant, so a second call returns nil instead of ErrNotFound. If tests assert delete idempotency or tenant isolation, behavior will differ from production.

♻️ Align with prod guard
 	for key, u := range r.users {
-		if u.ID == id {
+		if u.ID == id && u.Status == types.StatusPublished {
 			u.Status = types.StatusArchived
 			r.users[key] = u
 			return nil
 		}
 	}
 	return ierr.NewError("user not found").Mark(ierr.ErrNotFound)
🤖 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/testutil/inmemory_user_store.go` around lines 112 - 124, In
InMemoryUserStore.Delete, make behavior match production: while holding r.mu,
locate the user by id and tenant (check u.ID == id and u.Tenant == ctxTenant),
only archive if u.Status == types.StatusPublished (set u.Status =
types.StatusArchived and write back to r.users), and return nil; if no matching
published user is found (either missing, wrong tenant, or already archived),
return ierr.NewError("user not found").Mark(ierr.ErrNotFound) so a second delete
returns ErrNotFound; keep the existing locking and use the same error type.
internal/service/user_test.go (1)

231-262: 💤 Low value

Consider adding test coverage for new service account operations.

The test file only covers metadata merge for the authenticated user. The new UpdateUser with explicit ID (service-account path) and DeleteUser method lack test coverage. Consider adding table-driven tests for:

  • Updating a service account by explicit ID
  • Rejecting update when ID points to a non-service-account user
  • Deleting a service account
  • Rejecting deletion of non-service-account users
🤖 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/service/user_test.go` around lines 231 - 262, Add table-driven tests
to internal/service/user_test.go covering service-account paths for
s.userService.UpdateUser and s.userService.DeleteUser: create service-account
and non-service-account users via s.userRepo.Create, then test UpdateUser with
an explicit ID (ensure successful update only for service-account and rejected
for non-service-account) using dto.UpdateUserRequest with Metadata and verify
merged fields, and test DeleteUser by ID (successful for service-account and
rejected for non-service-account). Place tests alongside
TestUpdateUser_MetadataMerge in the UserServiceSuite and reuse ctx/baseModel
setup to assert success responses and error cases for each table entry.
🤖 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/v1/user.go`:
- Around line 185-205: The Swagger annotation for DeleteUser is inconsistent
with the handler: update the comment on the DeleteUser method to reflect the
actual 204 response by replacing "`@Success` 200 {object} dto.UpdateUserResponse"
with a 204 annotation (e.g. "`@Success` 204 {string} string \"No Content\"" or
simply "`@Success` 204 \"No Content\"") so the doc no longer claims a response
body; keep the handler implementation (UserHandler.DeleteUser) unchanged.

In `@internal/service/user.go`:
- Around line 360-371: DeleteUser currently passes an empty id to
userRepo.GetByID which yields a confusing "not found" error; add the same
empty-id handling used in UpdateUser: if id == "" resolve the target id from the
request context (the context user ID retrieval used by UpdateUser) and use that
resolved id for GetByID/Delete, or if your codebase prefers explicit validation
return a clear validation error when no context user is present. Update the
logic in DeleteUser (referencing DeleteUser, UpdateUser, and GetByID) to either
resolve empty id from ctx before calling GetByID or return a marked validation
error rather than calling GetByID with an empty string.

---

Outside diff comments:
In `@internal/testutil/inmemory_user_store.go`:
- Around line 30-34: The store currently keys users by email in
InMemoryUserStore.Create (r.users[user.Email]) which collides for service
accounts with empty Email; change the store to key by a non-empty unique field
(use user.ID) and update all related methods (e.g., InMemoryUserStore.Create,
GetByEmail, any direct r.users accesses) to maintain a map keyed by ID while
keeping a secondary email->ID index or scanning for GetByEmail lookups so
GetByEmail("") and multiple service accounts work correctly; ensure Create still
checks uniqueness (by ID and/or email) and that tests referencing GetByEmail
continue to behave as expected.

---

Nitpick comments:
In `@internal/repository/ent/user.go`:
- Around line 110-112: The update currently ignores explicit requests to clear
Name because it only calls updateQuery.SetName when user.Name != "", so change
the service/DTO to distinguish "unset" vs "set to empty" (e.g., make Name a
*string or add an explicit flag), then update this code to check for nil rather
than empty string: if the DTO's Name pointer is non-nil call
updateQuery.SetName(*user.Name) (which allows setting an empty string),
otherwise leave the field unchanged; ensure you update any callers and
converters to populate nil vs pointer values accordingly.

In `@internal/service/user_test.go`:
- Around line 231-262: Add table-driven tests to internal/service/user_test.go
covering service-account paths for s.userService.UpdateUser and
s.userService.DeleteUser: create service-account and non-service-account users
via s.userRepo.Create, then test UpdateUser with an explicit ID (ensure
successful update only for service-account and rejected for non-service-account)
using dto.UpdateUserRequest with Metadata and verify merged fields, and test
DeleteUser by ID (successful for service-account and rejected for
non-service-account). Place tests alongside TestUpdateUser_MetadataMerge in the
UserServiceSuite and reuse ctx/baseModel setup to assert success responses and
error cases for each table entry.

In `@internal/testutil/inmemory_user_store.go`:
- Around line 112-124: In InMemoryUserStore.Delete, make behavior match
production: while holding r.mu, locate the user by id and tenant (check u.ID ==
id and u.Tenant == ctxTenant), only archive if u.Status == types.StatusPublished
(set u.Status = types.StatusArchived and write back to r.users), and return nil;
if no matching published user is found (either missing, wrong tenant, or already
archived), return ierr.NewError("user not found").Mark(ierr.ErrNotFound) so a
second delete returns ErrNotFound; keep the existing locking and use the same
error type.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcaf667c-663b-4cdc-bb45-40b83a7cd55d

📥 Commits

Reviewing files that changed from the base of the PR and between 5ec7dba and 2ecb36c.

📒 Files selected for processing (20)
  • ent/migrate/schema.go
  • ent/mutation.go
  • ent/runtime.go
  • ent/schema/user.go
  • ent/user.go
  • ent/user/user.go
  • ent/user/where.go
  • ent/user_create.go
  • ent/user_update.go
  • internal/api/dto/user.go
  • internal/api/router.go
  • internal/api/v1/user.go
  • internal/config/rbac/roles.json
  • internal/domain/user/model.go
  • internal/domain/user/repository.go
  • internal/rbac/rbac.go
  • internal/repository/ent/user.go
  • internal/service/user.go
  • internal/service/user_test.go
  • internal/testutil/inmemory_user_store.go

Comment thread internal/api/v1/user.go
Comment thread internal/service/user.go
@subratsahilgupta subratsahilgupta added the need-db-migration This tag describes that this issue/PR requires an Database Migration label Jun 2, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 (1)
internal/testutil/inmemory_user_store.go (1)

55-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update still drops the new Name field.

This store is part of the service-test support for the PR, but Update only persists metadata/audit fields. With the new user name support added across the service contract, tests backed by this double won't exercise repository semantics for Name updates reliably.

Suggested fix
 		if u.ID == updatedUser.ID && u.TenantID == updatedUser.TenantID {
+			u.Name = updatedUser.Name
 			if updatedUser.Metadata != nil {
 				u.Metadata = updatedUser.Metadata
 			}
 			u.UpdatedBy = updatedUser.UpdatedBy
🤖 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/testutil/inmemory_user_store.go` around lines 55 - 63, The Update
method in inmemory_user_store.go currently only persists metadata and audit
fields, dropping changes to the new Name field; modify the Update function (the
loop over r.users comparing u.ID and u.TenantID) to also copy updatedUser.Name
into the stored user (u.Name = updatedUser.Name) when a new name is provided
(e.g., non-empty or non-nil per your User type semantics), just like how
Metadata is handled, then write r.users[key] = u and return.
🤖 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/testutil/inmemory_user_store.go`:
- Around line 116-118: The lookup of tenant context drops the ok flag so a
missing types.CtxTenantID is treated like an empty/ wildcard tenant; change the
lookup to capture the boolean (tenantID, ok :=
ctx.Value(types.CtxTenantID).(string)) and require ok to be true before allowing
the wildcard branch in the loop that iterates r.users (where the check is u.ID
== id && ...). In other words, only allow deletion when the tenant context
exists (ok == true) and either tenantID == "" (explicit empty tenant) or
u.TenantID == tenantID; if ok is false, do not match and do not perform
cross-tenant deletes.

---

Outside diff comments:
In `@internal/testutil/inmemory_user_store.go`:
- Around line 55-63: The Update method in inmemory_user_store.go currently only
persists metadata and audit fields, dropping changes to the new Name field;
modify the Update function (the loop over r.users comparing u.ID and u.TenantID)
to also copy updatedUser.Name into the stored user (u.Name = updatedUser.Name)
when a new name is provided (e.g., non-empty or non-nil per your User type
semantics), just like how Metadata is handled, then write r.users[key] = u and
return.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9684c9f7-b8a3-469d-8f76-49e90b98816b

📥 Commits

Reviewing files that changed from the base of the PR and between 2ecb36c and 8af8fae.

📒 Files selected for processing (3)
  • internal/api/v1/user.go
  • internal/service/user.go
  • internal/testutil/inmemory_user_store.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/service/user.go
  • internal/api/v1/user.go

Comment on lines +116 to +118
tenantID, _ := ctx.Value(types.CtxTenantID).(string)
for key, u := range r.users {
if u.ID == id && (tenantID == "" || u.TenantID == tenantID) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing tenant context currently turns into a cross-tenant delete.

Line 116 drops the ok result from the context lookup, so a missing types.CtxTenantID is treated the same as an intentionally empty tenant and the wildcard branch on Line 118 becomes active. That means service tests can forget to propagate tenant context and still pass deletion across tenants, which weakens the multitenant contract this double is supposed to enforce.

Suggested fix
-	tenantID, _ := ctx.Value(types.CtxTenantID).(string)
+	tenantID, ok := ctx.Value(types.CtxTenantID).(string)
+	if !ok {
+		return ierr.NewError("tenant ID not found in context").Mark(ierr.ErrValidation)
+	}
 	for key, u := range r.users {
 		if u.ID == id && (tenantID == "" || u.TenantID == tenantID) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tenantID, _ := ctx.Value(types.CtxTenantID).(string)
for key, u := range r.users {
if u.ID == id && (tenantID == "" || u.TenantID == tenantID) {
tenantID, ok := ctx.Value(types.CtxTenantID).(string)
if !ok {
return ierr.NewError("tenant ID not found in context").Mark(ierr.ErrValidation)
}
for key, u := range r.users {
if u.ID == id && (tenantID == "" || u.TenantID == tenantID) {
🤖 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/testutil/inmemory_user_store.go` around lines 116 - 118, The lookup
of tenant context drops the ok flag so a missing types.CtxTenantID is treated
like an empty/ wildcard tenant; change the lookup to capture the boolean
(tenantID, ok := ctx.Value(types.CtxTenantID).(string)) and require ok to be
true before allowing the wildcard branch in the loop that iterates r.users
(where the check is u.ID == id && ...). In other words, only allow deletion when
the tenant context exists (ok == true) and either tenantID == "" (explicit empty
tenant) or u.TenantID == tenantID; if ok is false, do not match and do not
perform cross-tenant deletes.

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

Labels

need-db-migration This tag describes that this issue/PR requires an Database Migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant