Skip to content

feat: add opt-in authoritative superuser reconciliation#1709

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

feat: add opt-in authoritative superuser reconciliation#1709
rohilsurana wants to merge 4 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 22, 2026 5:40am

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Added audit logging for platform admin and member role grants and revocations
    • Introduced authoritative admin mode to reconcile platform administrators with the configured list at startup, automatically demoting those not present in configuration
  • Changes

    • Platform user removal now explicitly handles admin and member relations separately and requires specifying the relation type

Walkthrough

This PR adds audit recording for platform admin/member grant and revoke operations across core/user and core/serviceuser services, extending UnSudo to accept a relationName parameter specifying which relation to revoke. It also introduces an "authoritative" superuser reconciliation mode in the bootstrap service that demotes absent human admins at startup. All interface, mock, handler, and DI wiring changes are propagated accordingly.

Changes

Platform Admin Audit Recording and Authoritative Superuser Reconciliation

Layer / File(s) Summary
Audit event constants and AuditRecordRepository interfaces
pkg/auditrecord/consts.go, core/user/service.go, core/serviceuser/service.go
Adds PlatformAdminAddedEvent, PlatformAdminRemovedEvent, PlatformMemberAddedEvent, PlatformMemberRemovedEvent, and PlatformType constants; introduces the AuditRecordRepository interface with Create(ctx, auditRecord) method and wires it into both service structs and constructors.
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
Sudo audits admin-granted events; UnSudo now accepts relationName (admin/member), validates it, checks whether the exact relation exists via IsSudo, returns early if absent, and audits revocation events via recordPlatformAuditRecord. All user service tests updated with audit mock wiring and grant/revoke event assertions.
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 user-service changes: Sudo audits admin-granted events, UnSudo accepts relationName with conditional permission check and audit recording, new recordPlatformAuditRecord helper, and updated tests with audit mock setup and grant/revoke assertions.
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
Updates UserService and ServiceUserService interface UnSudo signatures to require relationName; RemovePlatformUser now loops over admin and member relations calling UnSudo with the specific name; mocks regenerated; handler tests added for user, service-user, and missing-ID validation paths.
Bootstrap authoritative superuser reconciliation
internal/bootstrap/service.go, internal/bootstrap/service_test.go, config/sample.config.yaml
Adds AdminConfig.Authoritative; extends UserService with UnSudo(ctx, id, relationName) and GetByID(ctx, id) methods; implements reconcileSuperUsers logic in MakeSuperUsers that lists current platform admin relations and demotes any human admin relation subject not in the configured desired set when authoritative mode is enabled; tests and sample config updated with reconciliation behavior and recovery guidance.
Dependency injection wiring
cmd/serve.go
Passes auditRecordRepository into both serviceuser.NewService and user.NewService in buildAPIDependencies.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • whoAbhishekSah
  • AmanGIT07
🚥 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.

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


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.

@coveralls

coveralls commented Jun 21, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 27932033211

Coverage increased (+0.4%) to 44.154%

Details

  • Coverage increased (+0.4%) from the base build.
  • Patch coverage: 21 uncovered changes across 5 files (133 of 154 lines covered, 86.36%).
  • 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/bootstrap/service.go 42 37 88.1%
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: 37179
Covered Lines: 16416
Line Coverage: 44.15%
Coverage Strength: 12.39 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