Scopes
Where a permission applies. Global, self, reporting lines and sets such as org units.
A scope says where a grant applies. The same line_manager role means "approve leave for your team" for one person and "approve leave in Finance" for another; the scope is on the assignment, not in the role. That way one role serves every team.
Built-in scopes
| Scope | Matches a record when |
|---|---|
global | always (within the org) |
self | R.attr.ownerId == P.id. The kind must declare ownerId. |
Product scope types
Products declare their own in the product manifest. There are two kinds.
Prefix: reporting lines and other trees of paths
scopeTypes:
team:
kind: prefix
principalAttr: reportingPath # the user's own path: /u_ceo/u_mgr/
resourceAttr: ownerPath # the record owner's path: /u_ceo/u_mgr/u_emp/
excludeSelf: trueA record matches when its resourceAttr starts with the user's principalAttr. Paths are materialised: every person's path lists everyone above them and ends in /, so /u_ceo/u_mgr/ is a prefix of /u_ceo/u_mgr/u_emp/ but not of /u_ceo/u_mgrX/u_other/. excludeSelf: true leaves the user's own records out (a manager's own leave is not "their team"); it needs ownerId on the kind.
The user's path comes from the resolver (PrincipalAttrs), which reads it from the product's own tables. A user the resolver returns nothing for has their prefix grants switched off; that's not an error.
In list filters, prefix matches become LIKE ... ESCAPE '\' with %, _ and \ escaped, so an ID with an underscore can't widen a match.
Set: org units, sites, projects
scopeTypes:
org_unit:
kind: set
resourceAttr: orgUnitId
expand: trueAn assignment at a set scope carries IDs (["fin"]). A record matches when its resourceAttr is one of them. With expand: true, the resolver's ExpandSet turns the assigned IDs into those IDs plus everything below them, so "HR for Finance" covers Accounts Payable too. The admin screens show set scopes with a tree when they're given parent IDs.
Modes: how a role's permission picks its scope
Each permission in a role has a mode:
| Mode | Scope used |
|---|---|
inherit | the assignment's scope |
self | always self, whatever the assignment says |
global | always global |
A line manager role can hold leave.request:approve: inherit (their team, from the assignment) and leave.request:create: self (only their own requests) at once. A mode must be one of the scopes the action allows; zaccess check enforces it.
The resolver
Products implement two methods from their own data. Both must be fast; results are cached with the grants.
type Resolver interface {
// Values for prefix scope types' principalAttr, e.g. {"reportingPath": "/u_1/u_42/"}.
PrincipalAttrs(ctx context.Context, orgID, userID string) (map[string]string, error)
// ids plus everything under them, for set scope types with expand: true.
ExpandSet(ctx context.Context, orgID, scopeType string, ids []string) ([]string, error)
}The TypeScript Resolver has the same two methods (principalAttrs, expandSet). The example services implement both with a recursive SQL query over their org unit table.
Picking scope types
- Model reach the way the organisation talks about it: "my team", "my units", "my sites". Two or three scope types cover most products.
- Use a prefix type when reach follows a tree that each record carries a path for (reporting lines). Use a set type when records belong to one node of a tree or a flat list (org units, regions, projects).
- Records must carry the attribute each scope type reads. Keeping
owner_pathon the record (copied when it's created, updated when someone moves) is what makes list filters a plain indexedLIKE.