zynactradocs

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

ScopeMatches a record when
globalalways (within the org)
selfR.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

access/access.yaml
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: true

A 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

access/access.yaml
scopeTypes:
  org_unit:
    kind: set
    resourceAttr: orgUnitId
    expand: true

An 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:

ModeScope used
inheritthe assignment's scope
selfalways self, whatever the assignment says
globalalways 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_path on the record (copied when it's created, updated when someone moves) is what makes list filters a plain indexed LIKE.
Edit on GitHub

On this page