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
does for built-in types like projects and organizations.
This page explains three things:
- How you register a custom resource type and its actions.
- What permission rules Frontier generates for it.
- Which role each action ends up with.
How custom resources are registered
A custom resource type is a namespace plus the actions (permissions) it supports. A
namespace has two parts joined by a slash, service/resource. So compute/machine is the
machine resource in the compute service.
You register the actions in one of two ways:
- The reconcile flow (
frontier reconcile). You write the actions you want in a desired-state YAML file and apply it. This is the recommended path: the file is the full desired state, so it reads back the same way it was written and fits GitOps. - The admin API (
CreatePermission,CreateRole). The imperative version of the same thing, useful for one-off changes or scripting.
Here is the compute/machine example as a reconcile file. Each entry is a namespace and an
action name:
apiVersion: v1
kind: Permission
spec:
- namespace: compute/machine
name: get
- namespace: compute/machine
name: create
- namespace: compute/machine
name: update
- namespace: compute/machine
name: deleteApply it with a superuser credential (for example the bootstrap service account):
# preview the change first
frontier reconcile -f compute-machine.yaml --dry-run -H "Authorization:Basic <base64>"
# apply it
frontier reconcile -f compute-machine.yaml -H "Authorization:Basic <base64>"frontier export permission -H "Authorization:Basic <base64>" prints the current custom
permissions in this same format, so you can capture the live state into a file.
A few rules the reconcile flow enforces for permissions:
- A permission is identity only (namespace plus name). It is added or deleted, never updated.
- Nothing is deleted by leaving it out. A permission that is in the server but missing from
the file fails the plan. To remove one, mark its entry with
delete: true. - A namespace must be in
service/resourceform: two non-empty parts, each lowercase alphanumeric. Socompute/machineworks, butcompute/machine-v2does not (the hyphen is not alphanumeric). The action name must be alphanumeric too. - A namespace under
apporapp/...is rejected, because the base schema owns those types (see Why a separateuser/projectnamespace).
What happens when a permission is registered
Adding a permission (through reconcile or the admin API) runs Frontier's schema step,
AppendSchema, which does the following:
- Loads every permission already in Postgres, so the re-apply keeps them.
- Loads the base SpiceDB schema (
base_schema.zed), which defines users, organizations, projects, roles, and role bindings. - Generates extra rules for each custom action and merges them into the base schema.
- Validates the merged schema, writes the permission list to Postgres, and writes the full schema to SpiceDB.
The same step runs on every boot (MigrateSchema), re-applying the base schema merged with
the permissions already in the database. It is idempotent: it recreates the same schema each
time and never drops what is already there. Frontier no longer reads a resource config file at
startup. The boot-time resources_config_path loader has been removed, and the reconcile flow
and admin API own custom permissions now.
reconcile / admin API ─┐
├─→ merge + generate rules ─→ validate ─┬─→ Postgres (permissions)
base_schema.zed ───────┘ └─→ SpiceDB (schema)What rules get generated
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.
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:<id>:
compute/machine#get = owner
+ project->app_project_administer
+ project->compute_machine_get
+ granted->compute_machine_getOn 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_getOn 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_getOn 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 (the generated rules) 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 |
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 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 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_updateNone 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.
Project-level actions: use user/project as a proxy for app/project
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:<id> to check against. "List all
machines in a project" is the same. It is a question about the project, not about one machine.
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:<userid>, # the authenticated caller
permission = user_project_createcomputemachine,
resource = app/project:<project_id>, # the container, it already exists
)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, anddelete, the object is the item itself (compute/machine:<id>). 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.
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. The app and app/... namespaces belong to the base schema, which the server rebuilds
on every boot, so you are not allowed to add permissions to them. The reconcile flow rejects an
entry under app/... with an error, and the admin API rejects the app namespace the same way.
The schema step also drops any permission whose namespace starts with app
(the filterDefaultAppNamespacePermissions step), so such an entry would never take effect.
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 allowed 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 whose two parts are lowercase alphanumeric, 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".
Declaring it
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. A single
reconcile file can hold more than one document, separated by ---, so the permissions and the
role go together:
apiVersion: v1
kind: Permission
spec:
# Per-item actions live on the resource itself, checked against compute/machine:<id>.
- namespace: compute/machine
name: get
- namespace: compute/machine
name: update
- namespace: compute/machine
name: delete
# Project-level capabilities live on user/project, a proxy for app/project.
# Checked against app/project:<project_id>, 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 are rejected.
- namespace: user/project
name: createcomputemachine
- namespace: user/project
name: listcomputemachines
---
apiVersion: v1
kind: Role
spec:
- name: app_project_owner # extend the built-in Project Owner role
title: Project Owner
scopes:
- app/project
permissions:
- user/project:createcomputemachine
- user/project:listcomputemachinesThis does three things:
- Defines
user_project_createcomputemachine(and..._list...) and mirrors them ontoapp/project. - Grants them to the Project Owner role, which is scoped to
app/project. - Lets an owner of a project pass the check above, because
app/project#user_project_createcomputemachineresolves throughgranted->...on the project.
Rule of thumb
- Per-item actions (
get,update,delete) go on the resource namespace, e.g.compute/machine. Checked againstcompute/machine:<id>. - Project-level capabilities (
create, project-widelist) go onuser/project. Checked againstapp/project:<project_id>. - Treat
user/projectas a stand-in forapp/projectthat you are allowed to write to. The name is your choice: any non-app/...namespace works, as long as its two parts are lowercase alphanumeric.user/projectis 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.
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:
- 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.
This works because the generated rules 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).
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 repeat what the schema already does.
Does not work by default
These roles get nothing on a custom resource until you grant it:
- Admin (
app_organization_manager), 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 a Role document. 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:
apiVersion: v1
kind: Role
spec:
- 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:listcomputemachinesOne thing to watch: each field you list is the whole desired value for that field. When you list
permissions on 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. 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. If you omit permissions entirely, the role keeps
its shipped defaults untouched.
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:
apiVersion: v1
kind: Role
spec:
- 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:listcomputemachinesA new role starts empty, so you only list what you want it to have. Once it exists, you assign
this role to a user or group on a project, the same way you assign any other role. To remove a
custom role later, mark its entry with delete: true. A predefined role cannot be deleted.
In short
- 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.
- Listing
permissionson a role replaces the set, so list everything you want it to keep. Omittingpermissionskeeps the role's current values. - A new role name creates a fresh role with exactly the permissions you list.
Quick reference
- A custom resource is registered by declaring its
service/resourcenamespace and actions throughfrontier reconcile(a desired-state file) or the admin API. There is no boot-time config file. - Registering a permission merges generated rules into the base schema and writes them to Postgres and SpiceDB. Boot re-applies the base schema plus the permissions already in the database.
- The resource owner, platform admin, Owner (
app_organization_owner, org-wide), and Project Owner (app_project_owner, within its project) can always perform every action on a resource. Other 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 onuser/projectand are checked against the project.