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 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.

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:

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 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.
  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 resource config and restarting is all it takes to register a new type.

  resource config files ─┐
                         ├─→ 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_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 (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.

WhoHow they reach getGranted automatically?
Resource owner (creator)owner arrow on the resourceYes, on create
Platform adminplatform->superuserYes
Org Owner role (app_organization_administer)org rule's granted->app_organization_administerYes — every custom action, for free
Org owner relationorg rule's owner arrowYes
A project role that lists the actionproject->compute_machine_get -> granted->compute_machine_getOnly if the role lists it
A project admin role (app_project_administer)project->compute_machine_get -> granted->app_project_administerOnly if the role grants project admin
A direct grant on the resourcegranted->compute_machine_get on the resourceOnly 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.


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, and delete, 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 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 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.

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

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:

permissions:
  # Per-item actions live on the resource itself, checked against compute/machine:<id>.
  - name: get
    namespace: compute/machine
  - name: update
    namespace: compute/machine
  - name: delete
    namespace: compute/machine

  # 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 get filtered out.
  - name: createcomputemachine
    namespace: user/project
  - name: listcomputemachine
    namespace: user/project

roles:
  - name: app_project_owner          # extend the built-in Project Owner role
    title: Project Owner
    scopes:
      - app/project
    permissions:
      - user/project:createcomputemachine
      - user/project:listcomputemachine

This does three things:

  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

  • Per-item actions (get, update, delete) → resource namespace, e.g. compute/machine. Checked against compute/machine:<id>.
  • Project-level capabilities (create, project-wide list) → user/project. Checked against app/project:<project_id>.
  • Treat user/project as a stand-in for app/project that you are allowed to write to from config.

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:

  • 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.

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.

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 (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.

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:

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:

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 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.
  • 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.