Skip to content
Registry StackDocsDevelopment (unreleased)

Control access per profile

View as Markdown

You are authoring a registry project that bregctl check compiles, and you want to decide who may read and write what. This page covers access profiles and their grants, the three field lists that bound a response, a filter, and a write, the row boundaries that tie rows to a caller’s claims, the requirements an entity imposes on every profile that touches it, and the offline preview that tells you whether a set of synthetic claims would be admitted. At the end, every profile grants what its callers need and nothing more, and you can show a reviewer why.

An access profile is the unit of authorization. The application selects one profile per request, and that profile decides everything the request may do: which operations, fields, rows, lookups, and paths it may touch. Profiles are never merged, and naming a profile in a request grants nothing the profile does not already allow.

Before the server looks at any record, it admits the request against the selected profile: the token’s principal claim must be present, every scope in requiredScopes must be present, the token’s purpose must match one of requiredPurposes, and every row boundary must find its claim. explain access states the same rules at the top of its report:

all required scopes must be present
one allowed purpose must match; empty means unrestricted
all claim-bound row predicates must hold; empty means no row restriction
one profile per request; selecting its name never grants authority and profiles are not merged

Treat profiles as tasks, such as reading assigned records or editing records you own. The identity provider issues permissions and assignments; Base Registry Engine enforces the selected task’s limits. The application selects the profile for the task, while its users work with actions such as “edit address”. A caller holding several permissions still uses one profile at a time. If reading and editing need different row restrictions, declare separate task profiles with the appropriate operations. The server does not search for a more permissive profile when a request fails. Defaults apply only to the operations and paths a profile actually grants. You can leave an overlapping route without a default when choosing a task should be explicit.

What a token must carry to satisfy them, and where it comes from, belongs to Deploy a registry.

accessProfiles:
- id: site-planner
default: true
principalClaim: registry_principal
requiredScopes: [registry:sites:plan]
requiredPurposes: [site-planning]
grants:
- entity: asset-site
operations: [get, list]
readableFields: [site-code, label, zone]
filterableFields: [site-code, zone]
sortableFields: [site-code]
allowCount: true
rowBoundaries:
- field: zone
claim: planning_zones
operator: in
- entity: household
rowBoundaries: []
operations: [get, lookup]
readableFields: [household-code]
lookups:
- selector: by-household-code
valueOrigin: request
readPaths:
- path: people
readableFields: [person-code, legal-name]
- id: public-directory
anonymous: true
grants:
- entity: asset-site
rowBoundaries: []
operations: [get, list]
readableFields: [site-code, label]

Profile members:

MemberEffect
principalClaimThe explicitly selected token claim that identifies the caller in audit and workflow decisions. Required unless the profile is anonymous; it must match the runtime principal mapping.
requiredScopes, requiredPurposesEvery listed scope, and one of the listed purposes, must be present in the token. Empty lists impose no restriction for that dimension.
default: trueThe profile selected when a request names none. At most one default may expose a route. If several profiles expose a route and none is default, the request must name its profile. A sole profile is selected automatically.
anonymous: trueNo token needed. Anonymous profiles cannot carry row boundaries, lookups from claims, or write operations.

A profile with no required scope raises access.profile.no_required_scope, because any authenticated principal satisfying its purpose and row claims could then select it.

Each entry under grants names one entity, or one action, and what the profile may do with it.

Three field lists bound every entity grant. readableFields decide what a response may carry, writableFields what a create or patch may set, and filterableFields which fields $filter may name; sortableFields and allowCount do the same for $orderby and $count. A write never reveals a field the profile cannot read. Field ids in grants are the authored ids, asset-code, not the API names, assetCode, and a grant that names a field the entity does not have fails check. A profile that grants create or patch without any writable field raises access.profile.no_writable_fields, because every write naming a field would then be refused.

Every entity grant declares rowBoundaries explicitly, as do action targets, review targets, apply targets, and request-presence grants. Write rowBoundaries: [] when the grant intentionally has no row restriction; omitting the member is an error. An empty list grants all rows within the other permissions, so use it only where that breadth is intended.

A row boundary compares a declared field with a verified token claim: equals against one claim value, or in against a claim carrying a list, which the token issuer must then issue as a JSON array. On reads, rows outside the boundary do not exist for the caller: a get returns 404, and a list omits them. Writes check both the existing record, where one exists, and the proposed record against the same boundaries. Bind the boundary to whatever field carries your registry’s tenancy, an owning office, a jurisdiction code, a programme. Decide deliberately which profiles may write that field, because a profile that can patch it moves records in and out of another caller’s rows; check reports that as access.profile.writable_row_boundary. A profile that can list an entity with no row boundary raises access.profile.unrestricted_collection, because caller-supplied filters are never authorization; accept it only for a registry-wide role.

Use the principal claim for an ownership boundary when the field stores that same identity, for example {field: owner, claim: registry_principal, operator: equals}. District or team assignments belong in separate claims, such as assigned_districts. All boundaries must hold, so combining owner and district restricts the caller to their own records within assigned districts. The same claim must have a consistent type and cardinality throughout the project. Principal reuse passes the exact identity string through the field’s normal validation; it does not convert the identity or infer assignments.

The remaining grant members:

MemberEffect
lookupsThe selector profiles this grant may use, and whether the values come from the request or from a verified_claim with a claimMapping.
readPathsThe read paths this grant may follow and the target fields it may read.
spatialQueries.bboxAllows bounding-box queries and caps their longitude and latitude span in degrees.
revisionAccessWith the revisions operation, lets the profile read a record’s revision history. check reports access.profile.revision_history, because history can disclose values removed from the current record.
allowDataExportEnables bulk export through the profile. check reports access.profile.data_export, because disabling it later cannot recall downloaded data.
requestPresenceLets a reader of a target entity see that a change request of the named requestType is pending against a record.
reviewStages, applyTargetsWhich review stages a reviewer may decide, and which target entities an applier may write.
action, targets, resultsAn action grant instead of an entity grant.

The last three rows belong to Declare change requests and actions.

The operations a grant may list:

OperationGrants
create, get, list, patch, tombstone, batchDirect reads and writes. tombstone needs tombstone: true on the entity.
lookupSelector lookups declared under lookups.
revisions, snapshotReading revision history and snapshot or effective-time reads.
submit_request, revise_request, cancel_requestSubmitter actions on a change-request entity.
approve_request, reject_request, request_revisionReviewer actions, limited by reviewStages.
apply_requestApplying an approved request, limited by applyTargets.
invokeInvoking an immediate action.

An entity can state what any profile touching it must satisfy, and the compiler checks every profile against it:

- id: facility
accessRequirements:
requiredScopes: [facility:read]
allowedPurposes: [facility-registry]
rowBoundaries:
- field: administrative-boundary
claim: administrative_boundaries
operator: in

A profile that grants the entity without the scope, with another purpose, or without the exact field, claim, and operator binding fails the check. Requirements never grant access; they only refuse a profile that would widen it. Use requirements for limits that apply to every role, including actions and workflow target grants. An exact district binding also applies to a supervisor; it is unsuitable as a universal requirement if that supervisor should have registry-wide access. Requirements grant no exemption based on a profile’s name.

Terminal window
bregctl explain access ./my-registry

The plain-text report lists entity profiles with each grant’s row restrictions; --format json adds action grants, nested workflow permissions, and the row reach of every target. For the record-reader profile of the project init wrote, the block reads:

entity: record (internal)
profile: record-reader
principal claim: registry_principal
operations: ["get","list"]
required scopes (all): ["registry:generic:read"]
allowed purposes (any): ["registry-reporting"]
readable fields: ["code","group","label","status"]
writable fields: none
filterable fields: ["code"]
sortable fields: none
row restrictions (all): [{"claim":"registry_record_status","field":"status","operator":"equals"}]
lookups: none
related records: none
anonymous: false
allowCount: false
revisionAccess: false
allowDataExport: false

The other profiles and the findings are omitted. Read the complete report as a reviewer: inspect direct operations, action targets, and workflow targets separately. A direct entity grant does not describe all authority held through actions or change requests.

The compiled report shows what a profile allows; the preview shows whether a given caller would get in. Save a scenario file beside the project. This one matches the record-reader profile init wrote, so you can run it unchanged:

{
"entity": "record",
"accessProfile": "record-reader",
"operation": "list",
"claims": {
"principalClaim": "registry_principal",
"principal": "synthetic-reader",
"scopes": ["registry:generic:read"],
"purpose": "registry-reporting",
"directClaims": {"registry_record_status": "active"}
}
}
Terminal window
bregctl explain access ./my-registry --scenario ./access-scenario.json

The preview runs the same admission function as the HTTP layer and reports the verdict and the rule that produced it:

synthetic profile admission: allowed (profile_requirements_satisfied)
No credentials verified, records checked, or authority issued. Claim values are not printed.

Remove registry:generic:read from scopes and the first line becomes synthetic profile admission: refused (required_scope_missing). The other reasons are principal_missing_or_mismatched, purpose_missing_or_not_allowed, row_claim_missing_or_wrong_cardinality, operation_not_granted, and entity_or_profile_not_found. Both admitted and refused previews exit successfully, because a refusal is the answer you asked for; bregctl --format json explain access ./my-registry --scenario ./access-scenario.json reports the same verdict as explanation.admitted and explanation.reason. A scenario may add readPath with operation: list to preview a read path. The file is limited to 64 KiB, must be strict JSON without duplicate keys, and refuses unknown members.

In a repository checkout, products/breg/examples/access-review/README.md provides an offline district-reader check and a task-profile project covering assigned records, owned records, a registry-wide supervisor, history, an action, and reviewed corrections. Follow its README to compare profile admission with the limits that need a running database.

SymptomCause and next move
A request is refused when accessProfile is omitted.Several profiles expose that operation or path and none is the default. Have the application select the intended profile; it must still satisfy that profile’s permissions.
check refuses a grant because the entity has no such field.Field ids in grants are the authored kebab-case ids, not the camelCase API names. Use asset-code, not assetCode.
The preview refuses with required_scope_missing, purpose_missing_or_not_allowed, or principal_missing_or_mismatched.The scenario’s claims do not satisfy the profile. Compare scopes, purpose, and principalClaim with the profile’s requiredScopes, requiredPurposes, and principalClaim.
The preview refuses with row_claim_missing_or_wrong_cardinality.A row boundary found no claim, or an in boundary found a single value where it needs a list. Add the claim under directClaims with the cardinality the operator expects.
The preview refuses with operation_not_granted or entity_or_profile_not_found.The scenario names an operation the grant does not list, or an entity or profile the project does not declare. Check the ids against explain access.
check reports access.profile.unrestricted_collection.A profile lists an entity with no row boundary. Add a claim-bound boundary, or accept a registry-wide role knowingly and record why.