From c567d48ef08bc88facb1d7941a245ab0673b2659 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Mon, 29 Jun 2026 15:09:32 +0530 Subject: [PATCH 01/11] docs(authz): explain custom resource loading and role inheritance Add a doc covering how custom resource types are registered, what SpiceDB rules the bootstrap generator creates for each action, and which role each action ends up with. It also explains why an Org Admin can list resources but cannot get a single one: listing accepts app_project_get as org-wide visibility, while the per-resource get check does not. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 317 +++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/content/docs/authz/custom-resources.mdx diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx new file mode 100644 index 000000000..03c92d568 --- /dev/null +++ b/docs/content/docs/authz/custom-resources.mdx @@ -0,0 +1,317 @@ +--- +title: Custom Resources and Role Inheritance +order: 7 +--- + +# Custom Resources and Role Inheritance + +Frontier lets services register their own resource types (for example `compute/machine`). +Once registered, Frontier can answer permission checks on those resources the same way it +does for built-in types like projects and organizations. + +This page explains three things: + +1. How a custom resource type is loaded into Frontier. +2. What permission rules Frontier generates for it, and which role each action ends up with. +3. Why a role can **list** a resource but still fail to **get** a single one. This is the part + that surprises people most, so it has its own section. + +--- + +## How custom resources are loaded + +A custom resource type is described in a small config file. Each file lists a namespace and +the actions (permissions) that namespace supports. Here is the built-in `compute/machine` +example from `resources_config/compute_machine.yml`: + +```yaml +permissions: + - name: get + namespace: compute/machine + - name: create + namespace: compute/machine + - name: update + namespace: compute/machine + - name: delete + namespace: compute/machine +``` + +A namespace has two parts joined by a slash: `service/resource`. So `compute/machine` is the +`machine` resource in the `compute` service. + +At startup Frontier runs a bootstrap step (`MigrateSchema`) that does the following: + +1. Reads every resource config file into a `ServiceDefinition` (the list of namespaces and + their actions). +2. Loads the base SpiceDB schema (`base_schema.zed`), which defines users, organizations, + projects, roles, and role bindings. +3. Generates extra rules for each custom action and merges them into the base schema. +4. Validates the merged schema, writes the permission list to Postgres, and writes the full + schema to SpiceDB. + +This step is idempotent. It runs on every boot and recreates the same schema, so adding a new +resource config and restarting is all it takes to register a new type. + +```mermaid +flowchart LR + A[resource config files] --> B[ServiceDefinition] + C[base_schema.zed] --> D[merge + generate rules] + B --> D + D --> E[validate] + E --> F[(Postgres: permissions)] + E --> G[(SpiceDB: schema)] +``` + +--- + +## What rules get generated + +For **each** action on a custom resource, the generator adds a rule in four places. The +action name is flattened into a single slug: namespace `compute/machine` with action `get` +becomes `compute_machine_get`. + +Below are the rules generated for the `get` action on `compute/machine`. The `+` sign means +"or", so a principal passes the check if **any** line matches. + +**On the resource itself** — who can `get` one machine. The resource definition is named +after its namespace, so the check runs against `compute/machine:`: + +``` +compute/machine#get = owner + + project->app_project_administer + + project->compute_machine_get + + granted->compute_machine_get +``` + +**On the organization** — the org-wide version of the action: + +``` +app/organization#compute_machine_get = owner + + platform->superuser + + granted->app_organization_administer + + granted->compute_machine_get + + pat_granted->app_project_administer + + pat_granted->compute_machine_get +``` + +**On the project** — the project-wide version, which pulls from the org: + +``` +app/project#compute_machine_get = org->compute_machine_get + + granted->app_project_administer + + granted->compute_machine_get +``` + +**On the role and role binding** — so a role can carry the action: + +``` +app/rolebinding#compute_machine_get = bearer & role->compute_machine_get +app/role: relation compute_machine_get: app/user:* | app/serviceuser:* | app/pat:* +``` + +When a resource is created, Frontier also writes an `owner` relation to the creator and a +`project` relation linking the resource to its project. Those two links are what make the +arrows above resolve. + +--- + +## Which action goes to which role + +There are two layers, and it helps to keep them apart: + +- **The schema** (generated above) fixes the *paths* a check can travel. +- **The roles** decide *which permissions a principal actually holds*. + +A principal gets access to a custom action only when both line up. Here is who can `get` a +custom resource and how each one reaches it. + +| Who | How they reach `get` | Granted automatically? | +| --- | --- | --- | +| Resource owner (creator) | `owner` arrow on the resource | Yes, on create | +| Platform admin | `platform->superuser` | Yes | +| Org Owner role (`app_organization_administer`) | org rule's `granted->app_organization_administer` | **Yes** — every custom action, for free | +| Org `owner` relation | org rule's `owner` arrow | Yes | +| A project role that lists the action | `project->compute_machine_get` -> `granted->compute_machine_get` | Only if the role lists it | +| A project admin role (`app_project_administer`) | `project->compute_machine_get` -> `granted->app_project_administer` | Only if the role grants project admin | +| A direct grant on the resource | `granted->compute_machine_get` on the resource | Only if a policy is set on the resource | + +The key point about the **Org Owner** role: the org-level rule hardcodes +`granted->app_organization_administer`. So whoever holds the Owner role on an organization can +perform **every** custom action on **every** resource in that org, without any project or +resource grant. This is on purpose. + +The **Org Admin** role (`app_organization_manager`) is different. Its permissions are: + +``` +app_organization_update, app_organization_get, app_organization_projectcreate, +app_organization_projectlist, app_organization_groupcreate, app_organization_grouplist, +app_organization_serviceusermanage, app_project_get, app_project_update +``` + +None of these appears anywhere in the custom-action rules above. So the Org Admin role does +**not** get custom resource actions through org inheritance. To act on a custom resource, an +Admin would need a project role that lists the action, a project admin role, or a direct grant +on the resource. + +--- + +## Creating resources: a special case (best practice) + +`create` is not like `get`, `update`, or `delete`. You check those against a resource that +already exists. But you check `create` *before* the resource exists, so there is no +`compute/machine:` to check against. + +The generator still emits a `compute/machine#create` rule on the resource, but it is not useful +for a real create. It would need a resource id you do not have yet. So in practice that rule is +dead. + +The right model is to treat "create" as a capability on the **container** — the project — and +check it against the project id, with the caller as the subject: + +``` +Check( + subject = app/user:, # the authenticated caller + permission = user_project_createcomputemachine, + resource = app/project:, # the container — it already exists +) +``` + +### Why the permission can't live in `app/project` + +It would read most naturally as `app/project:createcomputemachine`. That does not work. At boot, +bootstrap drops any permission whose namespace starts with `app` (the +`filterDefaultAppNamespacePermissions` step). The `app/*` types belong to the base schema and are +rebuilt on every start, so config is not allowed to add permissions to them. A +`app/project:createcomputemachine` entry in a config file is silently ignored. + +So the create capability gets its own namespace. By convention this is `user/project` — read it +as "something a user can do inside a project". The generator then mirrors it onto the project as +`app/project#user_project_createcomputemachine`, and that mirrored permission is what you check. + +> **Don't confuse two things.** A role *can* list an `app/project` permission — for example a +> Project Viewer role with `app/project:get` in its `permissions`. That is allowed because `get` +> already exists on `app/project` in the base schema; the role is pointing at an existing +> permission. Filtering only blocks *defining* a new permission under `app/*` in the +> `permissions:` section. So you can reference `app/project:get`, but you cannot create +> `app/project:createcomputemachine`. That is why the create action is defined under +> `user/project` and only then granted by a project role. + +> Note: the permission **slug** follows its namespace, so it is `user_project_createcomputemachine`, +> not `app_project_createcomputemachine`. The only way to get a bare `app/project` permission is to +> edit `base_schema.zed`, which is a code change, not config — and it does not scale to add one per +> resource. Use the `user/project` namespace instead. + +### Config + +Register the create action under `user/project`, then grant it to a project-scoped role such as +the built-in Project Owner: + +```yaml +permissions: + # get / update / delete stay on the resource itself + - name: get + namespace: compute/machine + - name: update + namespace: compute/machine + - name: delete + namespace: compute/machine + # create lives on the container, via the user/project namespace + - name: createcomputemachine + namespace: user/project # NOT app/project — that would be filtered out + +roles: + - name: app_project_owner # extend the built-in Project Owner role + title: Project Owner + scopes: + - app/project + permissions: + - user/project:createcomputemachine +``` + +This does three things: + +1. Defines `user_project_createcomputemachine` and mirrors it onto `app/project`. +2. Grants it to the Project Owner role, which is scoped to `app/project`. +3. Lets an owner of a project pass the create check shown above, because + `app/project#user_project_createcomputemachine` resolves through `granted->...` on the project. + +### Rule of thumb + +- Put `get`, `update`, `delete` (and other actions on an existing item) under the resource + namespace, for example `compute/machine`. +- Put `create` under `user/project`, and grant it to a project role. +- Check `create` against `app/project:`, never against the resource. + +This keeps every create check anchored on the project and avoids the dead resource-level +`create` rule. + +--- + +## Listing vs getting: why a role can see a list but not open an item + +This is the part worth reading twice. + +Frontier checks **list** and **get** with two different engines, and they accept different +permissions. A role can pass one and fail the other. + +### Getting one resource + +A `get` is a real SpiceDB check on the resource (for example `compute/machine#get`). It walks +the chain above: + +``` +compute/machine#get + -> project->compute_machine_get + -> org->compute_machine_get + -> granted->app_organization_administer (Org Owner) + + granted->compute_machine_get (a role that lists the action) +``` + +At the org step, the only org-level keys that open the door are +`app_organization_administer` or a literal `compute_machine_get` grant. The Org Admin role has +neither, so its `get` is denied. + +### Listing resources + +Listing usually starts by asking "which projects can this user see?" That question is answered +from Postgres policy rows, not from a per-resource SpiceDB check. The service treats a broader +set of org permissions as proof that a user can see every project in the org: + +``` +OrganizationProjectInheritPerms = { app_organization_administer, app_project_get, app_project_administer } +``` + +The Org Admin role holds `app_project_get`, which is in that set. So when a caller lists +projects with inheritance turned on, the Admin sees **every project in the org** — and a +service that lists its resources per visible project will then list every resource too. + +### The result + +For the Org Admin role: + +| Operation | Engine | Org-level key it accepts | Admin passes? | +| --- | --- | --- | --- | +| List projects in org | Postgres policy check | `app_project_get` (among others) | Yes | +| List resources (per visible project) | Postgres policy check | `app_project_get` (among others) | Yes | +| Get one resource | SpiceDB resource check | `app_organization_administer` or the literal action grant | No | + +So an Org Admin can end up seeing a full list of resources where every item returns "permission +denied" when opened. The fix is a data change or a schema change: + +- **Data change**: add the resource action (for example `compute_machine_get`) to a role the + user holds — at the org level (so the org rule's `granted->compute_machine_get` matches) or + at the project level. +- **Schema change**: change the generator so the org-level rule for custom actions also accepts + `app_project_get`. This is broader and affects every custom resource, so weigh it carefully. + +--- + +## Quick reference + +- A custom resource is registered from a config file listing a `service/resource` namespace and + its actions. +- Bootstrap merges generated rules into the base schema on every boot and writes them to SpiceDB. +- The **resource owner**, **platform admin**, and **Org Owner** can always perform every action + on a resource. Project and direct grants depend on the roles in use. +- **Listing** accepts `app_project_get` as org-wide visibility; **getting** does not. That gap is + why a role can list resources it cannot open. From 5e574e0e017cd3856af8a85d5dc8af7774d546e7 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 09:46:07 +0530 Subject: [PATCH 02/11] docs(authz): cover project-level create/list and the user/project proxy Add a "project-level actions" section explaining that create and list are not special permissions. By RBAC nature they need an object to check against, and the natural object is the container (the project), not a not-yet-created item or a single item. Document the user/project namespace as a config-legal proxy for app/project, since app/* permissions cannot be added from config, with a compute/machine example for create and list. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 112 ++++++++++++------- 1 file changed, 70 insertions(+), 42 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 03c92d568..321a0174f 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -155,69 +155,93 @@ on the resource. --- -## Creating resources: a special case (best practice) +## Project-level actions: use `user/project` as a proxy for `app/project` -`create` is not like `get`, `update`, or `delete`. You check those against a resource that -already exists. But you check `create` *before* the resource exists, so there is no -`compute/machine:` to check against. +Some actions do not belong on a single resource. The clearest example is `create`: you check it +*before* the resource exists, so there is no `compute/machine:` to check against. "List all +machines in a project" is the same — it is a question about the project, not about one machine. -The generator still emits a `compute/machine#create` rule on the resource, but it is not useful -for a real create. It would need a resource id you do not have yet. So in practice that rule is -dead. - -The right model is to treat "create" as a capability on the **container** — the project — and -check it against the project id, with the caller as the subject: +These are **project-level capabilities**. They belong on the project (the container), and you +check them against the project id with the caller as the subject: ``` Check( - subject = app/user:, # the authenticated caller + subject = app/user:, # the authenticated caller permission = user_project_createcomputemachine, - resource = app/project:, # the container — it already exists + resource = app/project:, # the container — it already exists ) ``` -### Why the permission can't live in `app/project` +### These actions are not special — it is a modeling choice + +Frontier and SpiceDB do not treat `create` or `list` differently from `get`, `update`, or +`delete`. The generator builds the same set of rules for every action, and to the engine +`createcomputemachine` is just another permission slug. There is no built-in idea of "this one is +a create permission". + +So why put them on the container? It falls out of how RBAC checks work. Every check asks one +question: *does this subject have this permission on this object?* That means every action needs +an object to check against: + +- For `get`, `update`, and `delete`, the object is the item itself (`compute/machine:`). It + already exists, so checking against it is natural. +- For `create`, the item does not exist yet, so there is no object to name. The closest real + object is the container the item will live in — the project. +- For `list`, you are asking about the whole collection, not one item. Again the natural object + is the container. -It would read most naturally as `app/project:createcomputemachine`. That does not work. At boot, -bootstrap drops any permission whose namespace starts with `app` (the -`filterDefaultAppNamespacePermissions` step). The `app/*` types belong to the base schema and are -rebuilt on every start, so config is not allowed to add permissions to them. A +So anchoring `create` and `list` on the project is a **modeling decision you make**, the normal +RBAC way to handle actions that have no single item to point at. Frontier does not force it. The +system will happily generate a `compute/machine#create` permission; it simply is not useful, +because at check time you have no machine id to check against. + +### Why a separate `user/project` namespace + +The natural home would be the project itself, as `app/project:createcomputemachine`. You cannot +do that from config. At boot, bootstrap drops any permission whose namespace starts with `app` +(the `filterDefaultAppNamespacePermissions` step). The `app/*` types belong to the base schema +and are rebuilt on every start, so config is not allowed to add permissions to them. An `app/project:createcomputemachine` entry in a config file is silently ignored. -So the create capability gets its own namespace. By convention this is `user/project` — read it -as "something a user can do inside a project". The generator then mirrors it onto the project as -`app/project#user_project_createcomputemachine`, and that mirrored permission is what you check. +So Frontier uses a small trick: a separate namespace, `user/project`, that acts as a **proxy for +the project**. Read it as "something a user can do inside a project". You define the capability +there, and the generator mirrors it onto the real project as +`app/project#user_project_createcomputemachine`. That mirrored permission is what you check. In +effect, `user/project` is the config-legal way to hang project-level capabilities off +`app/project`. > **Don't confuse two things.** A role *can* list an `app/project` permission — for example a > Project Viewer role with `app/project:get` in its `permissions`. That is allowed because `get` -> already exists on `app/project` in the base schema; the role is pointing at an existing +> already exists on `app/project` in the base schema; the role is just pointing at an existing > permission. Filtering only blocks *defining* a new permission under `app/*` in the > `permissions:` section. So you can reference `app/project:get`, but you cannot create -> `app/project:createcomputemachine`. That is why the create action is defined under -> `user/project` and only then granted by a project role. - -> Note: the permission **slug** follows its namespace, so it is `user_project_createcomputemachine`, -> not `app_project_createcomputemachine`. The only way to get a bare `app/project` permission is to -> edit `base_schema.zed`, which is a code change, not config — and it does not scale to add one per -> resource. Use the `user/project` namespace instead. +> `app/project:createcomputemachine`. The slug also follows its namespace, so it ends up as +> `user_project_createcomputemachine`, not `app_project_createcomputemachine`. ### Config -Register the create action under `user/project`, then grant it to a project-scoped role such as -the built-in Project Owner: +Put the per-item actions (`get`, `update`, `delete`) on the resource namespace, and the +project-level capabilities (`create`, project-wide `list`) on `user/project`. Then grant the +project-level ones to a project-scoped role such as the built-in Project Owner: ```yaml permissions: - # get / update / delete stay on the resource itself + # Per-item actions live on the resource itself, checked against compute/machine:. - name: get namespace: compute/machine - name: update namespace: compute/machine - name: delete namespace: compute/machine - # create lives on the container, via the user/project namespace + + # Project-level capabilities live on user/project — a proxy for app/project. + # Checked against app/project:, because there is no single + # machine to check against. Do NOT use namespace app/project here: the + # app/* namespaces are reserved for the base schema and get filtered out. - name: createcomputemachine - namespace: user/project # NOT app/project — that would be filtered out + namespace: user/project + - name: listcomputemachine + namespace: user/project roles: - name: app_project_owner # extend the built-in Project Owner role @@ -226,24 +250,28 @@ roles: - app/project permissions: - user/project:createcomputemachine + - user/project:listcomputemachine ``` This does three things: -1. Defines `user_project_createcomputemachine` and mirrors it onto `app/project`. -2. Grants it to the Project Owner role, which is scoped to `app/project`. -3. Lets an owner of a project pass the create check shown above, because +1. Defines `user_project_createcomputemachine` (and `..._list...`) and mirrors them onto + `app/project`. +2. Grants them to the Project Owner role, which is scoped to `app/project`. +3. Lets an owner of a project pass the check above, because `app/project#user_project_createcomputemachine` resolves through `granted->...` on the project. ### Rule of thumb -- Put `get`, `update`, `delete` (and other actions on an existing item) under the resource - namespace, for example `compute/machine`. -- Put `create` under `user/project`, and grant it to a project role. -- Check `create` against `app/project:`, never against the resource. +- Per-item actions (`get`, `update`, `delete`) → resource namespace, e.g. `compute/machine`. + Checked against `compute/machine:`. +- Project-level capabilities (`create`, project-wide `list`) → `user/project`. Checked against + `app/project:`. +- Treat `user/project` as a stand-in for `app/project` that you are allowed to write to from + config. -This keeps every create check anchored on the project and avoids the dead resource-level -`create` rule. +This keeps create and list anchored on the project and avoids the dead resource-level `create` +rule the generator would otherwise leave unused. --- From 7c952bb21f6e2f33c20ba691c0caad58bdea44a2 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 09:51:19 +0530 Subject: [PATCH 03/11] docs(authz): drop list-vs-get section, swap mermaid for ascii diagram Remove the "listing vs getting" section. The list-can-succeed-while-get-fails behaviour it described is likely a bug, not something to document as expected. Replace the mermaid load-flow diagram with a plain-text diagram so it renders the same in light and dark theme. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 79 ++------------------ 1 file changed, 7 insertions(+), 72 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 321a0174f..6140f948e 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -9,12 +9,10 @@ Frontier lets services register their own resource types (for example `compute/m Once registered, Frontier can answer permission checks on those resources the same way it does for built-in types like projects and organizations. -This page explains three things: +This page explains two things: 1. How a custom resource type is loaded into Frontier. 2. What permission rules Frontier generates for it, and which role each action ends up with. -3. Why a role can **list** a resource but still fail to **get** a single one. This is the part - that surprises people most, so it has its own section. --- @@ -52,14 +50,10 @@ At startup Frontier runs a bootstrap step (`MigrateSchema`) that does the follow This step is idempotent. It runs on every boot and recreates the same schema, so adding a new resource config and restarting is all it takes to register a new type. -```mermaid -flowchart LR - A[resource config files] --> B[ServiceDefinition] - C[base_schema.zed] --> D[merge + generate rules] - B --> D - D --> E[validate] - E --> F[(Postgres: permissions)] - E --> G[(SpiceDB: schema)] +```text + resource config files ─┐ + ├─→ merge + generate rules ─→ validate ─┬─→ Postgres (permissions) + base_schema.zed ───────┘ └─→ SpiceDB (schema) ``` --- @@ -275,65 +269,6 @@ rule the generator would otherwise leave unused. --- -## Listing vs getting: why a role can see a list but not open an item - -This is the part worth reading twice. - -Frontier checks **list** and **get** with two different engines, and they accept different -permissions. A role can pass one and fail the other. - -### Getting one resource - -A `get` is a real SpiceDB check on the resource (for example `compute/machine#get`). It walks -the chain above: - -``` -compute/machine#get - -> project->compute_machine_get - -> org->compute_machine_get - -> granted->app_organization_administer (Org Owner) - + granted->compute_machine_get (a role that lists the action) -``` - -At the org step, the only org-level keys that open the door are -`app_organization_administer` or a literal `compute_machine_get` grant. The Org Admin role has -neither, so its `get` is denied. - -### Listing resources - -Listing usually starts by asking "which projects can this user see?" That question is answered -from Postgres policy rows, not from a per-resource SpiceDB check. The service treats a broader -set of org permissions as proof that a user can see every project in the org: - -``` -OrganizationProjectInheritPerms = { app_organization_administer, app_project_get, app_project_administer } -``` - -The Org Admin role holds `app_project_get`, which is in that set. So when a caller lists -projects with inheritance turned on, the Admin sees **every project in the org** — and a -service that lists its resources per visible project will then list every resource too. - -### The result - -For the Org Admin role: - -| Operation | Engine | Org-level key it accepts | Admin passes? | -| --- | --- | --- | --- | -| List projects in org | Postgres policy check | `app_project_get` (among others) | Yes | -| List resources (per visible project) | Postgres policy check | `app_project_get` (among others) | Yes | -| Get one resource | SpiceDB resource check | `app_organization_administer` or the literal action grant | No | - -So an Org Admin can end up seeing a full list of resources where every item returns "permission -denied" when opened. The fix is a data change or a schema change: - -- **Data change**: add the resource action (for example `compute_machine_get`) to a role the - user holds — at the org level (so the org rule's `granted->compute_machine_get` matches) or - at the project level. -- **Schema change**: change the generator so the org-level rule for custom actions also accepts - `app_project_get`. This is broader and affects every custom resource, so weigh it carefully. - ---- - ## Quick reference - A custom resource is registered from a config file listing a `service/resource` namespace and @@ -341,5 +276,5 @@ denied" when opened. The fix is a data change or a schema change: - Bootstrap merges generated rules into the base schema on every boot and writes them to SpiceDB. - The **resource owner**, **platform admin**, and **Org Owner** can always perform every action on a resource. Project and direct grants depend on the roles in use. -- **Listing** accepts `app_project_get` as org-wide visibility; **getting** does not. That gap is - why a role can list resources it cannot open. +- Per-item actions (`get`, `update`, `delete`) live on the resource namespace; project-level + actions (`create`, `list`) live on `user/project` and are checked against the project. From 5f416a9f5d4b49fe75f0ad906184bc4439ced477 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:00:45 +0530 Subject: [PATCH 04/11] docs(authz): address CodeRabbit notes on bootstrap and rule count - Note that boot merges permissions already in Postgres (including ones added via CreatePermission), so a restart does not drop them. - Count the generated targets as five (resource, org, project, role binding, role), not four. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 6140f948e..99fac1074 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -41,10 +41,12 @@ At startup Frontier runs a bootstrap step (`MigrateSchema`) that does the follow 1. Reads every resource config file into a `ServiceDefinition` (the list of namespaces and their actions). -2. Loads the base SpiceDB schema (`base_schema.zed`), which defines users, organizations, +2. Loads the permissions already in Postgres — including any added later through the + `CreatePermission` API — and merges them in, so a restart does not drop them. +3. Loads the base SpiceDB schema (`base_schema.zed`), which defines users, organizations, projects, roles, and role bindings. -3. Generates extra rules for each custom action and merges them into the base schema. -4. Validates the merged schema, writes the permission list to Postgres, and writes the full +4. Generates extra rules for each custom action and merges them into the base schema. +5. Validates the merged schema, writes the permission list to Postgres, and writes the full schema to SpiceDB. This step is idempotent. It runs on every boot and recreates the same schema, so adding a new @@ -60,7 +62,8 @@ resource config and restarting is all it takes to register a new type. ## What rules get generated -For **each** action on a custom resource, the generator adds a rule in four places. The +For **each** action on a custom resource, the generator adds an entry in five places: the +resource namespace, `app/organization`, `app/project`, `app/rolebinding`, and `app/role`. The action name is flattened into a single slug: namespace `compute/machine` with action `get` becomes `compute_machine_get`. From 96c9036e7c55759276bc52203161a928b4fd7b21 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:01:16 +0530 Subject: [PATCH 05/11] docs(authz): soften tone of the app/project reference note Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 99fac1074..9b09beb11 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -207,13 +207,12 @@ there, and the generator mirrors it onto the real project as effect, `user/project` is the config-legal way to hang project-level capabilities off `app/project`. -> **Don't confuse two things.** A role *can* list an `app/project` permission — for example a -> Project Viewer role with `app/project:get` in its `permissions`. That is allowed because `get` -> already exists on `app/project` in the base schema; the role is just pointing at an existing -> permission. Filtering only blocks *defining* a new permission under `app/*` in the -> `permissions:` section. So you can reference `app/project:get`, but you cannot create -> `app/project:createcomputemachine`. The slug also follows its namespace, so it ends up as -> `user_project_createcomputemachine`, not `app_project_createcomputemachine`. +> A role can still *reference* an `app/project` permission — for example a Project Viewer role +> that lists `app/project:get`. That works because `get` already exists on `app/project` in the +> base schema. The filter only blocks *adding* a new permission under `app/*` from config. So you +> can point a role at `app/project:get`, but you cannot define `app/project:createcomputemachine`. +> The slug follows its namespace too, so it comes out as `user_project_createcomputemachine`, not +> `app_project_createcomputemachine`. ### Config From 585719fc04434362a5acdbf6c6dffea68a972bbe Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:09:38 +0530 Subject: [PATCH 06/11] docs(authz): add section on which roles get a custom action Explain that owners and admins get every custom action by default through the generated rules, while other roles get nothing until granted. Show config for both adding an action to a built-in role (which replaces its permission set) and making a new custom role. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 80 ++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 9b09beb11..fcd3af448 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -271,6 +271,86 @@ rule the generator would otherwise leave unused. --- +## Which roles can use a custom action + +When you register a custom resource, some roles can use its actions right away. Other roles get +nothing until you grant them. + +### Works by default + +You do not have to set up any roles for these. They work as soon as the resource is registered: + +- **Org Owner** — can do every action on every custom resource in the org. +- **Project Owner** — can do every action on resources in their project. +- **Platform admin** — can do everything. +- The **user who created a resource** — can act on that one resource. + +This works because the generated rules already include the owner and admin permissions. So an +owner or an admin is covered without the action being listed in any role. + +### Does not work by default + +These roles get nothing on a custom resource until you grant it: + +- Org Admin, Org Member, Access Manager +- Project Manager, Project Viewer, Project Member + +If you want one of these roles to use a custom action, you grant it in the config file. You have +two choices: add the action to a built-in role, or make your own role. + +### Choice 1: add the action to a built-in role + +List the built-in role by its name and give it the permissions you want. This example lets the +Project Viewer read and list machines: + +```yaml +roles: + - name: app_project_viewer # the built-in Project Viewer role + title: Project Viewer + scopes: + - app/project + permissions: + - app/project:get # keep what the role already had + - app/project:resourcelist + - compute/machine:get + - user/project:listcomputemachine +``` + +One thing to watch: when you list a role that already exists, Frontier **replaces** its whole +permission set with the one you write. It does not add to the old set. So you must include the +permissions the role already had, or it will lose them. In the example, `app/project:get` is +kept so the role can still open the project. + +### Choice 2: make your own role + +You can also add a brand new role. Give it a name that is not already in use, a scope, and the +permissions you want: + +```yaml +roles: + - name: compute_machine_operator # your own new role + title: Machine Operator + scopes: + - app/project + permissions: + - compute/machine:get + - compute/machine:update + - user/project:createcomputemachine + - user/project:listcomputemachine +``` + +A new role starts empty, so you only list what you want it to have. After boot, you assign this +role to a user or group on a project, the same way you assign any other role. + +### In short + +- Owners and admins get every custom action for free. +- Every other role gets an action only when you grant it. +- Re-using a built-in role name replaces its permissions, so list everything you want it to keep. +- A new role name creates a fresh role with exactly the permissions you list. + +--- + ## Quick reference - A custom resource is registered from a config file listing a `service/resource` namespace and From d190a6278a0a12ad57105c1a2c35fe675e40529c Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:16:19 +0530 Subject: [PATCH 07/11] docs(authz): note admins need no grant, fix role list, add cross-refs - State that custom actions never need listing on admin roles, since app_project_administer / app_organization_administer already grant them through the schema. - Remove the non-existent "Project Member" role; predefined project roles are Owner, Manager, Viewer only. - Link mentions of the generated rules to the "What rules get generated" section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index fcd3af448..5b7a66f32 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -116,7 +116,8 @@ arrows above resolve. There are two layers, and it helps to keep them apart: -- **The schema** (generated above) fixes the *paths* a check can travel. +- **The schema** (the [generated rules](#what-rules-get-generated)) fixes the *paths* a check can + travel. - **The roles** decide *which permissions a principal actually holds*. A principal gets access to a custom action only when both line up. Here is who can `get` a @@ -172,9 +173,9 @@ Check( ### These actions are not special — it is a modeling choice Frontier and SpiceDB do not treat `create` or `list` differently from `get`, `update`, or -`delete`. The generator builds the same set of rules for every action, and to the engine -`createcomputemachine` is just another permission slug. There is no built-in idea of "this one is -a create permission". +`delete`. The generator builds the [same set of rules](#what-rules-get-generated) for every +action, and to the engine `createcomputemachine` is just another permission slug. There is no +built-in idea of "this one is a create permission". So why put them on the container? It falls out of how RBAC checks work. Every check asks one question: *does this subject have this permission on this object?* That means every action needs @@ -285,15 +286,19 @@ You do not have to set up any roles for these. They work as soon as the resource - **Platform admin** — can do everything. - The **user who created a resource** — can act on that one resource. -This works because the generated rules already include the owner and admin permissions. So an -owner or an admin is covered without the action being listed in any role. +This works because the [generated rules](#what-rules-get-generated) already include the owner and +admin permissions. So an owner or an admin is covered without the action being listed in any role. + +You never need to list a custom action on these roles. `app_project_administer` (Project Owner) +and `app_organization_administer` (Org Owner) already grant every custom action through the +schema, so listing them again would just be repeating what the schema already does. ### Does not work by default These roles get nothing on a custom resource until you grant it: - Org Admin, Org Member, Access Manager -- Project Manager, Project Viewer, Project Member +- Project Manager, Project Viewer If you want one of these roles to use a custom action, you grant it in the config file. You have two choices: add the action to a built-in role, or make your own role. From 89cc9128423cf36d5efe44ab7757919836f0a727 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:17:31 +0530 Subject: [PATCH 08/11] docs(authz): show role title and internal slug together Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 5b7a66f32..81e8bfd76 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -281,8 +281,9 @@ nothing until you grant them. You do not have to set up any roles for these. They work as soon as the resource is registered: -- **Org Owner** — can do every action on every custom resource in the org. -- **Project Owner** — can do every action on resources in their project. +- **Org Owner** (`app_organization_owner`) — can do every action on every custom resource in the + org. +- **Project Owner** (`app_project_owner`) — can do every action on resources in their project. - **Platform admin** — can do everything. - The **user who created a resource** — can act on that one resource. @@ -297,8 +298,9 @@ schema, so listing them again would just be repeating what the schema already do These roles get nothing on a custom resource until you grant it: -- Org Admin, Org Member, Access Manager -- Project Manager, Project Viewer +- Org Admin (`app_organization_manager`), Org Member (`app_organization_viewer`), Access Manager + (`app_organization_accessmanager`) +- Project Manager (`app_project_manager`), Project Viewer (`app_project_viewer`) If you want one of these roles to use a custom action, you grant it in the config file. You have two choices: add the action to a built-in role, or make your own role. From 6873fc61a6718017d8d9f9022524ee019ac78833 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:21:39 +0530 Subject: [PATCH 09/11] docs(authz): use real org role titles, pluralize list action - Org roles are titled Owner / Admin / Member (not "Org Owner" etc.); keep the internal slug in brackets to tell them apart from the project roles. - Rename the example list action to listcomputemachines (plural). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 81e8bfd76..44ebaac6d 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -127,18 +127,18 @@ custom resource and how each one reaches it. | --- | --- | --- | | Resource owner (creator) | `owner` arrow on the resource | Yes, on create | | Platform admin | `platform->superuser` | Yes | -| Org Owner role (`app_organization_administer`) | org rule's `granted->app_organization_administer` | **Yes** — every custom action, for free | +| Owner role (`app_organization_owner`) | org rule's `granted->app_organization_administer` | **Yes** — every custom action, for free | | Org `owner` relation | org rule's `owner` arrow | Yes | | A project role that lists the action | `project->compute_machine_get` -> `granted->compute_machine_get` | Only if the role lists it | | A project admin role (`app_project_administer`) | `project->compute_machine_get` -> `granted->app_project_administer` | Only if the role grants project admin | | A direct grant on the resource | `granted->compute_machine_get` on the resource | Only if a policy is set on the resource | -The key point about the **Org Owner** role: the org-level rule hardcodes +The key point about the **Owner** role (`app_organization_owner`): the org-level rule hardcodes `granted->app_organization_administer`. So whoever holds the Owner role on an organization can perform **every** custom action on **every** resource in that org, without any project or resource grant. This is on purpose. -The **Org Admin** role (`app_organization_manager`) is different. Its permissions are: +The **Admin** role (`app_organization_manager`) is different. Its permissions are: ``` app_organization_update, app_organization_get, app_organization_projectcreate, @@ -146,7 +146,7 @@ app_organization_projectlist, app_organization_groupcreate, app_organization_gro app_organization_serviceusermanage, app_project_get, app_project_update ``` -None of these appears anywhere in the custom-action rules above. So the Org Admin role does +None of these appears anywhere in the custom-action rules above. So the Admin role does **not** get custom resource actions through org inheritance. To act on a custom resource, an Admin would need a project role that lists the action, a project admin role, or a direct grant on the resource. @@ -237,7 +237,7 @@ permissions: # app/* namespaces are reserved for the base schema and get filtered out. - name: createcomputemachine namespace: user/project - - name: listcomputemachine + - name: listcomputemachines namespace: user/project roles: @@ -247,7 +247,7 @@ roles: - app/project permissions: - user/project:createcomputemachine - - user/project:listcomputemachine + - user/project:listcomputemachines ``` This does three things: @@ -281,7 +281,7 @@ nothing until you grant them. You do not have to set up any roles for these. They work as soon as the resource is registered: -- **Org Owner** (`app_organization_owner`) — can do every action on every custom resource in the +- **Owner** (`app_organization_owner`) — can do every action on every custom resource in the org. - **Project Owner** (`app_project_owner`) — can do every action on resources in their project. - **Platform admin** — can do everything. @@ -291,14 +291,14 @@ This works because the [generated rules](#what-rules-get-generated) already incl admin permissions. So an owner or an admin is covered without the action being listed in any role. You never need to list a custom action on these roles. `app_project_administer` (Project Owner) -and `app_organization_administer` (Org Owner) already grant every custom action through the +and `app_organization_administer` (Owner) already grant every custom action through the schema, so listing them again would just be repeating what the schema already does. ### Does not work by default These roles get nothing on a custom resource until you grant it: -- Org Admin (`app_organization_manager`), Org Member (`app_organization_viewer`), Access Manager +- Admin (`app_organization_manager`), Member (`app_organization_viewer`), Access Manager (`app_organization_accessmanager`) - Project Manager (`app_project_manager`), Project Viewer (`app_project_viewer`) @@ -320,7 +320,7 @@ roles: - app/project:get # keep what the role already had - app/project:resourcelist - compute/machine:get - - user/project:listcomputemachine + - user/project:listcomputemachines ``` One thing to watch: when you list a role that already exists, Frontier **replaces** its whole @@ -343,7 +343,7 @@ roles: - compute/machine:get - compute/machine:update - user/project:createcomputemachine - - user/project:listcomputemachine + - user/project:listcomputemachines ``` A new role starts empty, so you only list what you want it to have. After boot, you assign this @@ -363,7 +363,7 @@ role to a user or group on a project, the same way you assign any other role. - A custom resource is registered from a config file listing a `service/resource` namespace and its actions. - Bootstrap merges generated rules into the base schema on every boot and writes them to SpiceDB. -- The **resource owner**, **platform admin**, and **Org Owner** can always perform every action +- The **resource owner**, **platform admin**, and **Owner** (`app_organization_owner`) can always perform every action on a resource. Project and direct grants depend on the roles in use. - Per-item actions (`get`, `update`, `delete`) live on the resource namespace; project-level actions (`create`, `list`) live on `user/project` and are checked against the project. From 427590e0533a7789e473fe7c179e908f8e369d63 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 30 Jun 2026 10:23:02 +0530 Subject: [PATCH 10/11] docs(authz): retitle to Custom Resources and Permissions The role-inheritance (list-vs-get) section was removed, so the title no longer fit. The article is about loading custom resources, the generated permission rules, and which roles get which actions. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 44ebaac6d..23d584532 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -1,9 +1,9 @@ --- -title: Custom Resources and Role Inheritance +title: Custom Resources and Permissions order: 7 --- -# Custom Resources and Role Inheritance +# Custom Resources and Permissions Frontier lets services register their own resource types (for example `compute/machine`). Once registered, Frontier can answer permission checks on those resources the same way it From dfed07438b33fe6265a3afe03d56754036600b41 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Mon, 6 Jul 2026 13:38:38 +0530 Subject: [PATCH 11/11] docs(authz): clarify proxy namespace and admin roles Address review feedback on the custom resources doc: - Proxy namespace: state that the name is a free choice. The generator mirrors every custom permission onto app/project and app/organization regardless of namespace, so there is no convention to follow. user/project is just the example. - Admin roles: use the titles and names from PredefinedRoles everywhere, so Owner (app_organization_owner), Admin (app_organization_manager), Project Owner (app_project_owner), and platform admin are no longer ambiguous. - Built-in role permissions: clarify that "already had" means the predefined role defaults, not the base schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/content/docs/authz/custom-resources.mdx | 32 +++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/authz/custom-resources.mdx b/docs/content/docs/authz/custom-resources.mdx index 23d584532..eb000649b 100644 --- a/docs/content/docs/authz/custom-resources.mdx +++ b/docs/content/docs/authz/custom-resources.mdx @@ -208,6 +208,13 @@ there, and the generator mirrors it onto the real project as effect, `user/project` is the config-legal way to hang project-level capabilities off `app/project`. +The name `user/project` is not special, and there is no naming convention to follow. You can +pick any `service/resource` namespace, as long as it is not under `app/*`. The generator mirrors +every custom permission onto `app/project` (and `app/organization`) no matter which namespace you +chose, so the name does not change where the check runs. `user/project` is simply the name +Frontier uses here, because the slug it produces — `user_project_createcomputemachine` — reads as +"a thing a user does in a project". + > A role can still *reference* an `app/project` permission — for example a Project Viewer role > that lists `app/project:get`. That works because `get` already exists on `app/project` in the > base schema. The filter only blocks *adding* a new permission under `app/*` from config. So you @@ -265,7 +272,8 @@ This does three things: - Project-level capabilities (`create`, project-wide `list`) → `user/project`. Checked against `app/project:`. - Treat `user/project` as a stand-in for `app/project` that you are allowed to write to from - config. + config. The name is your choice — any non-`app/*` namespace works; `user/project` is just the + example used here. This keeps create and list anchored on the project and avoids the dead resource-level `create` rule the generator would otherwise leave unused. @@ -287,11 +295,15 @@ You do not have to set up any roles for these. They work as soon as the resource - **Platform admin** — can do everything. - The **user who created a resource** — can act on that one resource. -This works because the [generated rules](#what-rules-get-generated) already include the owner and -admin permissions. So an owner or an admin is covered without the action being listed in any role. +This works because the [generated rules](#what-rules-get-generated) already point at +`app_organization_administer` (held by the **Owner** role, `app_organization_owner`), +`app_project_administer` (held by the **Project Owner** role, `app_project_owner`), and +`platform->superuser` (the **platform admin**). So these principals are covered without the action +being listed in any role. This is *not* the org **Admin** role (`app_organization_manager`) — that +one gets nothing by default (see [below](#does-not-work-by-default)). -You never need to list a custom action on these roles. `app_project_administer` (Project Owner) -and `app_organization_administer` (Owner) already grant every custom action through the +You never need to list a custom action on these roles. `app_project_administer` (**Project Owner**) +and `app_organization_administer` (**Owner**) already grant every custom action through the schema, so listing them again would just be repeating what the schema already does. ### Does not work by default @@ -325,8 +337,10 @@ roles: One thing to watch: when you list a role that already exists, Frontier **replaces** its whole permission set with the one you write. It does not add to the old set. So you must include the -permissions the role already had, or it will lose them. In the example, `app/project:get` is -kept so the role can still open the project. +permissions the role already had, or it will lose them. For a built-in role, "already had" means +the default permissions Frontier ships it with — its entry in the predefined role list +(`PredefinedRoles` in `internal/bootstrap/schema/schema.go`), not the base schema. In the example, +`app/project:get` is kept so the role can still open the project. ### Choice 2: make your own role @@ -351,7 +365,9 @@ role to a user or group on a project, the same way you assign any other role. ### In short -- Owners and admins get every custom action for free. +- The **Owner** (`app_organization_owner`), **Project Owner** (`app_project_owner`), and the + **platform admin** get every custom action for free. The org **Admin** role + (`app_organization_manager`) does not. - Every other role gets an action only when you grant it. - Re-using a built-in role name replaces its permissions, so list everything you want it to keep. - A new role name creates a fresh role with exactly the permissions you list.