diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md new file mode 100644 index 000000000..fda83b2ca --- /dev/null +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -0,0 +1,455 @@ +# RFC 0001: Declarative management of platform resources + +| | | +|---|---| +| **Status** | Proposed (the PlatformUser kind shipped in v0.108.0; every other kind is proposed here) | +| **Author** | Rohil Surana | +| **Created** | 2026-07-09 | +| **Pull requests** | #1719 (shipped), #1731 and #1737 (drafts for this proposal) | + +## Summary + +Frontier gains a declarative way to manage its platform configuration. A YAML file +describes what should exist for one kind of resource. The `frontier reconcile` command +compares the file with the live server, prints a plan, and applies the difference through +the admin API. The `frontier export` command prints the live state in the same file format, +so a file can be seeded from a running server. + +Platform users shipped as the first kind and set the pattern. This RFC writes down the +rules every kind must follow, and proposes custom permissions and platform-level roles as +the next two kinds under those rules. Their implementation follows this RFC's approval and +changes with it. + +## Terminology + +| Term | Meaning | +|---|---| +| **SpiceDB** | The database Frontier uses to answer permission checks. It stores a schema (the rules) and relationships (the data). All Frontier pods share one SpiceDB. | +| **Base schema** | The SpiceDB rules built into the Frontier binary. It defines the object types (`app/organization`, `app/role`, ...) and how a permission check can be satisfied. It only changes at runtime to add custom permissions. | +| **Operator** | A person who runs a Frontier installation: edits desired-state files, reviews plans, and applies them. Different from a product user, who uses Frontier through applications. | +| **Kind** | One type of managed resource, for example `PlatformUser` or `Role`. Each kind has its own file format and its own rules. | +| **Reconcile** | Compare a desired-state file with the live server and make the server match. Runs through the admin API. | +| **Platform user** | A user or service user with platform-wide access: relation `admin` (superuser) or `member`. | +| **Service user** | A machine account (what other systems call a service account). Owned by an organization, authenticates with a client id and secret. | +| **Predefined role** | One of the roles built into Frontier (`app_organization_owner`, `app_project_viewer`, ...). Boot creates them when missing. Other Frontier code looks them up by name, so their names must never change. | +| **Custom role** | A platform-level role an operator created. Fully owned by the desired-state file. | +| **Bootstrap service user** | The automation superuser the server creates at boot from `app.admin.bootstrap`. Meant for tooling like this. The server guards it, and the reconciler refuses to touch it. | + +## Problem + +Frontier's platform configuration was managed in three ways. All three had problems. + +1. **Superusers came from server config** (`app.admin.users`). Adding someone meant a config + change and a restart. Removing someone did not work at all: the server only promoted, + never demoted. Stale superusers stayed until someone cleaned the database by hand. +2. **Custom permissions and roles came from boot-time YAML** (`resources_config_path`). The + loader only adds and updates. It never removes anything. There is no preview: the first + sign of a bad file is a server that will not boot. Every change needs a restart. +3. **Everything else was changed by hand through the admin API.** Nothing records what + should exist. Changes drift silently. Nobody can review them. + +## Goals + +- One reviewable file that says what should exist, per resource kind. +- A plan (dry run) before every change. +- Removal works. All three old flows could add things but not take them away. +- Changes survive restarts and version upgrades. +- Nothing is removed invisibly. Every removal appears in a plan that can be read before + it applies. +- New kinds plug in without changing the commands or the file format. + +## Non-goals + +- Managing user data (organizations, projects, groups, users, policies). People manage + those through the product. A file would fight them. +- Renaming a role's internal name. See decision 5. +- Deciding how deployments store their files or trigger runs. This RFC defines the + commands and their behavior; each installation wires them into its own process. + +## Design + +### The file format + +A desired-state file holds one or more YAML documents. Each document names the format +version and a kind, and carries a spec: + +```yaml +apiVersion: v1 +kind: PlatformUser +spec: + - type: user + ref: alice@example.org + relation: admin +``` + +`apiVersion` exists so a future change to a kind's spec shape can be told apart from old +files. A document without it is read as `v1`, so files written before the field existed +keep working; an unknown version is rejected. The version belongs to the document, so one +kind's spec shape can change without touching the files of other kinds. `frontier export` +always writes the field, so any file regenerated from an export carries it. A document +with a missing spec is rejected, because that is usually a typo. An empty list must be +written as `spec: []`. + +### The commands + +- `frontier reconcile -f [--dry-run] --host -H "Authorization:Basic "` + parses and checks the whole file first — version known, every kind registered, every + spec present — so a malformed later document stops the run before anything applies. + Documents then dispatch in file order. It prints one report per kind: `no changes`, a + plan (dry run), or what was applied. +- `frontier export ` prints the live state of one kind as a desired-state document on + stdout. The kind argument is case-insensitive and accepts a plural. + +Both commands talk to the admin API. They log in with any superuser credential; the +bootstrap service user exists so automation always has one. The `-H` flag is an interim +way to pass the token: process listings can see command arguments, so automation must +mask the token in its logs, and a file or environment input is planned (see future work). + +### The framework + +Each kind implements a small interface in `internal/reconcile`: + +- `Reconcile(spec, dryRun)`: read the current state through the API, diff it against the + spec, report the plan, and apply it unless this is a dry run. +- `Export()`: return the current state as a spec value. Part of the same interface, so a + kind cannot be registered without it — rule 9 depends on every kind having it. + +New kinds register in one map in the CLI. The behavior rules every kind must follow are +in the next section. + +One reconcile run, end to end: + +```mermaid +flowchart TD + FILE[Desired-state file] --> PARSE["Parse and check the whole file: + apiVersion known, kinds registered, specs present"] + PARSE -- any problem --> STOP[Fail before anything applies] + PARSE --> DOC[Next document, in file order] + DOC --> VAL[Validate every entry in the spec] + VAL --> FETCH[Read current state through the admin API] + FETCH --> DIFF[Diff current against desired] + DIFF --> PLAN["Plan: adds and updates first, removes last"] + PLAN -- dry run --> PRINT[Print the plan, change nothing] + PLAN -- apply --> OPS["Apply each op as one API call + (no transactions, no rollback)"] + OPS -- an op fails --> REPORT["Stop; report what applied. + Fix and re-run: it converges"] + OPS --> DONE[Report applied ops] + DONE --> DOC +``` + +Export is the same loop in reverse: the kind reads current state through the API and +prints it as a document that reconciling would leave unchanged. + +## How kinds behave + +### Rules for every kind + +These rules apply to every kind, current and future. A new kind must fit them. Anything +it cannot fit is an exception, and exceptions are written down as clearly as the +predefined-role rules below. + +1. **A kind manages operator-owned objects only.** Objects the server manages are + invisible: the diff skips them, exports omit them, and file entries that target them + are rejected. +2. **Every entry has a stable identity**, like a name or a namespace-plus-name pair. The + identity is how file entries and server objects match. Reconcile never changes an + identity. Renaming something means deleting one entry and creating another. +3. **A listed field is managed. An omitted field keeps its server value.** A kind that + resets to defaults (rule 4) declares a different fallback: there, an omitted field + takes the default value. The predefined-role rules below do this. +4. **Every kind declares what a missing entry means.** There are three allowed answers, + and each kind picks exactly one: + - **The plan fails** — the default. The file must account for every operator-owned + object: keep it, or mark it `delete: true`. Kinds that define structure use this. + - **Removal** — only for kinds whose purpose is controlling a small access list, + where absence meaning removal is the feature. + - **Reset to the default** — only where every object always has a value and "not + managed" has a well-defined default to return to: settings-like kinds, and + predefined roles. + Silently skipping an operator-owned object is not an allowed answer. Drift must + either fail the plan or show up in it. +5. **Destructive deletion needs an explicit `delete: true`**, unless the kind picked + removal-by-absence in rule 4. A kind whose API has no delete must define what the + flag means (for example, deactivate) or reject it. +6. **Validation runs before anything applies, and kinds stay independent.** The whole + file is parsed and checked before the first document dispatches, and one bad entry + fails its whole document. An entry with an unknown field is a bad entry: a typo like + `delet: true` fails the plan instead of being silently ignored, which would make a run + quietly do the wrong thing. Kinds do not validate against each other: a reconciler + never resolves another kind's references, so documents can contradict each other — + for example, a Permission document deletes a permission that a Role document still + grants. The server is the enforcement point: the contradicting API call fails with a + clear error, the run stops there, and nothing is lost. A dry run cannot see such a + contradiction (it reads the state before the delete), but the next dry run after the + failed apply shows the drift. The trade for this simplicity is accepted on purpose. +7. **Adds and updates run before removes, and nothing is transactional.** Every API call + commits on its own; a run can stop after any of them, and nothing rolls back. The + adds-first order means a half-finished apply can leave extra access for a moment, but + never less than intended. On a failure mid-apply, the report still shows what was + applied, and recovery is always the same: fix the cause and apply the file again. + What already applied plans as no change, and the run continues from the difference. +8. **Dependency order inside a file is the author's job.** The tool applies documents in + the order they appear and does not reorder them; an object must exist before another + document references it, so a Permission document goes before a Role document. Validation + should reject a file that places a Role document ahead of a Permission document, so a + wrong order fails fast instead of failing mid-apply. +9. **Every kind implements export.** Output is sorted and deterministic, omits anything + that matches server defaults, and round-trips: reconciling an export's output always + plans no changes. + +### The first three kinds + +PlatformUser is shipped; Permission and Role are proposed with the behavior below. The +table shows how the three fill in rules 4 and 5; the sections after it cover only what is +specific to each kind. + +| Case | PlatformUser | Permission | Role (custom) | Role (predefined) | +|---|---|---|---|---| +| Exists on server, not in the file | **Access removed** | **Plan fails** | **Plan fails** | **Reset to its definition** | +| In the file, not on the server | Added | Created | Created | Plan fails (boot creates these) | +| In both, but different | Relation set adjusted | Cannot differ (identity only) | Listed fields updated | Listed fields override the definition; omitted fields reset to it | +| How to delete | Remove the entry | Entry with `delete: true` | Entry with `delete: true` | Not allowed | +| Fields that can change | The relation set | None | `title`, `description`, `permissions`, `scopes` | `title`, `description`, `permissions`, `scopes` | + +### PlatformUser + +An entry is `{type, ref, relation}`: a user (by email or id) or a service user (by id), +with relation `admin` or `member`. + +- Picks **removal** for a missing entry: the file is the full access list, and removal is + the point of the kind. The list is small and easy to review. +- The diff works per person and per relation. Someone with both `admin` and `member` has + two entries, so changing one relation never touches the other. +- Adding a user by an email that does not exist creates that user. +- The bootstrap service user is server-managed (rule 1). + +### Permission + +An entry is `{namespace, name}`, for example `compute/order` + `get`. A permission is only +an identity, so it has no updatable fields: it is created or deleted. + +- Picks **the plan fails** for a missing entry, because deleting a permission also strips + it from every role that grants it. +- Creating a permission also creates its namespace and updates the SpiceDB schema, so it + is usable in roles right away, on all server pods at once. +- The server adds two delete guards of its own: it refuses to delete a permission that a + boot config file still defines (it would come back on restart; this guard lives only as + long as the deprecated loader — see server-side changes), and it refuses to delete the + last permission of a namespace that still has live relationships. +- Base-schema permissions (`app/...` namespaces) are server-managed (rule 1). Names are + alphanumeric; namespaces have exactly two parts (`service/resource`). + +### Role + +An entry is `{name, title, description, permissions, scopes, delete}`. The name is the +identity. Only platform-level roles are covered; organization-scoped roles are user data. +The description is stored in role metadata, and it is managed the same way as the title. + +- Custom roles pick **the plan fails** for a missing entry. A new custom role must list + its permissions. Permission references may use any format the server knows (`slug`, + `service/resource:verb`, `service.resource.verb`); slugs are canonical, and the + permission list compares as a set. +- **Predefined roles are the one exception to rule 1**: they are server-created, but the + flow manages them, and they pick **reset to the default** for a missing entry. Every + predefined role on the server converges to its shipped definition: a listed entry + overrides exactly the fields it lists (a different title, an extended permission set), + an omitted field takes the definition's value (the rule 3 exception, with one edge for + empty server values described below), and a role with no entry at all resets fully. The + plan marks those updates with "not in file, reset to default", so removing an entry from + the file is a visible action, not a silent one. Deleting a predefined role is rejected — + boot would recreate it — and a listed one must already exist, since creating them is + boot's job. Definitions are matched to roles by name; if the shipped list names one more + than once, the first definition wins, the same rule boot follows. +- Two edges of the reset, both consequences of the file format. A field whose server + value is empty (a row from before the `scopes` column, say) converges only when an + entry lists it explicitly: a file cannot hold an empty value, so treating it as + drift would break the export round trip (rule 9). And the definitions are the ones + compiled into the CLI, so the reconcile image should match the server's release — a + newer or older image would plan resets toward the wrong defaults. When a release + changes a predefined role's definition, the first run with the new image plans that + change; this is how new defaults roll out (see server-side changes). +- The server refuses to delete a role that any policy still binds, with a clear "role is + in use" error. Unbind first, then delete. +- Roles the reconciler creates or updates carry `managed_by: frontier-reconcile` in their + metadata, so their origin stays visible. An update merges these managed keys over the + role's existing metadata, so any other metadata keys an operator set stay in place. + +**What a role permission change actually does.** A role's permissions live as data +relationships in SpiceDB, one per permission the role grants. An update swaps that set in +one step. Every principal holding the role gains or loses the permission at that moment, +across all server pods, with no restart. + +**One warning when narrowing roles.** The SpiceDB schema also grants permissions +directly, outside roles: almost every predefined organization permission carries a +direct `owner` grant, several carry `member`, and all carry the platform superuser path. +An org owner keeps `rolemanage`, `policymanage`, `delete`, and the rest no matter what +the owner role says, because every membership path pairs the role with the `owner` +relation. So narrowing a predefined role restricts only principals whose access comes +purely through the role — verified live: after narrowing `app_organization_owner`, a +role-only holder was denied while a real owner kept everything. Only custom permissions +flip cleanly for everyone, because they are granted purely through role paths. Before +narrowing a predefined role, check the resource's permission line in `base_schema.zed`. +This limitation is temporary: relation-based ownership is planned for removal. The +direct `owner` and `member` grants come out of the base schema, and ownership is granted +through roles instead. From then on, removing a permission from a predefined role +restricts everyone holding it, exactly as expected. That migration is its own piece of +work (see future work), but it is why the limitation exists and where it goes. + +### Future kinds + +A new kind starts from the rules above and writes down its two choices before any code: +what a missing entry means (rule 4) and what deletion means (rule 5). The likely next +kinds map cleanly: platform settings would reset missing entries to their defaults; +webhook endpoints would fail the plan and delete by flag; plan-like resources whose API +only archives would either treat the delete flag as deactivation or reject it. A kind +that cannot express its behavior within these rules needs its exception written into +this document first. + +## Server-side changes + +Two boot behaviors change to make this flow work. The first already shipped; the second +is part of this proposal. + +1. **Superuser promotion from config is gone** (shipped in v0.108.0). The server no longer + reads `app.admin.users`. The only config-driven account is the bootstrap service + account, and the server guards it: its platform access cannot be removed, its + credential is created once and recovered idempotently, and API errors about it stay + generic. +2. **Boot stops updating existing roles** (proposed; draft in #1737). Today boot writes + every predefined role's definition back on each boot, which reverts any change made + through the API. With this change, boot creates a role only when it is missing and + never touches an existing one. + +The definition-update job boot loses does not disappear — it moves to reconcile. When a +new Frontier version changes a predefined role's definition, the first reconcile run with +the new release's image plans that change for every predefined role the file does not +override, a human reads the plan, and applying it converges the installation. The same +update that used to happen invisibly at every restart now happens once, reviewed. A +brand-new installation works the same way: boot installs plain defaults, and the first +reconcile brings it to the desired state. + +### Deprecating the boot-time loader + +Leaving `resources_config_path` alive would give the same objects two owners: a +permission defined in a boot file can never be deleted through reconcile until someone +also edits that file and restarts, and a role the boot file created can be changed by +reconcile, after which the file no longer describes the server. So the loader goes: + +- `resources_config_path` is deprecated as of this RFC and removed after two minor + versions. +- Migration is one export: run `frontier export permission` and `frontier export role`, + commit the output as the desired-state files, and drop `resources_config_path` from + the server config. +- The permission delete guard that checks the boot files stays for the deprecation + window and goes away with the loader. + +## Decisions and reasons + +1. **Three different rules for a missing entry.** Platform users: removed, because + controlling that list is the kind's purpose and the list is small. Permissions and + custom roles: the plan fails, because their removal has a bigger blast radius, and + failing shows drift instead of hiding it. Predefined roles: reset to their + definitions, because they always exist and have a well-defined default to return + to — listing all of them would be noise, so "not listed" means "at the defaults", + and any drift shows up in the plan instead of being skipped. +2. **Deleting is always an explicit flag** for permissions and roles. Nothing structural + is removed because a line disappeared from a file. +3. **Adds and updates before removes**, in every kind. Partial failure must never leave + less access than intended. +4. **Seed files from exports, not by hand.** Exports capture drift that config files never + see. Reconciling an export plans no changes, which makes a kind's first run safe by + construction. +5. **No role renames.** The server looks predefined roles up by name in more than a dozen + live code paths (owner constraint checks, group creation, invitation acceptance, + service user creation, admin listings, notifications). Renaming the name would break + them all. The display `title` is the thing to rename, and the file can manage it. +6. **Permissions apply before roles** when both change: roles reference permission slugs, + so the permissions must exist first. Deleting a permission cascades out of the roles + that grant it, so the reverse order is not needed for removal. +7. **Reads and writes use the public API**, not the database. The reconciler is an API + client like any other: every change is validated, audited, and visible to all pods at + once. Each kind needs the server APIs and behavior it depends on, so each kind states + its minimum server version: PlatformUser needs v0.108.0, and Permission and Role need + the release that ships this proposal (the boot change is what makes role edits stick). + For Role, the CLI image should match the server release too: it carries the + predefined-role definitions that the reset converges to. +8. **Convergence instead of transactions.** The API has no way to group changes into one + atomic unit, and building one (a staging area, a rollback log) would be a large piece + of machinery. We chose not to. Instead, the file is declarative — it describes the end + state, not steps — and applying it is idempotent, so it is safe to apply any number of + times. Whatever a failed or interrupted run left behind, the next run picks up exactly + the remaining difference and converges to the desired state. Re-running the file is + the one recovery procedure for every failure mode: a crashed run, a rejected API call, + or a lost race with another writer. + +## Alternatives considered + +- **Keep the boot-time loaders.** They cannot remove anything, cannot preview, and couple + a config typo to a failed boot. This flow exists to replace them. +- **A combined file format for all kinds in one document.** The multi-document format + already allows it; the per-kind interface keeps each kind's rules separate either way. +- **A marker-based boot skip** (only skip roles a reconcile has touched, keep updating the + rest). It would preserve default-permission updates on upgrades for untouched roles. + The blanket skip won because it is simpler to reason about — boot never edits existing + roles, full stop — and the reset-to-default behavior carries the upgrade flow through + reconcile instead, so nothing is lost. +- **Leaving unlisted predefined roles alone** instead of resetting them. Safer-looking — + removing a line could never change access — but it silently skips drifted roles, cuts + off definition updates from new releases, and makes "reset to defaults" inexpressible + except by writing every default out by hand. The reset keeps every change inside a + reviewable plan, which is the protection this flow actually relies on. +- **Renaming predefined role names via a mapping.** Broken by the name lookups in + decision 5. Would need a large refactor of the membership, invitation, and notification + paths. Titles cover the real need. + +## Drawbacks and accepted trade-offs + +- Predefined role definition updates from new versions no longer apply at boot; they + wait for the next reconcile run with the new release's image. Until someone runs it, + an upgraded installation keeps the old role definitions. +- Removing a predefined role's entry from the file is an action: the role resets to its + definition on the next run, which can widen or narrow live access. The plan marks + these updates ("not in file, reset to default"), but operators must know that for + predefined roles, absence means defaults — the guides say so. +- The reset target is the definition set compiled into the CLI, so the reconcile image + must match the server's release, or plans drift toward the wrong defaults. Serving + the definitions from the server itself would remove this coupling (future work). +- A failed apply can stop between adds and removes — there are no transactions and no + rollback. The report shows exactly how far it got, adds-first ordering keeps the + failure mode on the safe side, and applying the file again converges to the desired + state (decision 8). +- The reconciler trusts the file. Review of the file and of the dry-run plan is the + safety net; the commands do not ask for confirmation themselves. +- Concurrent runs are last-write-wins. Read, diff, and apply are not atomic, so two + reconciles racing each other — or one racing a manual admin change — can overwrite each + other. This is acceptable because the flow is declarative: re-running the same file + converges to the same end state, so a lost race is corrected by the next apply. + +## Future work + +- More kinds are natural fits: platform preferences (upsert-only, reset-to-default on + removal), webhooks (full lifecycle), and billing plans (replacing the boot-time plans + loader). +- Metaschemas cannot become a kind yet: the server caches them per pod at boot, so an API + change does not reach other pods. The cache needs a refresh or read-through first. +- Hardening how the CLI receives the auth header, so it is not visible in the process + list. +- A read-only API that returns the server's own predefined-role definitions. The CLI + would fetch the reset target from the running server instead of its compiled copy, + removing the image-version coupling and failing cleanly against servers that predate + the Role kind. +- Removing relation-based ownership from the base schema: the direct `owner` and + `member` grants go away and ownership is granted through roles. This makes + predefined-role permission changes apply to everyone, including org owners, and + removes the narrowing limitation described in the Role section. + +## References + +- #1719: PlatformUser kind, the reconcile framework, and the bootstrap service user + (shipped in v0.108.0). +- #1731: the `export` command and stdout fixes (draft for this proposal). +- #1737: the Permission and Role kinds and the boot change for existing roles (draft for + this proposal; changes with this RFC's review). +- CLI reference: `docs/content/docs/reference/cli.mdx`, the `reconcile` and `export` + entries.