Skip to content

feat(reconcile): add Permission and Role kinds, bootstrap only creates missing roles#1737

Merged
rohilsurana merged 3 commits into
mainfrom
feat/reconcile-roles-permissions
Jul 16, 2026
Merged

feat(reconcile): add Permission and Role kinds, bootstrap only creates missing roles#1737
rohilsurana merged 3 commits into
mainfrom
feat/reconcile-roles-permissions

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jul 3, 2026

Copy link
Copy Markdown
Member

Rebased onto main now that #1731 (export) and #1761 (schema cleanup) merged.

What

Two new desired-state kinds for frontier reconcile and frontier export, plus a bootstrap behavior change that makes role management through the API stick.

Permission kind

A permission is identity only (namespace + name). It is added or deleted, never updated.

kind: Permission
spec:
  - namespace: compute/order
    name: get
  - namespace: compute/order
    name: legacy
    delete: true
  • Base-schema permissions (app namespaces) are server-managed: the diff and export ignore them, and spec entries targeting them are rejected.
  • A custom permission on the server that the file does not mention fails the plan. Nothing is deleted by omission; deletion needs the explicit flag.
  • Validation mirrors the server: alphanumeric name, two-part service/resource namespace. Unknown fields in an entry are rejected as well (strict YAML decode), so a typo like delet: true fails the plan instead of being silently dropped — this applies to both kinds.

Role kind

Platform-level roles only. The role name is the identity and never changes. Title, description, permissions, and scopes are the managed fields. For custom roles, a field that is present in an entry is managed and a field that is omitted keeps its server value. Predefined roles converge to their shipped definitions instead: an entry overrides the fields it lists, an omitted field takes the definition's value, and a predefined role with no entry resets to the definition.

kind: Role
spec:
  - name: compute_order_manager
    title: Order Manager
    permissions: [compute_order_get, compute_order_update]
  - name: old_role
    delete: true
  - name: app_project_viewer       # predefined: override title and/or permissions
    permissions:                   # extends the predefined set with custom permissions
      - app_project_get
      - app_project_resourcelist
      - resource_aoi_get
  - name: app_organization_owner   # predefined: title-only override also works
    title: Workspace Owner
  • Custom roles must appear in the file — kept, or marked delete: true — so the plan fails loudly on unaccounted roles instead of removing anything by omission.
  • Predefined roles reset to their shipped definitions when not listed, so drift shows up in the plan (marked "not in file, reset to default") instead of being skipped. Removing a predefined role's entry from the file is therefore a visible action: the role goes back to its defaults on the next run.
  • Fields that are empty on the server (legacy rows without scopes, for example) converge only when an entry lists them explicitly — a file cannot represent an empty value, so export and diff both treat unlisted-empty as settled, keeping the round trip clean.
  • Deleting a predefined role is rejected (bootstrap would recreate it at boot), and creating missing ones stays boot's job.
  • Permission references accept any server-known form (slug, service/resource:verb, service.resource.verb); slugs are canonical.
  • Deleting a role that still has policy bindings fails with the server's foreign-key error, so a bound role cannot be dropped by accident.
  • Roles the reconciler creates or updates carry managed_by: frontier-reconcile in metadata. An update merges the managed keys (managed_by, description) over the role's existing metadata, so any other metadata keys an operator set are kept, not dropped.

Bootstrap change: existing roles are left alone

MigrateRoles used to write a role's definition back whenever its permission set drifted. That meant any change made through the API — including this reconciler's — was reverted on the next boot. Now boot creates a role only when it is missing and never updates an existing one. Operators own existing roles; the reconcile flow is the way to change them.

The definition-update job boot loses moves to reconcile: when a new frontier version changes a predefined role's definition, the first reconcile run with the new image plans that change for every role the file does not override, and applying it converges the deployment. The same update that used to happen invisibly at every restart now happens once, reviewed. This also means the reconcile CLI image should match the server's release — it carries the definitions the reset converges to.

Why no role renames

An earlier draft allowed renaming predefined roles. Verifying it showed the server resolves predefined roles by name at runtime in 13+ places (org owner constraint checks, group creation, invitation acceptance, service-user creation, admin listings, notifications). Renaming a predefined role would break all of them, so the name is fixed as the identity and only the display title is renameable.

Tests

  • Diff-level: adds/updates/deletes ordering, merge-over-current semantics for custom roles, predefined-role resets (unlisted drift, omitted-field convergence, empty-field carve-out), unaccounted-role and unaccounted-permission failures, predefined-role guards, permission-format normalization, duplicate and conflict detection.
  • Reconciler-level with fake APIs: apply paths incl. the metadata marker, dry-run, base-namespace invisibility, an unlisted drifted predefined role reset through the API, an unknown spec field failing the plan, and an update keeping metadata keys the reconciler does not manage.
  • Round-trips: exporting each kind and reconciling the output plans no changes, including a retitled predefined role and a legacy row with empty fields.
  • Bootstrap: existing role (even drifted) is not touched; missing role is created; transient Get errors do not fall through to create.

@vercel

vercel Bot commented Jul 3, 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 16, 2026 8:50am

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added Permission and Role support to export and reconcile commands.
    • Added dry-run planning, exporting, creation, updates, and explicit deletion controls for permissions and roles.
    • Added validation for resource definitions and required Permission-before-Role ordering.
  • Bug Fixes

    • Role creation now rejects requests without permissions.
    • Existing bootstrap-managed roles are no longer modified automatically.
  • Documentation

    • Updated CLI and reconciliation guides with supported kinds, rules, and deletion behavior.
    • Added deprecation notices directing permission and role management through reconciliation.

Walkthrough

The PR adds declarative reconciliation and export support for Permission and Role, validates desired-state ordering and role creation requests, changes bootstrap handling of existing roles, wires new reconcilers into CLI commands, and updates documentation and deprecation guidance.

Changes

Desired-state reconciliation

Layer / File(s) Summary
Permission reconciliation
internal/reconcile/permission.go, internal/reconcile/permission_reconciler.go, internal/reconcile/*permission*test.go
Adds permission validation, diffing, create/delete application, deterministic export, dry-run handling, and tests.
Role reconciliation
internal/reconcile/role.go, internal/reconcile/role_reconciler.go, internal/reconcile/*role*test.go
Adds custom and predefined role reconciliation, export/import round-tripping, managed metadata, validation, and tests.
Registry wiring and ordering
cmd/reconcile.go, cmd/export.go, internal/reconcile/reconcile.go, docs/content/docs/reconcile.mdx, docs/content/docs/reference/cli.mdx
Registers the new reconcilers, validates document ordering, and documents supported kinds and deletion rules.
Role bootstrap and API validation
internal/bootstrap/service.go, internal/bootstrap/service_test.go, internal/api/v1beta1connect/role.go, internal/api/v1beta1connect/role_test.go
Leaves existing bootstrap roles unchanged and rejects role creation with no permissions.
Deprecation guidance
cmd/serve.go, config/sample.config.yaml
Adds warnings and sample configuration guidance for deprecated resource management settings.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

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 Jul 3, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29484890319

Warning

No base build found for commit 27ca236 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 45.855%

Details

  • Patch coverage: 66 uncovered changes across 7 files (469 of 535 lines covered, 87.66%).

Uncovered Changes

File Changed Covered %
internal/reconcile/role_reconciler.go 142 121 85.21%
cmd/reconcile.go 20 5 25.0%
internal/reconcile/permission_reconciler.go 77 63 81.82%
internal/reconcile/role.go 204 194 95.1%
cmd/serve.go 4 0 0.0%
cmd/export.go 1 0 0.0%
internal/reconcile/permission.go 68 67 98.53%
Total (10 files) 535 469 87.66%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 38303
Covered Lines: 17564
Line Coverage: 45.86%
Coverage Strength: 13.28 hits per line

💛 - Coveralls

@rohilsurana

Copy link
Copy Markdown
Member Author

From the RFC review (#1751), verified on a live sandbox built from this branch: frontier export role omits predefined roles even after they have been modified. I narrowed app_organization_owner to [get, update, policymanage] via reconcile, then ran export — the output contained only the custom role. A file seeded from that export silently drops the override, and the RFC's "seed files from exports" guidance breaks for exactly the roles this PR makes overridable. Suggest: Role export should include predefined roles whose fields differ from the boot defaults in schema.PredefinedRoles, and keep omitting untouched ones. That also keeps the round-trip rule intact: reconciling the export output still plans no changes.

@rohilsurana

Copy link
Copy Markdown
Member Author

Checked this against a fresh sandbox built from the current branch, and the exact scenario works: narrowing app_organization_owner to [get, update, policymanage] via reconcile, then exporting, produces an entry for the owner with its changed permissions (title omitted since it still matches the default), and reconciling that export plans no changes. There is also a unit test now with exactly this scenario. The earlier repro most likely ran a stale binary.

The review still caught real gaps right next to it, fixed in 2cb94ca:

  • Scope overrides on predefined roles were never compared, so a scopes-only change was silently dropped from exports. Export now compares title, permissions, and scopes.
  • Managed-empty lists could not survive a round trip: YAML omits empty lists, so a predefined role emptied to zero permissions (or scopes) would be exported as if untouched. Rather than represent the unrepresentable, validation now rejects emptying a predefined role's permissions or scopes, and a custom role must list at least one permission.
  • The permission comparison now normalizes both sides, so duplicate slugs from the server cannot cause spurious or missed diffs.

Kept the differs-only export rather than always exporting predefined roles — the fixes are small, the files stay minimal, and the round-trip rule holds.

@rohilsurana

Copy link
Copy Markdown
Member Author

Verified the reconciler against the database identity model:

  • Roles: the database enforces UNIQUE(org_id, name), and scopes is a plain indexed column with no uniqueness. The reconciler matches: it lists platform-org roles only (so name alone is a complete identity inside its scope), treats scopes as a managed field never used for matching, upserts hit the same (org_id, name) conflict target, and the reserved org field lines up with the composite key for later.
  • Permissions: the database enforces both UNIQUE(namespace_name, name) and UNIQUE(slug). The diff is keyed by slug, which the slug uniqueness makes collision-free for live rows.

One edge fixed in d555799: slugs flatten / to _, and namespace parts may contain underscores, so two different file entries (for example resource/order_item:get and resource_order/item:get) produce the same slug. The diff used to silently treat the second as a duplicate; it now fails the plan naming both entries and the colliding slug.

@rohilsurana

Copy link
Copy Markdown
Member Author

Review notes (self-review after an independent pass)

Needs a decision — role metadata is wiped on update

roleBody always sends metadata: {managed_by: frontier-reconcile} and nothing else. The
server's UpdateRole builds metadata only from the request body, and the store replaces
the whole metadata column. Bootstrap seeds every predefined role with
{description: ...}. So the first reconcile update of a role — including an automatic
reset of a drifted predefined role — drops its description for good. Boot does not put
it back (skip-if-exists).

Nothing in non-test code reads role description today, and the default role metaschema
accepts the body, so nothing breaks right now. But it is silent data loss, and a stricter
role metaschema that requires description would make every reconcile role update fail.

Three ways to fix it:

  • A — merge in the CLI (recommended). The reconciler reads the role's current metadata
    and writes managed_by on top of it. Stays inside this PR, and no other API client is
    affected. Needs metadata carried through ListRoles into the diff. It is still a full
    replace at the API, so a metadata change made between our read and write is lost — but
    that is already how the flow behaves (last write wins).
  • B — merge on the server. UpdateRole merges the incoming metadata over what the role
    already has. Fixes it for every client, but it changes the public API: a caller can no
    longer drop a key by leaving it out. Bigger change, needs maintainer agreement.
  • C — drop the marker. Stop stamping managed_by. No data loss and no complexity, but
    we lose the way to tell reconcile-managed roles apart, which is the reason the marker
    exists.

Leaning towards A. Open to input before I implement.

Fixed

  • A platform role could be created with no permissions. UpdateRole already rejected
    an empty permission list, but CreateRole did not, so a zero-permission role could
    exist and then fail to reconcile back from its own export. CreateRole now rejects it
    too, with a test. (bff42ac)
  • The reconcile guide did not cover the new kinds. docs/content/docs/reconcile.mdx
    now documents the Permission and Role kinds, the reset-to-default behavior for
    predefined roles, and the permission-before-role ordering rule. (2ec0b55)

Nits (not blocking)

  • The server permission list is compared as a set but is not de-duplicated first
    (role.go, sortedCopy(cur.Permissions)). A role row with a repeated slug would plan a
    spurious update every run. DB uniqueness makes this unlikely; worth de-duplicating both
    sides anyway.
  • On a partial apply, the printed report lists the full plan while the applied count is
    correct, so the list can show more lines than were applied. Cosmetic.

@rohilsurana

Copy link
Copy Markdown
Member Author

Update on the metadata-wipe item above: description is now a managed spec field
(24fc1165), read from and written to role metadata, and treated the same as title for
custom and predefined roles. That closes the real harm. Reconcile-managed platform roles
only ever carry managed_by and description in metadata, and both now round-trip, so a
reconcile update no longer drops anything an operator can see.

The only residual is a metadata key someone sets directly on a platform role outside this
flow (not managed_by or description); a later reconcile update would drop it, which is
consistent with reconcile owning the role's declared fields. If we ever want to keep such
keys, option B (merge on the server) is the way. Leaving that out for now.

Base automatically changed from feat/reconcile-export to main July 16, 2026 05:41

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


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 24d5ff50-595e-4360-a7e8-c16b595f20cd

📥 Commits

Reviewing files that changed from the base of the PR and between 27ca236 and a226f47.

📒 Files selected for processing (19)
  • cmd/export.go
  • cmd/reconcile.go
  • cmd/serve.go
  • config/sample.config.yaml
  • docs/content/docs/reconcile.mdx
  • docs/content/docs/reference/cli.mdx
  • internal/api/v1beta1connect/role.go
  • internal/api/v1beta1connect/role_test.go
  • internal/bootstrap/service.go
  • internal/bootstrap/service_test.go
  • internal/reconcile/permission.go
  • internal/reconcile/permission_reconciler.go
  • internal/reconcile/permission_reconciler_test.go
  • internal/reconcile/permission_test.go
  • internal/reconcile/reconcile.go
  • internal/reconcile/role.go
  • internal/reconcile/role_reconciler.go
  • internal/reconcile/role_reconciler_test.go
  • internal/reconcile/role_test.go

Comment thread docs/content/docs/reconcile.mdx
Comment thread docs/content/docs/reference/cli.mdx
Comment thread internal/reconcile/permission_reconciler.go
Comment thread internal/reconcile/role_reconciler.go Outdated
Comment thread internal/reconcile/role.go
@rohilsurana rohilsurana merged commit 3c08b2f into main Jul 16, 2026
7 checks passed
@rohilsurana rohilsurana deleted the feat/reconcile-roles-permissions branch July 16, 2026 09:49
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