Skip to content

feat: manage platform users via GitOps (relation-level grants, bootstrap superuser SA, reconcile CLI)#1719

Merged
rohilsurana merged 11 commits into
mainfrom
feat/platform-user-correctness
Jul 2, 2026
Merged

feat: manage platform users via GitOps (relation-level grants, bootstrap superuser SA, reconcile CLI)#1719
rohilsurana merged 11 commits into
mainfrom
feat/platform-user-correctness

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jun 30, 2026

Copy link
Copy Markdown
Member

What

This PR moves platform-user (admin and member) management to a file-based (GitOps) flow, and fixes how grants and revokes work along the way.

It was originally split out of #1709 as two PRs. The second one (#1720 — the bootstrap service account and the reconcile CLI) has since been merged into this branch, so this PR now carries the whole change.

⚠️ Breaking change — this removes app.admin.users. Only merge it once the new flow is ready: the bootstrap service account is configured and the reconcile files are in place. Otherwise human superadmins lose access until the first reconcile runs.

Changes

Platform-user correctness

  • Grant and revoke by exact relationSudo and UnSudo now act on the exact relation. This fixes a bug where turning an admin into a member did nothing: the old check saw that an admin could already pass, so it skipped adding member. It also lets UnSudo remove admin, not just member.
  • Remove one relation at a timeRemovePlatformUser now reads the new optional relation field (feat(frontier): add optional relation field to RemovePlatformUserRequest proton#489). Leave it empty to remove both admin and member, as before. Closes RemovePlatformUser is not relation-selective (asymmetric with AddPlatformUser) #1710.
  • List every relationListPlatformUsers now returns all the relations a user or service account holds, in metadata.relations. The old metadata.relation field stays for backward compatibility.
  • Audit events — we now emit platform.{admin,member}_{added,removed} on each grant and revoke.

GitOps flow

  • Bootstrap superuser service account (app.admin.bootstrap.{client_id, client_secret}) — created and promoted to superuser at boot, with a fixed id. You rotate the secret by changing the config. client_id must be a UUID. Boot recovery is safe to repeat: if the account row exists but its credential is missing, the next boot reuses the row and writes the credential. It never creates a duplicate row or a second credential.
  • Locked down — this service account cannot be changed through the API. It can't be removed, deleted, or given new credentials, tokens, or keys, not even by a superuser. The reconcile flow skips it too.
  • frontier reconcile command — reads a file that lists who should have access and makes the platform match it. Anyone listed is added; anyone not listed is removed. Adds run before removes, so a partial failure never leaves someone with no access. If an apply fails part-way, the report still shows what was applied. It lives in internal/reconcile and can support more kinds later; PlatformUser is the first.
  • Removes app.admin.users and MakeSuperUsers.
  • Supporting change — the org_name lookup in the service-user store can now be null, since the platform service account's org id points to no real organization row.

Testing

Unit tests pass (-race -count 2). Vet, lint, and format are clean. Full e2e tests pass (the test bench seeds data using the bootstrap service account). A live security check confirmed no one can remove or delete the service account, or create credentials for it, even when the per-object permission check is made to pass.

Related

@vercel

vercel Bot commented Jun 30, 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 Jul 2, 2026 6:07am

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds audit persistence for relation grants and revokes, makes platform removal relation-selective, replaces bootstrap promotion with bootstrap seeding and immutability guards, and adds platform reconciliation plus a new reconcile CLI command.

Changes

Platform audit, bootstrap seeding, and reconciliation

Layer / File(s) Summary
Audit types and contracts
pkg/auditrecord/consts.go, internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/*, core/user/mocks/*
Adds platform audit constants, extends UnSudo contracts with relationName, and regenerates the matching mocks and audit-record repository mocks.
User audit flow
core/user/service.go, core/user/service_test.go
Adds audit repository wiring to the user service, records platform audit events on sudo and unsudo, and updates user service tests for the new relation-scoped behavior.
Service-user audit flow
core/serviceuser/service.go, core/serviceuser/service_test.go, cmd/serve.go, core/serviceuser/errors.go
Adds audit repository wiring to the service-user service, blocks bootstrap credential deletion, records platform audit events on sudo and unsudo, and updates service-user tests and server wiring.
Platform handler behavior
internal/api/v1beta1connect/platform.go, internal/api/v1beta1connect/platform_test.go
Normalizes platform relations on add, makes removal relation-selective, and changes platform listing to group, deduplicate, and stamp relation metadata for users and service users.
Bootstrap seeding and immutability
config/sample.config.yaml, internal/bootstrap/*, internal/api/v1beta1connect/serviceuser.go, internal/api/v1beta1connect/serviceuser_test.go, test/e2e/testbench/*
Replaces bootstrap user promotion with bootstrap superuser seeding, adds bootstrap service-user immutability guards, updates bootstrap config and schema, and wires the new bootstrap flow into tests and e2e setup.
Reconcile framework and CLI
cmd/reconcile.go, cmd/root.go, Makefile, internal/reconcile/*
Adds the generic reconcile framework, the platform-user reconciler, their tests, and CLI commands that invoke reconciliation.
Estimated code review effort: 5 (Critical) ~120 minutes

Possibly related PRs

  • raystack/frontier#1567: Shares the core/user/service.go constructor and cmd/serve.go wiring surface with this change.
  • raystack/frontier#1689: Modifies the same internal/api/v1beta1connect/platform.go handler methods and related platform-user behavior.

Suggested reviewers: whoAbhishekSah, AmanGIT07

🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds large unrelated bootstrap, audit, reconcile, and testbench changes not required to make RemovePlatformUser relation-selective. Move the bootstrap seeding, audit/reconcile CLI, and related service changes into separate PRs so this one only covers RemovePlatformUser.
✅ Passed checks (1 passed)
Check name Status Explanation
Linked Issues check ✅ Passed RemovePlatformUser now accepts an optional relation, validates it, removes only that relation when set, and keeps removing both relations when omitted.

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 30, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 28569278944

Coverage increased (+0.8%) to 44.833%

Details

  • Coverage increased (+0.8%) from the base build.
  • Patch coverage: 120 uncovered changes across 11 files (449 of 569 lines covered, 78.91%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

Top 10 Files by Coverage Impact Changed Covered %
cmd/reconcile.go 60 31 51.67%
internal/bootstrap/bootstrapuser.go 106 77 72.64%
internal/reconcile/platformuser_reconciler.go 94 71 75.53%
internal/api/v1beta1connect/platform.go 64 57 89.06%
internal/reconcile/reconcile.go 31 24 77.42%
cmd/serve.go 6 0 0.0%
core/serviceuser/service.go 59 53 89.83%
core/user/service.go 50 44 88.0%
internal/bootstrap/service.go 3 0 0.0%
internal/reconcile/platformuser.go 67 65 97.01%
Total (13 files) 569 449 78.91%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/user/service.go 2 67.28%

Coverage Stats

Coverage Status
Relevant Lines: 37604
Covered Lines: 16859
Line Coverage: 44.83%
Coverage Strength: 12.49 hits per line

💛 - Coveralls

@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: 3

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)

163-189: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate relationName before auto-creating users.

For an unknown valid email, Sudo creates the user before rejecting an invalid relation, leaving persisted user data after a failed request. Move the relation validation to the top of the method.

Proposed fix
 func (s Service) Sudo(ctx context.Context, id string, relationName string) error {
+	// validate the 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)
+	}
+
 	currentUser, err := s.GetByID(ctx, id)
 	if errors.Is(err, ErrNotExist) {
 		if isValidEmail(id) {
 			// create a new user
@@
 	if err != nil {
 		return err
 	}
-
-	// validate the 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)
-	}
🧹 Nitpick comments (3)
internal/api/v1beta1connect/platform_test.go (1)

101-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the deterministic legacy relation value.

The handler sorts relations, so this test can lock both the full ordered list and the backward-compatible first relation instead of only checking non-empty.

🧪 Proposed test tightening
-			assert.ElementsMatch(t, []string{schema.AdminRelationName, schema.MemberRelationName}, got)
+			assert.Equal(t, []string{schema.AdminRelationName, schema.MemberRelationName}, got)
 			// "relation" stays populated for backward compatibility.
-			assert.NotEmpty(t, fields["relation"].GetStringValue())
+			assert.Equal(t, schema.AdminRelationName, fields["relation"].GetStringValue())
core/serviceuser/service_test.go (1)

32-76: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a no-op Sudo test for an already-present service-user relation.

This block only covers the grant path. A regression that re-creates an existing admin/member tuple—or emits a duplicate grant audit—would still pass for service users.

Example shape
+ t.Run("does not recreate an existing member relation", func(t *testing.T) {
+   svc, repo, _, rel, _, _ := newTestService(t)
+   repo.On("GetByID", ctx, suID).Return(serviceuser.ServiceUser{ID: suID, Title: "svc"}, nil)
+   rel.On("CheckPermission", ctx, platformRel(schema.MemberRelationName)).Return(true, nil)
+
+   if err := svc.Sudo(ctx, suID, schema.MemberRelationName); err != nil {
+     t.Fatalf("Sudo() error = %v", err)
+   }
+ })
core/user/service_test.go (1)

753-820: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mirror the admin idempotency check for the member Sudo path.

The admin branch has an already-present no-op case, but the member branch only covers creation. A member-specific regression could still duplicate the relation or grant audit without failing this suite.

Example shape
+ {
+   name:    "don't create user member if already created",
+   wantErr: false,
+   args: args{
+     id:           "test-id",
+     relationName: schema.MemberRelationName,
+   },
+   setup: func() *user.Service {
+     repo, relationService, sessionService, auditRecordRepository := mockService(t)
+     repo.EXPECT().GetByName(mock.Anything, "test-id").Return(user.User{
+       ID:   "test-id",
+       Name: "test",
+     }, nil)
+     relationService.EXPECT().BatchCheckPermission(mock.Anything, []relation.Relation{
+       {
+         Subject: relation.Subject{ID: "test-id", Namespace: schema.UserPrincipal},
+         Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace},
+         RelationName: schema.MemberRelationName,
+       },
+     }).Return([]relation.CheckPair{{
+       Relation: relation.Relation{
+         Subject: relation.Subject{ID: "test-id", Namespace: schema.UserPrincipal},
+         Object: relation.Object{ID: schema.PlatformID, Namespace: schema.PlatformNamespace},
+         RelationName: schema.MemberRelationName,
+       },
+       Status: true,
+     }}, nil)
+     return user.NewService(repo, relationService, sessionService, auditRecordRepository)
+   },
+ },

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6195a1d0-b561-4445-bf51-63b0c50fdb39

📥 Commits

Reviewing files that changed from the base of the PR and between 9c2795d and 7c4d6d9.

⛔ Files ignored due to path filters (1)
  • proto/v1beta1/admin.pb.go is excluded by !**/*.pb.go, !proto/**
📒 Files selected for processing (14)
  • Makefile
  • cmd/serve.go
  • 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
  • pkg/auditrecord/consts.go

Comment thread core/serviceuser/service.go
Comment thread core/user/service.go
Comment thread internal/api/v1beta1connect/platform.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.

🧹 Nitpick comments (2)
internal/api/v1beta1connect/platform_test.go (2)

46-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add parallel service-user cases for the new branch-specific behavior.

These tests only exercise the userService path, but platform.go now has separate serviceUserService branches for relation-scoped UnSudo and normalized Sudo. A regression in the service-user path would still pass this suite.

Also applies to: 94-106


76-83: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the Connect error code for invalid relations.

assert.Error will also pass on an unexpected internal failure. These cases should pin the API contract to connect.CodeInvalidArgument.

Suggested assertion tightening
 		assert.Error(t, err)
+		assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
 		assert.Nil(t, resp)

Also applies to: 108-115


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 62415c8c-90df-4847-8c6c-1a8231613429

📥 Commits

Reviewing files that changed from the base of the PR and between 7c4d6d9 and c556cbd.

📒 Files selected for processing (2)
  • internal/api/v1beta1connect/platform.go
  • internal/api/v1beta1connect/platform_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/v1beta1connect/platform.go

@whoAbhishekSah whoAbhishekSah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Overall LGTM. It might be cleaner to just log the audit log error instead of failing the call.

@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: 6

🧹 Nitpick comments (4)
cmd/reconcile.go (2)

36-53: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No timeout on the admin-API context used for reconcile RPCs.

RunE runs reconcile.Run with cmd.Context() directly, which has no deadline. If the admin API hangs (network partition, slow server), the CLI blocks indefinitely with no way out except Ctrl-C, and any half-applied AddPlatformUser/RemovePlatformUser sequence in platformuser_reconciler.go stalls mid-way.

♻️ Suggested fix
 		RunE: func(cmd *cli.Command, args []string) error {
+			ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
+			defer cancel()
 			data, err := os.ReadFile(filePath)
 			if err != nil {
 				return fmt.Errorf("read desired-state file: %w", err)
 			}
 			adminClient, err := createAdminClient(cliConfig.Host)
 			if err != nil {
 				return err
 			}
 			registry := map[string]reconcile.Reconciler{
 				reconcile.KindPlatformUser: reconcile.NewPlatformUserReconciler(adminClient, header),
 			}
-			reports, runErr := reconcile.Run(cmd.Context(), registry, data, dryRun)
+			reports, runErr := reconcile.Run(ctx, registry, data, dryRun)

58-58: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Auth secret passed as a plain CLI flag.

--header puts the raw Authorization: Basic <base64> value on the command line, which is visible in shell history and to any local user via ps. This mirrors a common CLI pattern (curl -H, kubectl), so it's not a blocker, but consider also accepting the header value via an env var (e.g. FRONTIER_RECONCILE_HEADER) to avoid shell-history/ps exposure for operators who prefer it.

internal/reconcile/platformuser_reconciler.go (1)

144-153: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Malformed --header value is silently ignored instead of erroring.

If header doesn't contain a :, authReq just skips setting it — requests then go out unauthenticated with no warning, surfacing later as a confusing auth error from the server instead of a clear CLI usage error.

♻️ Suggested fix
-func authReq[T any](msg *T, header string) *connect.Request[T] {
+func authReq[T any](msg *T, header string) (*connect.Request[T], error) {
 	req := connect.NewRequest(msg)
 	if header != "" {
-		if k, v, ok := strings.Cut(header, ":"); ok {
-			req.Header().Set(strings.TrimSpace(k), strings.TrimSpace(v))
+		k, v, ok := strings.Cut(header, ":")
+		if !ok {
+			return nil, fmt.Errorf("invalid header %q, want key:value", header)
 		}
+		req.Header().Set(strings.TrimSpace(k), strings.TrimSpace(v))
 	}
-	return req
+	return req, nil
 }
internal/reconcile/platformuser_reconciler_test.go (1)

14-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test simulates an API error mid-reconcile.

fakePlatformUserAPI always succeeds. Since Reconcile returns a partial Report (with Applied correctly reflecting ops before the failure) on an apply error, a test exercising that path would both document the intended behavior and catch the report-discarding issue in reconcile.go's Run.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5c26acc5-6d59-4cca-90f2-2e83b27d5bf5

📥 Commits

Reviewing files that changed from the base of the PR and between f31dda9 and 3334397.

📒 Files selected for processing (24)
  • cmd/reconcile.go
  • cmd/root.go
  • cmd/serve.go
  • config/sample.config.yaml
  • core/serviceuser/errors.go
  • core/serviceuser/service.go
  • core/serviceuser/service_test.go
  • internal/api/v1beta1connect/platform.go
  • internal/api/v1beta1connect/platform_test.go
  • internal/api/v1beta1connect/serviceuser.go
  • internal/api/v1beta1connect/serviceuser_test.go
  • internal/bootstrap/bootstrapuser.go
  • internal/bootstrap/bootstrapuser_test.go
  • internal/bootstrap/schema/schema.go
  • internal/bootstrap/service.go
  • internal/reconcile/platformuser.go
  • internal/reconcile/platformuser_reconciler.go
  • internal/reconcile/platformuser_reconciler_test.go
  • internal/reconcile/platformuser_test.go
  • internal/reconcile/reconcile.go
  • internal/reconcile/reconcile_test.go
  • internal/store/postgres/serviceuser_repository.go
  • test/e2e/testbench/helper.go
  • test/e2e/testbench/testbench.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/serviceuser/service_test.go
  • internal/api/v1beta1connect/platform.go
  • core/serviceuser/service.go

Comment thread internal/api/v1beta1connect/serviceuser_test.go
Comment thread internal/bootstrap/bootstrapuser_test.go
Comment thread internal/bootstrap/bootstrapuser.go
Comment thread internal/bootstrap/bootstrapuser.go Outdated
Comment thread internal/reconcile/reconcile.go
Comment thread test/e2e/testbench/testbench.go
…rviceUsers to avoid confusion with human users
@rohilsurana rohilsurana changed the title feat: relation-level platform-user grant/revoke + relation-selective removal + audit feat: manage platform users via GitOps (relation-level grants, bootstrap superuser SA, reconcile CLI) Jul 2, 2026
@rohilsurana rohilsurana merged commit cc4e300 into main Jul 2, 2026
8 checks passed
@rohilsurana rohilsurana deleted the feat/platform-user-correctness branch July 2, 2026 06:16
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.

RemovePlatformUser is not relation-selective (asymmetric with AddPlatformUser)

3 participants