Skip to content
Registry StackDocsv0.34.0

Membership and consent read boundaries

View as Markdown

A read grant can require that the caller hold a current active membership in the organization, program, or team responsible for a record. Memberships are ordinary governed records: an authorized steward creates or deactivates them through the normal mutation API, and the reader keeps the same identity token throughout. Base Registry Engine (BReg) compiles the requirement into the database, so the same model works for facility, document, or asset registries without new server code.

A read grant can also require that the subject of a row consented to its disclosure to the caller’s organization, for the request’s purpose, before the row is returned. Consent decisions are ordinary create-only records, and the same database-side model enforces them. Consent-gated reads covers that boundary.

Reach for a membership boundary when the record itself does not carry the caller’s identity and the right to read it follows from an affiliation that changes over time. When the record stores the authorized principal directly, an ordinary rowBoundaries claim comparison is the simpler mechanism and stays available.

A facility stores organization, a reference to an organization entity. A separate membership entity stores an organization reference, a string principal, and a Boolean active. The boundary joins them inside the facility’s read grant:

permissions:
- entity: facility
rowBoundaries: []
operations: [get, list, lookup, snapshot, revisions]
readableFields: [label]
allowCount: true
revisionAccess: true
membershipBoundaries:
- field: organization
membershipEntity: membership
membershipKeyField: organization
principalField: principal
activeField: active

The two organization fields must be stored references to the same entity, principalField must be a stored string or text field, and activeField a stored Boolean. Only an active-lifecycle membership row with active: true and an exact match to the profile’s verified principal grants access: a missing membership, a null value, an inactive membership, or another principal does not. The declared fields are authorization inputs, not an additional readable-field grant.

Every listed membership boundary and every ordinary rowBoundaries predicate must hold. Declare rowBoundaries explicitly even when membership is the only row restriction; rowBoundaries: [] adds no direct claim predicate and leaves the membership checks in force. A permission carries at most eight boundaries, reported as access.membership.limit. Configure the identity claim from a trusted issuer, and never accept a caller-submitted principal as authority.

The refusals keep a membership boundary from being read as broader authority than it is:

CombinationDiagnostic
An anonymous profile, or one without a verified principalClaimaccess.membership.authentication
Writes, actions, reviewed changes, or request lifecycle grantsaccess.membership.read_only
A spatial bbox permission, which uses a separate database authority roleaccess.membership.spatial_unsupported
A membership entity with a change-request lifecycleaccess.membership.request_unsupported
More than eight boundaries on one grantaccess.membership.limit

A membership-bounded grant supports get, lookup, list, snapshot, and revisions, plus counts when it sets allowCount. It can protect the root of a relationship read path, and a target permission that would reach the same entity through a separate root is rejected rather than silently bypassing the rule. A membership source stays a leaf entity: no boundaries of its own, no change-request lifecycle, and no incoming relationship read paths, which is what keeps row-security policies from recursing. A membership entity with mandatory rowBoundaries is rejected at compilation, because the boundary carries no additional direct row requirement from that source. Use a separate, explicitly authorized steward profile for membership administration and record mutation, and bregctl explain access to inspect an expanded boundary beside its scopes, purposes, and ordinary row boundaries.

Authorization queries read current membership records in PostgreSQL. A request issued after a deactivation commits is denied or returns an empty authorized collection, including counts, lookups, and cursor continuations; a read already running can finish from the statement snapshot that preceded revocation. There is no token membership cache, external authorization service, or registry-wide authorization lock, and a read cursor replay reevaluates live membership.

Snapshots and revisions compare the retained record’s organization with current memberships, so historical membership state cannot restore access and deactivation removes access to historical records as well. Moving a facility to another organization does not by itself remove access to its older revisions from members of the previous organization: that preserves the ordinary historical row-boundary contract, and current-owner-only history is a different policy the boundary does not imply. Erased history stays unavailable.

PostgreSQL enforces the predicate through generated SECURITY INVOKER functions and forced row-level security. Each generated PL/pgSQL function saves the prior authorization context, enables only its matching membership probe, and restores the context before returning, with an exception block that rolls back the local context on error. It grants no access to private membership records through direct endpoints or derived views, and it needs no additional database role and no row-level-security bypass.

A membership boundary asks who the caller is affiliated with. A consent boundary asks what the row’s subject decided: whether they agreed that this recipient may read their record for this purpose, under this profile. It is not a lawful-basis engine, a consent-request workflow, or a credential. The registry records what the subject decided and refuses a gated read the recorded decisions do not cover.

A project declares recipients: organizations, each with the OAuth clients it reads through, and groups of organizations. The caller’s recipient set comes from the verified requester client, the token’s azp or client_id: the organization that lists that client plus every group it belongs to. A token cannot supply the set. The engine discards any registry:recipients or registry:consent-decisions: claim a token carries and fills both from its own configuration, and a client no organization lists has an empty set, so every consent check it reaches fails closed. The server refuses to start when a recipient client is missing from the issuer’s allowedClients.

A permission lists requireConsent checks, each naming a consent record and the row key it matches: the row’s own id, or a stored reference to the subject. A row is disclosed while, for every check, a live give exists whose subject is the row’s key, whose scope is the gated profile id, whose purpose is the request’s verified purpose, and whose recipient is in the caller’s set.

Decisions are ordered by their capped time, the earlier of the declared start and the moment the registry recorded them. A withdrawal whose capped time is at or after a give’s ends that give, and on a tie the withdrawal wins. So a future-dated give cannot outrank a withdrawal recorded after it, and a backdated give recorded after a withdrawal is superseded by it: a renewed give needs a current start. A give ends at the earlier of its until and its capped time plus the record’s maxDuration. Expiry is computed at read time, and a withdrawal takes effect for transactions that start after it commits, with no token change, cache flush, or restart.

The profile id is the scope a subject agreed to. Removing requireConsent or widening a gated profile in place would read under a scope the subject never saw, so the supported change is a new profile id with the old one listed under retiredConsentScopes. bregctl diff marks the in-place change for review and attaches a reason; that reason is a review aid in tooling output, not a runtime guarantee. Consent codes are append-only: stored decisions keep their recipient and scope codes, so a recipient is retired by removing its clients, not its entry.

CombinationDiagnostic
Writes, actions, reviewed changes, or request lifecycle grants on a gated permissionconsent.require.read_only
An anonymous gated profileconsent.require.anonymous
A gated profile without requesterClients, or with a client no recipient organization listsconsent.require.clients
A spatial bbox permission, which uses a separate database authority roleconsent.require.spatial_unsupported
Bulk data export of a gated permissionconsent.require.export_unsupported
Exporting a gated profile as an Evidence sourceconsent.require.evidence_source_unsupported
A read path that reaches a gated entity through a later stepconsent.require.read_path_target
A profile granting create or batch on a consent record, which only actions declaring consentIssuer writeconsent.record.direct_write
A consent record no permission requires yetconsent.require.unused

Every read operation of a gated permission is checked: get, lookup, list, count, snapshot, revisions, continuation cursors, and read paths rooted at the gated entity. A row without consent answers 404 exactly like an absent row.

A profile without requireConsent bypasses consent. bregctl check reports access.consent.ungated_client when an ungated profile reads a gated entity while it admits any client, or a client a gated profile admits; give each side its own clients. A recipient reads the decisions addressed to it through a feed profile, and that feed shows withdrawals and invalidations addressed to it, including one for a subject it never received a give for; refusals never reach it. Assisted capture and link provisioning rest on staff integrity and the audit journal. List response timing is not equalized, so a caller measuring many requests may infer the size of a gated population. Consent gates rows, not fields.

Each consent record compiles to one SECURITY INVOKER set-returning probe function, evaluated once per query under the same processing-only marker model as membership boundaries. The runtime sets the caller’s recipients in the record transaction from the verified client, never from a token claim or request header, and the probe matches nothing when that setting is missing or malformed. The compiler also creates the indexes the probe reads, and a runtime refuses a probe whose catalog definition was altered.