0036. Custom roles and property-scoped membership
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: Accepted
- Date: 2026-10-27
- Extends: ADR 0017, ADR 0024, ADR 0030
Observable acceptance criteria
Section titled “Observable acceptance criteria”An administrator opens Settings → Roles, duplicates an immutable built-in role or creates a
workspace role, selects permissions by area, saves it and sees it after reload. An
administrator assigns that role and one or more allowed properties to a member. On that
member’s next request, unauthorized operations receive 403 and another property’s records
are invisible in lists, search, calendar, reports and the property switcher. Database RLS
blocks cross-property reads and writes even if a query omits its property filter. An
administrator can edit an unused role and delete it, but cannot delete one in use or remove
the last effective team.manage grant. Each role and access change is audited. The matrix
comes from the catalog, with Italian and English descriptions.
Integration acceptance (round 3)
Section titled “Integration acceptance (round 3)”Built-in users retain exactly the pre-integration authorization matrix for folio, policy, blocks, maintenance, reporting and team operations. Custom grants control those actions independently in the API and screens. A member limited to property A cannot read or mutate property B through those APIs or SQL without property filters. Reload preserves allowed data and denied operations leave it unchanged. Migration 0016 follows main’s ARI migrations 0013–0015. The role migration remains unapplied and was renumbered from 0013 when those main migrations merged; scoped invitations remain part of that same migration. Contract revision 1.13.0 and generated clients expose the complete catalog in both languages.
Decisions
Section titled “Decisions”A custom role belongs to one workspace. The existing organization owner/admin roles
keep full access to every workspace and cannot be reduced by a workspace membership. The
six built-in workspace roles are immutable templates in guestcraft_meta.role_templates;
application runtime roles have no write grant there. The core catalog and its
BUILT_IN_ROLE_PERMISSIONS mapping decide authorization. Migration 0016 seeds a database
copy of the templates for inspection; a database test compares it to the core mapping.
Custom role names are trimmed and unique per workspace without case distinction. The role
contains catalog codes only; unknown or duplicate codes are rejected. Deletion is blocked
while a membership refers to it. Membership keeps the existing role column for built-ins
and adds a composite foreign key to a workspace-local custom role.
can(principal, permission, {workspaceId, propertyId?}) is the only new domain permission
check. The permission catalog is stable API data, not a UI-only matrix. To convert an
existing operation, choose the narrowest code for its actual action, pass the concrete
property ID when it has one, and add a built-in grant only if that role was previously
allowed. Tests assert each built-in grant against the pre-0036 behavior. In particular,
folio read, post, correction, refund and open-balance checkout approval are separate codes;
the existing front-desk role can post but cannot refund or approve an open balance. The
server sends effective grants in the workspace shell; web action and navigation visibility
reads those grants. The original coarse matrix in GET /roles is derived from the same
built-in mapping for API compatibility. New operations from concurrent branches must
follow the same recipe and add IT/EN catalog labels and a database template entry in a
new migration if 0016 has already shipped.
The integration of main at dd61725 assigns dedicated folio, policy, blocks,
maintenance codes as listed in the workflow contract.
The compatibility test freezes each operation’s old role arrays independently of catalog
metadata. Reports and team already use reports.view, team.manage and audit.view.
A pending invitation loses its issuer’s authority when that issuer becomes scoped.
Only unrestricted effective managers count toward last-manager guards.
A workspace membership uses property_scope=all or selected with at least one property
in the latter case. Selected IDs must belong to that workspace. The request’s live
principal carries those IDs; the unit of work writes app.property_ids for PostgreSQL.
Every property-bearing table receives a restrictive RLS policy in addition to the existing
permissive workspace policy. Indirect children such as inventory days and folio movements
follow a visible parent. A scoped member cannot manage the team, view the workspace audit,
configure workspace-wide properties, or read or configure Channex data. Those operations
have no safe single-property interpretation. Workspace-wide guest search shows a guest
created in an allowed property or associated with a reservation there; a shared guest
appears in both.
Persistence review correction: standalone guest creation requires a validated
origin_property_id chosen in the directory’s New guest form. It is persisted explicitly
and never selected from membership array order. New inline reservation guests and newly imported OTA guests inherit the
reservation’s explicit property. Guest updates preserve ownership. Earlier guests with no
origin remain visible to unrestricted members and become visible to selected members through
an associated reservation in an allowed property. Organization owners/admins
and unrestricted memberships use all properties.
Team writes serialize on the organization row, then validate the actor’s live grant and the
target role/properties. Role, membership, audit and domain event writes share a transaction.
The member editor saves role and allowed properties together; invalid input leaves the
previous access in place. The permission matrix remains readable without team management,
while role editing requires team.manage.
Public contracts and limits
Section titled “Public contracts and limits”GET /api/v1/roles provides the built-in matrix and permission catalog. GET/POST /api/v1/roles/custom lists and creates workspace roles. GET/PUT/DELETE /api/v1/roles/custom/{role_id} reads, replaces and deletes one role. GET/PUT /api/v1/members/{member_id}/access reads and replaces a member’s role plus property set.
Each item read returns a strong ETag. PUT and DELETE require that exact value in
If-Match; a stale value returns team.precondition_failed (412), and delete succeeds
with 204. Role creation returns 201, Location and ETag. Workspace selection uses the canonical GuestCraft-Workspace-Id header. Deprecated
workspace_id query/body aliases are accepted only when they equal that header. The generated SDK and React Query factories follow these paths.
The member and invitation list responses include nullable property_ids (null means all).
A workspace invitation records both role and allowed property IDs before the link is issued. The default is all properties; a selected set must be nonempty and belong to the workspace. Acceptance creates the membership and selected-property rows in one transaction, so the invited colleague has scoped access on the first request. Resending preserves the selection. Existing memberships are not widened by accepting another link. Organization invitations continue to have unrestricted inherited access. Bulk membership editing remains planned. Country-specific permissions belong to their own modules and must not be added to the neutral catalog until those modules exist.
Security review R1 acceptance and decisions
Section titled “Security review R1 acceptance and decisions”The independent review and
finding-by-finding response refine the acceptance
criteria: a supervisor holding team.manage and reservations.read must fail to create a
refund role, expand their own role, assign themselves admin, or invite an admin. Creating
and delegating a subset must succeed. A pending invitation must fail if its role grows beyond
the issuer’s current grants before acceptance. Denied writes leave roles, access and audits
unchanged, and take effect without signing in again.
Every team mutation holds the organization lock while resolving the actor’s current role and
comparing the proposed permission set with the actor’s effective set. Creation, replacement,
built-in/custom assignment, invitation creation/rotation and acceptance share this ceiling.
Organization owners/admins remain unrestricted. No workspace actor may widen their own grants
or property access; a selected-property membership cannot use team.manage at all. The editor
disables grants and role choices beyond the current ceiling and explains why. A role edit can
reduce the actor’s own role, subject to the last-manager guard. The obsolete
workspaces.addMember/removeMember and corresponding store methods are removed; team
transactions are the sole application membership-write path.
Missing or empty app.property_ids now grants zero property-bearing rows. The unit of
work explicitly writes * for unrestricted user/system/worker operations; a UUID array grants
only that set. Restrictive INSERT/UPDATE/DELETE policies on membership, membership-property
and custom-role tables also require *. Identity-resolution reads remain possible. Restrictive
SELECT policies hide workspace-wide audit/event rows from selected or missing property context;
scoped business writes can still append their own audit/event entries atomically.
Role audits include role id and sorted permission codes added/removed. Access audits include
previous/next role ids, permission deltas, previous/next property selections (null means all),
and added/removed property ids. Organization membership audits retain previous/next org roles.
The audit API/viewer exposes those safe facts. Tokens, invitation links, credentials and names
are absent from these deltas. Domain events carry the same safe deltas and scope.
properties.read gates property collection and item HTTP reads. The inert housekeeping.read
code is removed: inventory.read includes room readiness. The catalog now contains 57 codes,
each connected to a server authorization path. inventory.configure explicitly includes
back-dated arrivals. Dedicated folio posting/correction/refund/re-rate descriptions state what
their answer includes: since the 2026-10-30 amendment below, the full folio only with folios.read.
Names normalize with NFKC before storage/uniqueness comparison, reject controls and formatting characters (including bidi overrides), reserve built-in codes and EN/IT names without case or separator distinction, and reject mixed Latin/Cyrillic/Greek impersonation. This is not a complete Unicode confusable-name registry; names using a single other script remain supported.
API revision 1.13.0 returns the same CustomRole representation on create/item reads, with
Location free of workspace query parameters. Custom-role pages are {data,next_cursor, grantable_permissions}. Member and invitation collections have independent bounded offset
cursors (maximum 100 rows/page); clients follow both. Offset pagination can shift under
concurrent inserts. Team lists reuse the existing bounded audit offset model for this security
fix; stable keyset cursors remain planned before large exports. This is an explicit temporary
deviation from ADR 0016, as for audit.
Missing roles return team.role_not_found; invalid team JSON returns 422 with field-linked
errors. Successful access PUT returns 204 plus the replacement ETag. Legacy role/removal POSTs
require the same strong If-Match and transaction check, including organization changes.
Amendment 2026-10-28: fail-closed permission scopes and reserved role names
Section titled “Amendment 2026-10-28: fail-closed permission scopes and reserved role names”The domain code-quality review of 9539e56 found that can was a deny-list for a
member limited to selected properties (finding M1). That member received every workspace-level
permission except five hard-coded ones, so a new code was granted by default. Custom role names
could also impersonate built-in ones through apostrophe, accent or spacing variants (M2).
Scope per code. Every PERMISSION_CATALOG entry declares scope: "property" or
scope: "workspace", and a workspace entry also declares restricted: "filtered" or
"denied". The catalog satisfies a type that requires both, so a code cannot be added without
them. A core test pins the decision of every code independently of the catalog metadata. For a
member limited to selected properties, can now:
- grants nothing when the selection is empty, with or without a property;
- grants a property permission only for a named property in the selection. A call that names no property, such as property creation, is refused;
- grants a workspace permission only when it is
filtered, and refuses it when it isdenied; - never grants a code missing from the catalog, even when a stored role lists it.
Unrestricted members and organization owners/admins are unaffected, and the built-in matrix is unchanged.
| Scope | Selected-property members | Codes |
|---|---|---|
| workspace, filtered | allowed; rows limited to their properties by the restrictive guest RLS policy | guests.read, guests.manage |
| workspace, denied | refused | channex.view_health, channex.configure, channex.manage_account_mode, team.manage, audit.view |
| property | only for a property of the selection | the other 44 codes, including properties.read, properties.configure and messages.* |
The guest directory is a workspace resource: one guest can stay at several properties, and the existing restrictive policy limits it to guests created in, or booked at, an allowed property. Channex connections, sync health, mappings and webhooks span every property of the workspace. Team management and the workspace audit administer the whole workspace. None of these has a safe single-property meaning, as decided earlier in this ADR.
Reads across properties. canAcrossProperties(principal, permission, workspaceId) serves
the few reads that span every property the caller may reach. It requires the property permission
at each selected property, or workspace-wide for an unrestricted caller. The unit of work’s
app.property_ids limits the rows. It accepts property codes only, and its callers are:
- the property list (
properties.read); - a guest’s stay history (
reservations.read); - money in guest-level reservation views (
folios.read); - the workspace Messages inbox, its summary and request list, and a workspace synchronization
request (
messages.read,messages.configure; ADR 0044).
Every other screen’s API names its property: Today, planning, reservations, folios, availability, rooms, rates and plans, policies, blocks, maintenance, credits due and reports. A real PostgreSQL test drives each one for a selected-property front-desk member and revenue manager on two properties. Each call succeeds for the granted property and is refused for the other, and workspace-wide reads return only the granted property’s rows.
Behaviour changes. GET /properties/{id} checks properties.read for that property, so a
property outside the selection returns 403 before the database, like every other property route
(it was 404). The back-dated-arrival exception checks inventory.configure at the reservation’s
property. User-triggered ARI and booking-import operations now run their transactions in the
caller’s tenant scope. A workspace administrator who is not an organization administrator
therefore no longer receives organization-level RLS context. The push worker, webhook signals,
recovery and drains keep an explicit system scope for the workspace, documented in the code.
Catalog order. The catalog is ordered by group, in the order the role editor’s fieldsets and the permission matrix show: properties, inventory, guests, reservations, stays, policies, folios, rates, reports, housekeeping, blocks, maintenance, messages, channex, team. Codes are unchanged. Stored role permissions keep their alphabetical canonical order, so ETags are unaffected. API revision 2.0.1 reorders the permission enum.
Reserved role names. Names are compared on a key that is compatibility-decomposed and
case-folded, with diacritics, apostrophes, quotes, dashes, other punctuation, symbols and spaces
removed. Combining marks are removed before the case conversion as well, because the upper case
of U+0345 is a letter (the Greek capital iota). The core
always reserves the built-in role codes (BUILT_IN_ROLE_CODES: organization owner and member
plus the workspace roles). The API also reserves the team.role_<code> display name of every
code in every catalog language, read from @guestcraft/i18n, because the core cannot read
catalogs. A new language needs no code change. It refuses such a name with team.invalid (422)
and a field error on /name before the team gateway. The earlier hand-copied list also held
Italian words that are not displayed role names (“proprietario”, “membro”, “lettore”). Those are
now ordinary names. “Reception notte” and other descriptive names remain valid.
New codes from concurrent branches must declare their scope in the same way, and the type
check rejects them otherwise. The messages.* codes are property codes, checked on the
property of each thread, request or template. channex.manage_account_mode is workspace-scoped
and denied to members limited to selected properties, even when a custom role grants it.
Amendment: 2026-10-29 — English labels and reserved role names
Section titled “Amendment: 2026-10-29 — English labels and reserved role names”The English-only application decision in ADRs 0012/0013 supersedes the prospective IT/EN label requirement and EN/IT built-in-name reservation above. New capabilities need English catalog labels and descriptions. Only built-in codes and names in the published English catalog are reserved, with the same normalization protections. Former translated labels are ordinary custom-role names; existing custom roles keep their identity, grants and uniqueness rules. Earlier bilingual implementation and review evidence above is historical.
Amendment 2026-10-30: a folio write answers the folio only to a caller who reads it
Section titled “Amendment 2026-10-30: a folio write answers the folio only to a caller who reads it”The review of the folio response mappers (PR #221, finding 5) found that a custom role or API key
with a folio write grant but without folios.read received the whole folio in the write’s answer:
every movement, tax, total and balance, while GET …/folio refused it. Two paths made this a read
at will, with no write and no audit entry: an exact retry of a posting answers the current folio,
and a re-rate that changes nothing does too, which is every re-rate without a tax module. Security
review R1 (finding L3) had accepted the full answer for real writes and stated it in the
permission texts. Built-in roles were never affected: each one with a folio write grant also holds
folios.read.
Decision. folios.postExtra, folios.pay, folios.refund, folios.reverse and
folios.rerate answer the folio only when the caller also holds folios.read for the folio’s
property. Any other caller receives a FolioAcknowledgement: the folio’s id, workspace_id,
property_id and reservation_id, which the caller already knows from the path and the workspace
header. The status stays 200, and these operations still send no ETag or Location. The rule covers
every answer, a first write as well as an exact retry or a re-rate that changes nothing, so the
answer does not even tell such a caller whether its request wrote anything. The core folio service
applies it, as it applies every folio authorization; the API’s presenters still ignore the
principal. This supersedes the L3 response.
Rejected alternatives:
- Auditing no-op re-rates and retries: the folio is still disclosed; the audit only records it.
- Requiring
folios.readfor every folio write: it would remove a real least-privilege case, such as an API key for a point-of-sale integration that posts extras withfolios.postand must not read guest accounts, and would turn an idempotent retry into a refusal. - Answering a folio without its movements and with zero totals: plausible values that are false.
- Answering 204 to such callers: a status that depends on the caller’s grants, and the same contract change.
The other grants keep their answers. folios.configure_extras answers the saved extra, which is
the caller’s own input and its id. folios.credits_due, policies.prepayments and
policies.simulate_cancellation are dedicated reads. Reservation, stay and policy writes already
show money only with folios.read at the property. A night audit answers what
night_audits.run reads.
Contract. API revision 4.0.0: the 200 answer of the five operations is FolioWriteResult,
which is Folio or FolioAcknowledgement. A reader receives the same folio as before. A client
tells the two apart by the presence of movements. The generated SDK and React Query types
follow. The change stays under /v1 as a dated exception to the addition-only rule, recorded with
its criteria and migration in ADR 0016.
Screens. The web folio screen already requires folios.read. A role with a write grant only
sees the existing notice that it cannot view folios, and the write forms open only from that
screen. Such a role posts through the API only.
Amendment 2026-10-30: Channex’s price limits in ARI health need rates.read
Section titled “Amendment 2026-10-30: Channex’s price limits in ARI health need rates.read”The review of the ARI response mappers (PR #224, follow-up F1) found that ARI health answered the
property’s Channex price limits, settings.values.min_price and max_price, to every holder of
channex.view_health. Every built-in workspace role holds that grant, housekeeping included, and a
custom role can hold it alone; neither needs rates.read, which is “View rate plans, prices and
restrictions”. A property’s price floor and ceiling are commercial values, as its rates are, not a
diagnostic of publication.
Decision. channex.ari.health answers the two limits only to a caller who also holds
rates.read for the property. Any other caller receives the same health without these two members
of values. The warnings still name a set limit to every caller, without its value, because a
limit changes how Channex treats the values GuestCraft sends, which is what health reports. The
core ARI service applies the rule, where it already decides what a member reads of health (the
values of a send stay with the worker), with can at the concrete property: the rule that
mayReadMoney applies to money in general views. Members limited to selected properties are still
refused health entirely. Among built-in roles only housekeeping loses the limits; admin, front desk,
revenue manager, accounting and viewer hold rates.read and see no change.
Rejected alternatives:
folios.read: it governs guest accounts, and price limits are rate constraints.- Refusing health without
rates.read: housekeeping and operations roles would lose the publication diagnostics thatchannex.view_healthgrants. - Hiding the warnings that name a limit: the warning says that Channex may change or refuse a published value, which an operator needs, and it carries no amount.
Contract. API revision 4.1.1. values is an open map in the schema, so the reduced answer is an
instance of the unchanged schema, and the generated SDK types and validators do not change. The
criteria of ADR 0016’s 30 October 2026 exception hold: the change closes a security defect only; a
caller with rates.read sees no change; the status and headers are unchanged; and the document’s
schema already describes both answers, so no union and no exception entry are needed. The document
only gains a description of values that names the two limits and their permission. A client reads
them as optional keys, as the map’s type already requires.
Screens. The web Channels screen lists the settings the answer has, so such a member sees the other settings and the warning that names a limit. The mobile app does not read ARI health.
Amendment 2026-10-30: the front desk’s deposits due carry their amounts
Section titled “Amendment 2026-10-30: the front desk’s deposits due carry their amounts”The review of the reservation read-model mappers (PR #228, follow-up) found that the front desk
listed deposits due to a caller with policies.prepayments but without folios.read without their
required, covered and outstanding amounts, which the document requires. The generated SDK
validates every answer, so for such a role frontDesk.get failed as an invalid response: the web
and mobile Today screens could not load while a deposit was due.
Decision. deposits_due lists the prepayment statuses that reservations.prepayments answers,
under the same grant, policies.prepayments for the property, and carries their amounts to every
caller it lists them to. policies.prepayments is a dedicated financial read, “Read required,
covered and outstanding deposits with their deadlines”: its holder already reads these amounts per
reservation, so the list discloses nothing new. A caller without the grant receives an empty list,
as before. The totals and balances of stays still need folios.read.
Rejected alternatives:
- Optional amounts, documented as a reduced answer: a union in the SDK types and a new major revision under ADR 0016’s exception, to hide amounts that the same caller reads per reservation.
- Requiring
folios.readfor the list: it would take the deposits away from a least-privilege role that chases prepayments, and make the front desk disagree with the dedicated read of the same grant.
Contract. API revision 4.1.2. The schema is unchanged, and the answer now satisfies it; the
document only describes who receives deposits_due. Clients need no change: the amounts that the
schema always promised are present.
Screens. The web Today screen lists each deposit with its outstanding amount, where such a role saw a load failure. The mobile Today screen reads the same answer for its lists and loads again; it shows no deposits.
Amendment 2026-10-31: events.read
Section titled “Amendment 2026-10-31: events.read”events.read (group integrations, built-in for administrators) reads the events API of ADR 0040.
It is a workspace permission with restricted: "denied": an event names objects of any property,
and domain_events rows carry no property for row-level security to filter, so a member or an API
key limited to selected properties is refused rather than shown events of other properties.
Migration 0034 adds it to the administrator template and its delegation ceiling. API keys may hold
it. API key rotation applies the delegation ceiling to the rotating member, who becomes the
replacement’s creator.