Skip to main content

Permissions

Design RFC

Permission strings, hierarchical wildcards, and the @Auth({permissions}) gate described here are part of the planned authorisation stack. Today's Omnitron gates RPCs by role only (viewer / operator / admin); permission-string evaluation is the target design.

A permission is a dot-separated string that names a capability. Every gate in the platform — @Auth({permissions}), @kysera/rls policy, frontend usePermission() hook — resolves the same matcher against the same set of strings.

Grammar

<scope>.<resource>[.<action>][.<sub>]
TokenExamplesNotes
scopeadmin, site, commerce, orgTop-level domain
resourceusers, shops, employeesEntity inside the scope
actionlist, view, create, edit, deleteVerb on the resource
subown, assignedOptional refinement (e.g. posts.edit.own)

Examples drawn from the canonical registry:

admin.users.list
admin.users.ban
admin.users.permissions
admin.orgs.recovery
site.posts.edit.own
org.shops.create
org.employees.invite

Wildcards

Granting a wildcard authorises every key matching the prefix.

GrantedAuthorises
*Every permission in every scope (reserved for the owner role)
admin.*Every admin.<resource>.<action> key
admin.users.*admin.users.list, admin.users.ban, …
admin.users(hierarchical) — same effect as admin.users.*

The trailing .* is the explicit form. A granted entry without a trailing star also authorises every key one level below it — this hierarchical prefix grant lets short policy lists expand naturally as the registry grows.

Composition

A method that lists two permissions in @Auth({permissions}) requires both by default:

@Auth({ permissions: ['admin.orders.view', 'admin.users.view'] })

For either-of semantics, use BuiltInPolicies.requireAnyPermission:

@Auth({ policies: [BuiltInPolicies.requireAnyPermission(['admin.orders.view', 'admin.users.view'])] })

See ABAC conditions for richer composition (and / or / not, time-of-day, MFA gates).

Matcher

permissionGrants(granted, required) returns true when:

  1. granted === '*' (full wildcard)
  2. granted === required (exact match)
  3. granted ends in .* and required starts with the prefix
  4. required starts with granted + '.' (hierarchical prefix grant)
permissionGrants('admin.*', 'admin.users.ban') // true
permissionGrants('admin.users.*', 'admin.users.ban') // true
permissionGrants('admin.users', 'admin.users.ban') // true (hierarchical)
permissionGrants('admin.users.list', 'admin.users.ban') // false
permissionGrants('admin.*', 'site.posts.create') // false

The matcher lives in shared/permission-engine.ts and is used unchanged on both the platform and the organisation scopes.

Scopes

Two scopes ship out of the box; both share the matcher, the registry shape, and the override mechanism — they only differ in where the grant is stored.

ScopeStorageDecorator
platformusers.platformRole + users.customPermissions@RequirePlatformPermission(p)
orgemployees.roleIds[] + employees.customPermissions@RequireOrgPermission(p)

The org-scope decorator accepts an OrgIdResolver so a single permission applies to a specific organisation extracted from the call's args:

@RequireOrgPermission('org.shops.create') // default: args[0].organizationId
@RequireOrgPermission('org.shops.update', { argIndex: 0, field: 'orgId' })
@RequireOrgPermission('org.disputes.respond', (args) => args[0].dispute.organizationId)

Validation

Every permission key referenced in a role definition is validated against the registry at write time:

isValidPermissionKey('admin.users.ban', PLATFORM_PERMISSIONS) // true
isValidPermissionKey('admin.users.*', PLATFORM_PERMISSIONS) // true (wildcard, at least one key under it)
isValidPermissionKey('admin.users.lban', PLATFORM_PERMISSIONS) // false — typo refused

createRole / updateRole reject the whole mutation on any unknown key — the registry is the source of truth and typos never make it to the persistence layer.

See also