Skip to content

feat: add opt-in authoritative superuser reconciliation#1709

Open
rohilsurana wants to merge 5 commits into
mainfrom
feat/authoritative-superuser-reconciliation
Open

feat: add opt-in authoritative superuser reconciliation#1709
rohilsurana wants to merge 5 commits into
mainfrom
feat/authoritative-superuser-reconciliation

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jun 21, 2026

Copy link
Copy Markdown
Member

What

Adds an opt-in mode that makes the app.admin.users config list the authoritative source of truth for human superusers, and fixes a latent bug that prevented superusers from being demoted at all.

Why

Today MakeSuperUsers only ever promotes the configured users at boot — it never demotes, so removing someone from admin.users has no effect. And the existing demotion path (UnSudo / RemovePlatformUser) only ever deleted the member relation, so it could never actually strip a superuser (admin) — making manual demotion impossible.

Changes

  • Config flag app.admin.authoritative (default false → unchanged behavior). When true, at boot any human holding the platform admin relation that is not in admin.users is demoted. Service accounts and the member relation are never touched.
    • Reconciliation resolves each config entry (email / UUID / slug) to its canonical user ID, so an admin listed by both email and UUID never causes a spurious demotion.
  • Fix the demotion primitive: UnSudo(ctx, id, relationName) now removes the specified relation (so admin can actually be stripped), change-only. RemovePlatformUser now strips both admin and member for users and service users — fixing the bug above. Applied to both user and serviceuser services.
  • Config-driven safety: during reconciliation, a config entry that doesn't resolve to a user (ErrNotExist) is skipped, but any other resolve error (a transient backend/DB failure) aborts reconciliation rather than risk demoting a real admin that merely looked absent. MakeSuperUsers is a hard-fail bootstrap step, so that abort fails startup (the server won't boot until the error clears) — the intended safe-fail, consistent with MigrateSchema/MigrateRoles.
  • Audit: new platform.{admin,member}_{added,removed} events (and a platform entity type) recorded via the auditrecord system on every real platform relation change — for both the admin and member relations, on users and service accounts. None existed before. Names follow the existing membership-event convention (organization.added/removed, group.member_added/removed). Emitted inside Sudo/UnSudo (change-only — only fires when the relation actually changes); the actor is the acting principal on request paths and the system actor (uuid.Nil) for config/boot-driven changes. (Config reconcile only ever touches admin, so at boot only admin_* events fire.)

⚠️ Accepted risk

With authoritative: true and an empty/misconfigured users list, all human superusers are demoted at boot (no guard, by design). Recovery: fix users and restart, or use a service-account superuser. This is documented in config/sample.config.yaml.

Testing

  • New unit tests: bootstrap reconcile (demote-diff, service-account & member exclusion, additive-when-off, abort-on-transient-resolve-error, skip-on-not-found), user/serviceuser Sudo/UnSudo (admin & member add/remove + audit, no-op when the relation is absent, invalid relation), and the RemovePlatformUser handler (both relations for user + service user).
  • go build ./..., go vet, gofmt, golangci-lint (touched packages) all clean; full unit suite green. e2e (Docker) not run.

Known gap / follow-up

AddPlatformUser is relation-specific (grants admin or member), but RemovePlatformUser is relation-agnostic — it strips both relations because RemovePlatformUserRequest has no relation field. So there's currently no API to remove a single platform relation (e.g. demote an admin down to member). The underlying service primitives (Sudo/UnSudo) are already relation-specific and symmetric; only the proto request is coarse. Tracked for a future proto change in raystack/proton: #1710.

Notes

  • Default-off, so existing deployments are unaffected.
  • Mock changes limited to the two interfaces whose signature changed, plus two new audit-repo mocks.

@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Jun 29, 2026 12:00pm

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

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: b0f057c1-86ed-43da-977a-7e1bc8c6eb8c

📥 Commits

Reviewing files that changed from the base of the PR and between 1625cf6 and b059826.

📒 Files selected for processing (1)
  • internal/bootstrap/service.go
✅ Files skipped from review due to trivial changes (1)
  • internal/bootstrap/service.go

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Platform admin/member changes now create audit records, improving visibility into who was granted or removed from platform access.
    • Platform user removal now handles admin and member access separately for more precise access cleanup.
  • Bug Fixes

    • Access removal now validates the specific relation being removed, reducing the chance of deleting the wrong platform membership.
    • Updated platform user removal flows to apply the correct action for each relation type.

Walkthrough

Adds audit recording for platform admin/member grant and revoke operations in core/user and core/serviceuser services. UnSudo now accepts a relationName parameter to target a specific relation. New AuditRecordRepository interfaces and audit event constants are introduced, with changes propagated through the API handler, interface definitions, generated mocks, and DI wiring.

Changes

Platform Admin Audit Recording and relationName Propagation

Layer / File(s) Summary
Audit event constants and AuditRecordRepository interfaces
pkg/auditrecord/consts.go, core/serviceuser/service.go, core/user/service.go
Adds PlatformAdminAddedEvent, PlatformAdminRemovedEvent, PlatformMemberAddedEvent, PlatformMemberRemovedEvent, and PlatformType constants; introduces AuditRecordRepository interface with Create method and adds it as a field and constructor parameter in both Service structs.
core/user: Sudo/UnSudo audit recording and relationName param
core/user/service.go, core/user/mocks/audit_record_repository.go, core/user/service_test.go
UnSudo now accepts relationName, validates it, checks for the specific relation via IsSudo, deletes only the exact tuple, and records a revocation audit event. Sudo records a grant audit event after creating the relation. Adds platformAddedEvent/platformRemovedEvent helpers and recordPlatformAuditRecord. Tests wire the audit mock and assert Create calls for all grant/revoke flows.
core/serviceuser: Sudo/UnSudo audit recording and relationName param
core/serviceuser/service.go, core/serviceuser/mocks/audit_record_repository.go, core/serviceuser/service_test.go
Mirrors the user-service changes: UnSudo accepts and validates relationName, checks existence before deletion, and records revocation audit; Sudo records grant audit. Adds recordPlatformAuditRecord helper using time.Now().UTC(). Tests wire audit mock and assert Create calls.
API interface, handler, and mocks: UnSudo relationName propagation
internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/platform.go, internal/api/v1beta1connect/mocks/..., internal/api/v1beta1connect/platform_test.go, internal/bootstrap/service.go
Updates UserService and ServiceUserService interface UnSudo signatures to require relationName; rewrites RemovePlatformUser to iterate over admin and member relations calling UnSudo with each; regenerates mocks to match expanded signatures; adds handler tests covering user, service-user, and missing-ID paths; updates MakeSuperUsers doc comment.
DI wiring for audit repositories
cmd/serve.go
Passes auditRecordRepository into both serviceuser.NewService and user.NewService constructors in buildAPIDependencies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • whoAbhishekSah
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
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.

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.

@coveralls

coveralls commented Jun 21, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 28370424292

Coverage increased (+0.3%) to 44.088%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 16 uncovered changes across 4 files (96 of 112 lines covered, 85.71%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
core/serviceuser/service.go 48 43 89.58%
core/user/service.go 49 44 89.8%
internal/api/v1beta1connect/platform.go 13 9 69.23%
cmd/serve.go 2 0 0.0%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/user/service.go 1 68.1%

Coverage Stats

Coverage Status
Relevant Lines: 37137
Covered Lines: 16373
Line Coverage: 44.09%
Coverage Strength: 12.4 hits per line

💛 - Coveralls

@rohilsurana rohilsurana marked this pull request as ready for review June 22, 2026 05:03

@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 (2)
core/serviceuser/service.go (1)

518-531: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make member revocation idempotent in UnSudo.

UnSudo currently returns an error when deleting a non-existent member relation. In remove flows that revoke both admin and member, this can fail after successful admin demotion and leave the operation reported as failed.

Suggested fix
-	if err := s.relationService.Delete(ctx, relation.Relation{
+	if err := s.relationService.Delete(ctx, relation.Relation{
 		Object: relation.Object{
 			ID:        schema.PlatformID,
 			Namespace: schema.PlatformNamespace,
 		},
 		Subject: relation.Subject{
 			ID:        currentUser.ID,
 			Namespace: schema.ServiceUserPrincipal,
 		},
 		RelationName: relationName,
-	}); err != nil {
+	}); err != nil && !errors.Is(err, relation.ErrNotExist) {
 		return err
 	}
core/user/service.go (1)

255-267: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat missing member relation as no-op during UnSudo.

When relationService.Delete returns relation.ErrNotExist for member, UnSudo fails even though the desired end state is already satisfied. This can break platform-user removal flows that revoke both relations.

Suggested fix
-	if err := s.relationService.Delete(ctx, relation.Relation{
+	if err := s.relationService.Delete(ctx, relation.Relation{
 		Object: relation.Object{
 			ID:        schema.PlatformID,
 			Namespace: schema.PlatformNamespace,
 		},
 		Subject: relation.Subject{
 			ID:        currentUser.ID,
 			Namespace: schema.UserPrincipal,
 		},
 		RelationName: relationName,
-	}); err != nil {
+	}); err != nil && !errors.Is(err, relation.ErrNotExist) {
 		return err
 	}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f1ae0dde-555e-4c9a-b963-1eee98883348

📥 Commits

Reviewing files that changed from the base of the PR and between 6dc16f9 and e175a9a.

📒 Files selected for processing (16)
  • cmd/serve.go
  • config/sample.config.yaml
  • core/serviceuser/mocks/audit_record_repository.go
  • core/serviceuser/service.go
  • core/serviceuser/service_test.go
  • core/user/mocks/audit_record_repository.go
  • core/user/service.go
  • core/user/service_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/service_user_service.go
  • internal/api/v1beta1connect/mocks/user_service.go
  • internal/api/v1beta1connect/platform.go
  • internal/api/v1beta1connect/platform_test.go
  • internal/bootstrap/service.go
  • internal/bootstrap/service_test.go
  • pkg/auditrecord/consts.go

Comment thread internal/bootstrap/service.go Outdated

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

Caution

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

⚠️ Outside diff range comments (1)
core/user/service.go (1)

185-200: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Switch Sudo idempotency to relation-level checks.

Line 186-Line 199 uses permission checks (PlatformCheckPermission) for member. That can short-circuit Sudo(..., member) for already-admin users without creating the member relation tuple, while UnSudo now operates on exact relation tuples. This asymmetry can break downgrade flows that expect tuple-level state transitions.

Proposed fix
-	// check if already su
-	permissionName := ""
-	switch relationName {
-	case schema.MemberRelationName:
-		permissionName = schema.PlatformCheckPermission
-	case schema.AdminRelationName:
-		permissionName = schema.PlatformSudoPermission
-	}
-	if permissionName == "" {
+	// validate requested platform relation
+	switch relationName {
+	case schema.MemberRelationName, schema.AdminRelationName:
+	default:
 		return fmt.Errorf("invalid relation name, possible options are: %s, %s", schema.MemberRelationName, schema.AdminRelationName)
 	}
 
-	if ok, err := s.IsSudo(ctx, currentUser.ID, permissionName); err != nil {
+	// check if the exact relation already exists
+	if ok, err := s.IsSudo(ctx, currentUser.ID, relationName); err != nil {
 		return err
 	} else if ok {
 		return nil
 	}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6af69031-afee-4758-b2f3-fef5aec7469f

📥 Commits

Reviewing files that changed from the base of the PR and between e175a9a and 5a346d7.

📒 Files selected for processing (5)
  • core/serviceuser/service.go
  • core/serviceuser/service_test.go
  • core/user/service.go
  • core/user/service_test.go
  • pkg/auditrecord/consts.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/serviceuser/service_test.go
  • core/serviceuser/service.go
  • core/user/service_test.go

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

🧹 Nitpick comments (1)
core/user/service_test.go (1)

703-704: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Strengthen audit matcher assertions to lock the full payload contract.

These matchers only validate Event and Target.ID. Please also assert Resource (ID/Type), Target.Type, and Metadata["relation"] so payload regressions are caught.

Proposed matcher shape
- return r.Event == pkgAuditRecord.PlatformAdminAddedEvent && r.Target != nil && r.Target.ID == "test-id"
+ return r.Event == pkgAuditRecord.PlatformAdminAddedEvent &&
+   r.Target != nil &&
+   r.Target.ID == "test-id" &&
+   r.Target.Type == pkgAuditRecord.UserType &&
+   r.Resource.ID == schema.PlatformID &&
+   r.Resource.Type == pkgAuditRecord.PlatformType &&
+   r.Metadata["relation"] == schema.AdminRelationName

Also applies to: 817-818, 903-904, 958-959


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dc102442-61a1-47be-b539-28d52a165871

📥 Commits

Reviewing files that changed from the base of the PR and between 5a346d7 and 1625cf6.

📒 Files selected for processing (7)
  • core/serviceuser/service.go
  • core/serviceuser/service_test.go
  • core/user/service.go
  • core/user/service_test.go
  • internal/bootstrap/service.go
  • internal/bootstrap/service_test.go
  • pkg/auditrecord/consts.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • core/serviceuser/service_test.go
  • core/user/service.go
  • internal/bootstrap/service_test.go
  • internal/bootstrap/service.go
  • core/serviceuser/service.go

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.

2 participants