Roles and permissions (RBAC)
This guide tells you how Kulcare decides what a member of a tenant can do. It also tells you how to add a permission key, change a default, gate an action, and test it.
Related guides:
../authorization-pundit.md: the Pundit rules, the keep list, loud failures, and authorize coverage.../row-level-security.md: tenant isolation.../../user/roles-and-permissions.html: the user guide for owners and admins.../../testing/rbac-qa.md: the manual QA guide.../../../plans/2026-09-14-feat-rbac-catalog-and-enforcement-plan.md: the plan. The labels KD1 to KD5 and KTD1 to KTD17 in this guide point to it.
1. The model
| Part | What it is | Where |
|---|---|---|
| Role | One of five membership roles: owner, admin, staff, practitioner, marketing. A member has one role in each tenant. | Identity::UserMembership#role |
| Permission key | A named action, for example view_patients. The catalog row gives its AREA, its LEVEL, its description, its default roles, and display. | reference.permissions (Reference::Permission) |
| Area | The group a key belongs to, for example clinical or payouts. Stored in feature_category. | Reference::Permission::AREAS |
| Level | The lowest rung of the area's ladder that grants the key: view, add, manage_own, manage, manage_all. | reference.permissions.access_level |
| App | Derived from the area, never stored, so the two cannot disagree. | Reference::Permission::AREA_APPS |
| Access table | Which apps exist, which areas sit in them, and which levels each area offers. | Reference::AccessTable |
| Catalog source | The seed file. It is the single source of the catalog (KTD3). | backend/db/seeds/reference/015_seed_permissions.rb |
| Tenant row | A change that a tenant made to one key for one role. enabled: true grants. enabled: false revokes. | identity.tenant_permissions (Identity::TenantPermission) |
| Per-user override | A grant or a revoke for one member. | membership.permissions['permission_overrides'] |
| Resolver | The one class that computes the effective set. | Identity::Permissions::EffectivePermissions |
Rules:
- An owner holds every permission. No tenant row, per-user override, or error removes a permission from an owner.
- A tenant row stores only a difference from the default. A tenant with no rows gets the catalog defaults (KTD6).
- So a change to a default reaches each tenant that has no row for that key and role.
- Permissions apply to the whole tenant. No permission applies to one facility only.
- Job titles such as nurse or receptionist (
StaffRole.role_type) are labels. They do not change permissions. - The
marketingrole holds the Marketing area and nothing else. It is a job, not a narrowerstaff.
Areas, levels and the access table
A key belongs to exactly ONE area, and an area belongs to exactly one app. A level is a rung on the area's ladder, and a level grants every key at or below it. So "Clinical at Manage" is a key set, computed from the catalog rather than written down twice.
The screen shows apps and areas. It does not show keys, except behind a disclosure.
The server owns the table. The screen reads it from
GET /identity/tenants/:id/permissions/access and sends back an area and a level. It never
sends a key list. The two halves of the repo deploy separately, and a save REPLACES a role's
whole key set, so a browser that works from its own copy of the catalog could revoke a key
it had not heard of.
Three areas can never be given to a role, whatever a request says:
subscription, payouts and tax_identity. Each is a decision about a named person, so it
is granted through the per-person dialog. Reference::AccessTable::OWNER_ONLY_AREAS names
them, and ApplyAccessLevels refuses them in the service as well as the policy.
An area whose keys match no level reads as Custom. Custom is a real state, not an error: it is what a per-key edit looks like through a level-shaped screen. A save leaves a Custom area alone until an owner picks a level for it.
Known gap, plan D12. The seeded defaults do not nest, so eleven of the twenty-one
editable role-and-area cells read as Custom on a tenant that has customised nothing. The
plan records the three ways out. Identity::AccessLevelsTest pins the count.
Adding a key to an existing level makes every role parked at that level read as Custom.
RoleAccessLevels matches a level whose key set is EXACTLY the role's, so growing a level
changes what "View" means and no stored row follows it. A migration that adds a key to a
level in use must backfill the roles sitting at it — find the (tenant, role) pairs whose
stored keys in the area are exactly the OLD set, and grant them the new key.
20260928000000_add_team_settings_read_keys.rb does this and is the worked example. Roles
running on the catalog defaults store no rows and need nothing.
An area's blurb is a promise. Reference::AccessTable::AREAS tells the owner what a dial
covers, and team_settings named users and vendors while the catalog held only a manage key
for each — so View could not reach them however the screen was set. Before shipping an area,
check that every noun in its blurb has a key at the lowest rung that should see it, or that
the surface needs no key at all (facilities: Identity::FacilityPolicy opens the list to any
tenant member).
Who becomes the owner
The user who creates a tenant gets an owner membership.
Identity::Facilities::SetupService#create_user_membership sets it on both creation paths:
| Path | Caller | Tenant status |
|---|---|---|
Sign-up with the Facility Owner / Manager card (signed_up_as: 'facility') | Identity::Users::ProfileOrchestrationService calls SetupService with draft: true | draft |
| The facility setup wizard | Identity::Facilities::WizardSetupService calls SetupService with draft: false | active |
Every other member joins through an invite and gets the role that the invite names.
Before #2371, the sign-up path gave the user an admin membership (#2368). That user could not
edit the permission matrix, because only an owner can save it. The migration
20260915110000_promote_sole_admins_on_draft_tenants_to_owner.rb repairs a draft tenant when
the admin is its only member. It promotes that admin to owner.
Known gap: the migration skips a draft tenant where the sign-up admin already invited other people. Such a tenant has no owner, so nobody can change its permission matrix. This query finds each one and its first member:
SELECT DISTINCT ON (m.tenant_id) m.tenant_id, m.id AS membership_id, m.role, u.signed_up_as
FROM identity.user_memberships m
JOIN identity.users u ON u.id = m.user_id
JOIN identity.tenants t ON t.id = m.tenant_id
WHERE t.status = 'draft'
AND NOT EXISTS (SELECT 1 FROM identity.user_memberships o
WHERE o.tenant_id = m.tenant_id AND o.role = 'owner')
ORDER BY m.tenant_id, m.created_at, m.id;When the first member is an admin with signed_up_as = 'facility', that member is the sign-up
owner. Promote that member to owner, and record the change in the audit log.
Where the code lives
| Piece | File |
|---|---|
| Resolver | backend/domains/identity/services/permissions/effective_permissions.rb |
| Unknown key error | backend/domains/identity/services/permissions/unknown_permission_error.rb |
| Invite and role ceiling | backend/domains/identity/services/permissions/role_ceiling.rb |
Membership check (has_permission?) | backend/domains/identity/models/user_membership.rb |
Policy helpers (PermissionChecks) | backend/domains/shared/policies/base_policy.rb |
| My-permissions endpoint | backend/domains/identity/controllers/my_permissions_controller.rb |
| Matrix endpoints and their audit | backend/domains/identity/controllers/tenant_permissions_controller.rb |
| Matrix save | backend/domains/identity/services/permission_management/sync_tenant_permissions.rb |
| Tenant row cleanup | backend/domains/identity/services/permission_management/normalize_tenant_permissions.rb |
| Tenant row cleanup task | backend/lib/tasks/rbac/normalize_tenant_permissions.rake |
| Invite guard and invite audit | backend/domains/identity/controllers/concerns/invite_guard.rb |
Catalog model (CATEGORIES, ROLES) | backend/domains/reference/models/permission.rb |
| Browser bridge | frontend/apps/shell/src/components/PermissionsBridge.tsx |
| Browser store | frontend/packages/auth-identity/src/store.ts |
| Browser hook | frontend/packages/auth-identity/src/hooks/usePermissions.ts |
| Browser gate component | frontend/packages/auth-identity/src/components/RequireAccess.tsx |
| Matrix screen | frontend/apps/shell/src/pages/account/TenantPermissionsPage.tsx |
2. Resolution order
The resolver answers one question: does this membership hold this key?
Details:
- The resolver walks only active catalog keys (
Reference::Permission.active,display: true). So an override or a tenant row for a hidden or unknown key never adds that key. - Only a real boolean counts as a per-user override. Any other value falls through to the tenant row and the default.
- An owner passes each key before the catalog check. So an owner passes an unknown key, with no raise.
Two entry points
effective = Identity::Permissions::EffectivePermissions.for_membership(membership)
effective.keys # granted active keys, sorted
effective.granted?(:view_patients) # true or false
effective.owner?
Identity::Permissions::EffectivePermissions.for_role(tenant_id: tenant.id, role: 'staff').keysfor_membershipincludes the per-user overrides of the member.for_rolegives the set of a role in a tenant: catalog defaults and tenant rows, with no per-user overrides. The invite ceiling (RoleCeiling) compares against it.- The resolver takes an explicit membership. It never reads
Shared::Current.membership, because a job sets the tenant but not the membership. - Policies call the resolver through
UserMembership#has_permission?.
Per-request cache
for_membershipandfor_rolekeep one instance for each request onShared::Current.effective_permissions.- The cache key is the membership ID and its tenant ID, or the tenant ID and the role. It never
uses
Shared::Current.tenant, becauseRlsContext.withchanges that value in a request. - Ten checks for one membership in one request run the queries one time.
- A save or destroy of a
TenantPermissionclears the whole cache. A change to the role, status, or permissions of a membership also clears it. The hooks run in the transaction, so a read after a write is fresh. Identity::Permissions::EffectivePermissions.clear_cache!clears it by hand.
RLS
identity.tenant_permissions has FORCE row level security on app.current_tenant_id. The
resolver reads the tenant rows under the RLS context of the membership's own tenant
(Shared::Tenancy::RlsContext.with). RlsContext.with puts back the caller's context in an
ensure. So a check in a job, a new thread, or a Connect fan-out reads the correct rows.
A tripwire compares the caller's context with the membership tenant. An empty context is normal
in a job, and it does not fire. A context that names another tenant is a bug in the caller. The
tripwire then raises TenantContextMismatch in test and development, and reports in production.
Failure mode
EffectivePermissions.strict? is true in test and development (Rails.env.local?). In that
mode an internal error or an unknown key raises. In production, UserMembership#has_permission?
reports the error with IDs only, and denies. An owner still passes. See
Loud failures.
3. The endpoint and the browser
GET /identity/my/permissions
The signed-in member's effective permissions for the session tenant.
{
"data": {
"role": "staff",
"tenant_id": "0190...",
"owner": false,
"permissions": ["manage_appointments", "view_patients"],
"invitable_roles": []
}
}- The tenant comes from the server session. The endpoint reads no tenant parameter.
permissionsis the sorted list of granted active keys. An owner gets every active key andowner: true.invitable_rolescomes fromIdentity::Permissions::RoleCeiling#invitable_roles. It is empty withoutmanage_users. See section 9.- The key list only grows over time. A client ignores keys that it does not know.
- The response has
Cache-Control: no-store, also on an error. - The rate limit is 60 requests each minute for each user. Over the limit, the endpoint returns 429.
- A user with no active membership in the session tenant gets 403 with the code
TENANT_ACCESS_DENIED. This includes a platform admin who is not a member. That code is notpermission_denied, so it cannot start a refetch loop.
The browser flow
| Part | What it does | Where |
|---|---|---|
myPermissionsQueryOptions, useMyPermissions | The query. staleTime 30 s, refetch on window focus, refetch every 5 min while the tab is visible. No retry on 400, 401, or 403. Two retries on other errors. | frontend/packages/shared-services/src/domains/identity/hooks.ts |
identityKeys.permissions.me(tenantId), identityKeys.permissions.meAll() | The query keys. | frontend/packages/shared-services/src/domains/identity/keys.ts |
PermissionsBridge | Runs the query one time for the app and writes the store. | frontend/apps/shell/src/components/PermissionsBridge.tsx, mounted in frontend/apps/shell/src/App.tsx |
PermissionsErrorNotice | Shows "Could not check your permissions. Retrying." after a failed load. | Same file |
| Auth store | enabledPermissions, permissionsOwner, permissionsRole, permissionsLoading, permissionsError | frontend/packages/auth-identity/src/store.ts |
usePermissions | hasPermission, canAccess, hasAllPermissions, isOwner, role, isLoading, error | frontend/packages/auth-identity/src/hooks/usePermissions.ts |
RequireAccess | Renders its children only when the check passes. Renders nothing while permissions load or after a failed load. | frontend/packages/auth-identity/src/components/RequireAccess.tsx |
The bridge follows these rules (KTD7, KTD8):
- A tenant switch or a logout clears the store first. The bridge also removes the cached answers of other tenants.
setMyPermissionsdrops a response for a tenant that is not the current tenant.- A final error (400, 401, 403), or an error with no earlier answer, calls
setPermissionsFailed. The key list stays empty, and the notice shows. The focus refresh and the interval refresh retry. - A session with no tenant ends the load state at once.
- Both API clients (
frontend/packages/api-client/src/index.ts,client.ts) send akulcare-forbiddenwindow event with the server's error code. On the codepermission_denied, the bridge invalidates the query, at most one time each 60 s. - A profile switch in another tab reloads this tab (
onProfileSwitchFromOtherTab), because the server session changed for all tabs. - The store does not persist permission fields (
PERMISSION_FIELDS). Persist version 1 drops them from a store that version 0 saved.
Rules for frontend gates
-
Import from
@kulcare/auth-identity. Do not import from the old@kulcare/authpackage. -
Use catalog keys (
manage_users,view_patients). Do not use dotted names such aspatient.create. The catalog keys are inPERMISSIONS(frontend/packages/auth-identity/src/constants/permissions.ts). -
Use
RequireAccessto show or hide a control. If you callhasPermissiondirectly, checkisLoadingfirst. Every check returns false until the first answer arrives. -
Gate a read on the READ key, not on the manage key. A gate written as
permission={PERMISSIONS.X.MANAGE}is a gate the View and Manage-own rungs cannot pass, so every rung below Manage lands on "Access Restricted" and the area reads as broken to the owner who set it. Name every key the surface legitimately opens on and passmode="any":<RequireAccess permissions={APPOINTMENT_READ} mode="any" fallback={<UnauthorizedMessage />}>Gate the WRITES separately, at the control — hand the handler through as
undefinedwhen the member cannot act, which is how most of these components already hide a button. This is what the September 2026 RBAC QA pass found across appointments, patient billing and team settings: the pages were gated at Manage, so the lower rungs bought nothing. -
Gate an app on the area's keys.
AREA_KEYSandREPORTS_APP_KEYSlist every key an area grants. A role at None holds none of them and a role at any other rung holds at least one, socanAccess(AREA_KEYS.marketing)is the area's on/off switch. Hide the nav entry and gate the route on the SAME list, or a hidden entry and a reachable URL disagree. -
Gate a write on the key its policy checks. The billing address page (
/account/billing-address,BillingPage.tsx) is the facility's tax identity, andBilling::BillingAddressPolicychecksmanage_tax_identityon every write. The page readscanManageTaxIdentityfromusePermissions(), notcanManageBilling(practice finance) and not the role name. It used to enable the button for every admin, whom the server then refused (#2338).tax_identityis seeded to the owner alone, so by default only the owner can edit, and a member granted the key holds it too. A member without the key sees a "View only" notice instead of silently disabled buttons; the notice is suppressed while permissions load or after a failed load, when the answer is not known. Editing is also blocked when the address itself failed to load: an "Add" there would upsert over a row the page could not see. -
hasPermissionreturns true for every key when the server's owner flag is true. -
Use
rolefromusePermissions()only for a persona gate on the keep list. That role comes from the endpoint, not from the JWT. -
An invite modal offers only the roles in
invitable_roles. It shows each other role as unavailable, with the reason. -
After a matrix save, invalidate
identityKeys.permissions.meAll(), so the editor's own permissions refresh. -
Do not calculate permissions in the browser. Do not call the endpoint from a second place.
4. Add a permission key
A new key changes access only through its defaults (R10). Do all of these steps in one change.
Some of these steps repeat each other, on purpose. Steps 10, 11 and 15 write the key, its defaults or its area a second time, by hand. That is not duplication to remove. Each one is a GUARD: it holds an independent copy of what the seed should say, so a seed edit that moves a key, changes a default or changes an area fails a test and reaches a reviewer. A guard that reads the seed agrees with the seed whatever the seed says, which is the same as no guard. Steps 6 and 8 work the same way: the migration holds a frozen copy because it runs BEFORE the seed, so for one deploy window the migration's copy IS the catalog.
- Add the entry to a non-billing section at the end of the seed data in
backend/db/seeds/reference/015_seed_permissions.rb. Add the section toALL_SEEDED_PERMISSIONS. - Keep the section above the first top-level
defin the file.NormalizeTenantPermissions.current_seed_defaultsreads only the part before thatdef. - Give
feature_name,feature_category,access_level,description, anddefault_enabled_for_roles. - Use an AREA from
Reference::Permission::AREASforfeature_category, a LEVEL fromReference::Permission::ACCESS_LEVELS, and roles fromReference::Permission::ROLES. The seed raises when a row fails to save.- Pick the level by what a grant of the key opens, not by the name of the resource. An area that promises money and delivers audit data teaches an owner to distrust the screen (plan D10).
- A key that creates, changes or deletes never sits at
view. - A key that a clinic would grant SEPARATELY from everything else in its area needs its
own area, not only its own key. A level grants every key at or below it, so two keys
in one ladder always travel together.
payoutsandtax_identityare the examples.
- Add
display: falseonly for a key that the matrix must not show. - Write a data migration that inserts the key with
ON CONFLICT (feature_name) DO NOTHING. Give it a hard-coded copy of the definition. - Make the migration
downa documented no-op. A delete cascades to every tenant row of the key. - Add a migration test that asserts the migration copy equals the seed section.
- If a policy ORs the new key with a current key, copy the revokes of the current key (see section 6).
- ADD the key to the sentinel list in
seed_reference_permissions!(backend/test/test_helper.rb). Add it, do not replace the list: the guard short-circuits the whole seeder, so a database holding one sentinel but not the others skips seeding entirely. This has bitten several branches. - Add the key and its default roles to the defaults snapshot,
backend/domains/identity/test/rbac/permission_defaults_snapshot.yml. - Add at least one backend check with a literal key:
has_permission?(:your_key). - If a frontend gate uses the key, add it to
PERMISSIONSinfrontend/packages/auth-identity/src/constants/permissions.ts. - Add the key to the policy matrix test of its domain and to the defaults test.
- Add it to the access table guard,
permission_access_table_test.rb, which is typed by hand from the plan so a seed edit that moves a key fails and is reviewed. - Run the RBAC tests (see section 12).
Why a migration: migrations run before deploy:seed_reference (backend/config/deploy.rb). The
new code raises on an unknown key, so the key must exist when the new code first runs.
Do not add a key to RBAC_CATALOG_ADDITIONS. The migration
backend/db/migrate/20260915000000_add_rbac_catalog_keys.rb already ran with that list, and a
test keeps the two equal. Start a new section and a new migration.
Patterns to copy:
- Migration:
backend/db/migrate/20260915000000_add_rbac_catalog_keys.rb - Migration test:
backend/domains/identity/test/migrations/add_rbac_catalog_keys_test.rb
Seed entry shape (an example key):
{ feature_name: 'view_widgets', feature_category: 'settings',
description: 'View widgets',
default_enabled_for_roles: %w[owner admin staff practitioner] },The catalog agreement guard fails when a checked key is not in the catalog. It also fails when
a catalog key has no check and no allowlist entry, and when a check builds its key at runtime.
The guard is in backend/domains/identity/test/rbac/catalog_agreement_test.rb.
5. Change a default
A default applies to each tenant with no row for the key and role. So a default change changes access in many tenants at one time. It is a reviewed act (KTD17, R11).
- Change
default_enabled_for_rolesin the seed. - Write a data migration that names the key and sets its new
default_enabled_for_roles. - If some tenants must keep the old access, write their tenant rows in the same migration.
- Update the defaults snapshot,
backend/domains/identity/test/rbac/permission_defaults_snapshot.yml, in the same commit. - Run the snapshot test. It fails when the seed and the snapshot differ.
- After publish, run
rbac:normalize_tenant_permissions(see section 7).
A seed edit alone fails the snapshot test. Do not update the snapshot without the migration.
6. Derived keys: the missing-row rule
Tenant rows are differences from the defaults. So a tenant with no row for a key and role gets the default. A migration that derives a new key from an old key must obey this rule:
A missing row means the old key's default for that role. It does not mean "off", and it does not mean "on".
The effective value of the old key for a tenant and role is:
- the row's
enabled, when a row exists; - else,
truewhen the old key'sdefault_enabled_for_rolesincludes the role, andfalsewhen it does not.
Compute the new key's rows from that value. Write a row only where the new key's default gives a different answer. Do not copy only the rows that exist. Such a copy ignores each tenant that kept the old default.
Revokes must carry over. Many clinical policies accept a new key OR an old key. A tenant that
revoked the old key from a role must keep that revoke through the new key (KTD14). The
CARRY_OVER table in backend/db/migrate/20260915000000_add_rbac_catalog_keys.rb shows the
pattern for tenant rows and for per-user revokes.
identity.tenant_permissions has FORCE row level security. A migration has no
app.current_tenant_id, so a plain INSERT ... SELECT reads zero rows and reports success. Turn
FORCE off and on inside the transaction, and set SET LOCAL lock_timeout. Do not restore FORCE
in an ensure. The rollback restores it after a failure. Pattern:
backend/db/migrate/20260906000000_split_patient_billing_permissions.rb.
7. Deploy step: normalize tenant rows
Run this task after each deploy that publishes a catalog change (a new key or a changed default):
cd backend && RAILS_ENV=production bundle exec rake rbac:normalize_tenant_permissions- Run it after publish, not before. The old release copies default rows into new tenants until the new release is live.
- The task deletes enabled tenant rows that only repeat their role default. It keeps each revoke, each real grant, owner rows, and billing rows.
- It compares the database default with the current seed. A key whose two defaults differ keeps
its rows, and the task reports it. A later run removes them, after
deploy:seed_referencemakes the two equal. - It snapshots the effective access of every tenant, role, and key before and after the delete.
On any difference it raises
AccessChangedand deletes nothing. - It is safe to run again. A second run prints
deleted 0 tenant row(s) that repeated their role default. - The staging and production deploys run this task after
deploy:publishing. The task isdeploy:normalize_tenant_permissionsinbackend/config/deploy.rb. The hooks are inbackend/config/deploy/staging.rbandbackend/config/deploy/production.rb. The task runs after the release symlink switches and before Puma restarts.
The migration backend/db/migrate/20260915100000_normalize_tenant_permission_rows.rb runs the
same service one time, with a frozen copy of the seed defaults. Do not change that copy.
A key rename denies non-owners until the new code is live
A migration that renames a catalog key opens a window. The migration runs before the new code
publishes, so for that time the old code asks for a key name the catalog no longer holds. The
resolver finds no row and denies every non-owner. The owner keeps access, because
has_permission? short-circuits on the owner.
20260919120000_add_permission_areas_and_access_levels.rb renames view_billing to
view_practice_finance, and manage_billing to manage_practice_finance. The team accepts
this window and does not work around it. No tenant holds a customised permission yet, and the
app is not released. Expect practice finance denials between db:migrate and publish. Do not
page.
For a rename after release, do one of two things. Publish the migration and the code that reads the new names in two deploys, so no live code asks for a name the catalog lacks. Or teach the resolver an alias table for one release, and delete it in the next. An alias that stays becomes permanent, and then it undoes the rename in all but the column value.
The rename also moves the per-member overrides. Those are keyed by feature NAME in
user_memberships.permissions, unlike the tenant rows, which point at the permission by id. An
override left behind sits under a name no catalog row answers to, so the resolver skips it and
falls back to the role. A key an owner took away from one person comes back.
8. Role checks and the role-check inventory
Gate actions on permission keys. Use a role check only for an item on the keep list in
../authorization-pundit.md.
Each direct role check has a line in backend/domains/identity/test/rbac/role_check_inventory.yml.
This covers tenant_admin?, tenant_owner?, admin_in?, and role == in the backend, and
role === "admin" in the frontend. The line gives the key that replaced the check, or the keep
reason. A test fails on a role check with no line, and on a line with no role check.
When you convert a role check:
- Write a characterization test of the roles that pass today.
- Add a key whose defaults are those roles, or use a current key with those defaults.
- Replace the check with
has_permission?(:key). - Update the inventory line.
- Run the characterization test again. It must pass with no change.
9. Grant ceiling, invites, and role changes
No non-owner can give another role or user more access than they hold (R15).
Matrix saves
TenantPermissionPolicy enforces these rules. SyncTenantPermissions repeats them, so a direct
caller cannot skip them.
- The matrix is served only for the session's tenant. A URL that names any other tenant gets 404 before the policy runs, and no audit record is written (#2351).
- Owners and admins of the tenant can read the matrix. Other members get 403. They get their own
permissions from
GET /identity/my/permissions, so no sign-in path reads the matrix. - Only an active owner of the tenant can save. An admin sees the matrix read-only. A person who
can save can give a role
manage_subscription, and so let that role cancel the Kulcare plan (#2337). - Nobody can edit the owner row.
- The service accepts only active permission IDs.
- The service stores only differences from the defaults. A reset to the defaults leaves the role with no rows.
A rejected save returns 403. The controller writes a rejection record to the audit log with the reason.
The matrix stays role-based (KD4). An admin keeps read access when an owner revokes
manage_users from admins.
Invites
The staff and practitioner invite controllers include Identity::Concerns::InviteGuard.
- The inviter needs
manage_users. A platform admin bypasses these checks. - Rank: nobody invites a role that ranks above their own (
RoleCeiling::ROLE_HIERARCHY). Nobody invites an owner. A rank rejection returns 422. - Ceiling: a non-owner invites a role only when they hold every key in that role's effective
set (
RoleCeiling#missing_keys). A ceiling rejection returns 403 with the codepermission_denied. - A staff invite with
staff_role: adminmakes an admin membership. The ceiling compares the admin set. StaffInvite::MEMBERSHIP_ROLE_BY_STAFF_ROLEdecides which membership role an accepted invite writes. A role added toRoleCeiling::INVITABLE_ROLESmust be added to that table too. The ceiling checks the role the owner picked. The accepted invite then writes whatever the table says. So the screen offers a role that is in one list and not the other, the ceiling vets that role, and the invite creates a different one. Nobody sees an error.marketingdid exactly that: the screen offered it, the ceiling vetted marketing, and the invite wrotestaff. That gave a marketing hire the whole staff catalog.staff_invite_membership_role_test.rbnow pins the two lists together, and it reads both accept paths so neither can map the role for itself again. That test names each role which deliberately writesstaff, with the reason.practitioneris one, becausePractitionerInvitecarries the real practitioner invite.GET /identity/my/permissionsreturnsinvitable_rolesfrom the same class. So the invite modal and the endpoints agree.
By default an admin cannot invite a practitioner. Practitioners hold keys that admins lack:
manage_own_patients, manage_own_appointments, and the clinical deletion keys such as
manage_care_plans. An owner invites the practitioner, or an owner grants those keys to the
admin.
Role changes
No endpoint changes the role of a current member today. A new endpoint that does must call
RoleCeiling#assignable? before the change, and must write an audit record.
backend/domains/identity/test/rbac/role_ceiling_test.rb pins the rule.
Per-user overrides
An override is the most specific source in the resolution order: it beats the tenant row for the member's role, and the catalog default under that.
The owner writes one through PUT /identity/users/:user_id/permissions (#2340). The body is
{ "permissions": { "<key>": true | false | null } }: true grants, false revokes, and null
clears the key so the role decides again. A key the caller does not name keeps what it has.
| Part | Where |
|---|---|
| Endpoints | backend/domains/identity/controllers/membership_permissions_controller.rb |
| Who may read and write | backend/domains/identity/policies/membership_permission_policy.rb |
| The write itself | backend/domains/identity/services/permission_management/sync_membership_permissions.rb |
| The screen | frontend/apps/shell/src/features/userManagement/components/modals/MemberPermissionsModal.tsx |
Rules, which the policy and the service both enforce:
- Owners and admins read a member's grants. The owner alone writes them.
- The owner role is never a target. An owner holds every permission already.
- The target must be a member of the tenant the session acts in.
- Only active catalog keys are accepted, and only
true,falseornull. - Every save, and every refused save, writes an
Audit::ActivityLogrow that names the target member and the keys granted, revoked or cleared.
Other paths must not write overrides. Staff creation, invite acceptance, and the admin facility
bulk create strip a permission_overrides hash that the caller sends
(UserMembership.without_permission_overrides), because a caller who could put that key in an
invite could grant the new member anything.
10. Sign-in identifiers
A sign-in email or phone signs its holder in to every tenant they belong to. So no tenant
manager can change it (KD5). The generic user update ignores email and phone_number.
The holder changes their own identifier with a verified flow:
| Endpoint | What it does |
|---|---|
POST /identity/reauth_codes | For an account with no password only. Sends a one-time code to the CURRENT phone (or email, when there is no phone). |
POST /identity/identifier_changes/request_code | Sends a one-time code to the NEW email or phone. Needs current_password when the account has a password. Needs current_code (the code from reauth_codes) when the account has no password. |
POST /identity/identifier_changes/confirm | Applies the change when the code matches. |
- Code:
backend/domains/identity/controllers/identifier_changes_controller.rbandbackend/domains/identity/services/identifier_changes/. - The change applies only after the code matches. A wrong or expired code changes nothing.
- The pending change holds a keyed digest of its code, the expiry, and a count of wrong attempts
(
Identity::BoundCode). It does not use the sharedusers.verification_codecolumn, so a sign-in or registration code cannot replace it or confirm it. Five wrong codes block it. - An account with no password cannot use an access token alone to move its identifier or to set a
first password (
POST /identity/passwords/change). It sendscurrent_codeinstead ofcurrent_password(#2350). - A confirmed change signs out the holder's other sessions, sends a notice to the OLD identifier, and writes an audit record.
- The frontend component is
SignInEmailChange(frontend/packages/ui-kit/src/components/identity/SignInEmailChange.tsx). It covers email only. No screen changes the phone today.
The holder verifies the email the account ALREADY has with a second pair. The identifier change
refuses the current address, so before this pair nothing set email_verified for it.
| Endpoint | What it does |
|---|---|
POST /identity/email_verification/request_code | Sends a one-time code to the CURRENT email. No body. Returns { email }. |
POST /identity/email_verification/confirm | With { code }. Sets email_verified and email_verified_at when the code matches. Returns { email, email_verified: true, email_verified_at }. |
- Code:
backend/domains/identity/controllers/email_verifications_controller.rbandbackend/domains/identity/services/email_verifications/. - The code uses the same mechanism as the identifier change:
Identity::BoundCodeunder theemail_verificationkey, ten minutes, five wrong codes, and the same 10-minute rate limits. It adds a daily cap for each user: 20 sends and 30 checks in 24 hours. The entry holds the address that the code went to. A code for an address that the account no longer has is discarded. - The check and the write of
email_verifiedrun under one row lock, so a parallel email change cannot get the flag for an address that got no code. - A failed send puts back the earlier code, so a code that an earlier email delivered still works. The error report names the error class only: a mail error can hold the address.
- Status codes: 422 for no email on the account, no code sent, a wrong or expired code, or a
changed address. 409 (
EMAIL_ALREADY_VERIFIED) for an email that is already verified, with no mail sent. 429 after five wrong codes or at a rate limit. - A tenant-restricted session cannot use it, the same as the identifier change.
- The frontend component is
EmailVerification(frontend/apps/connect/src/components/profile/EmailVerification.tsx).
Facility contact verification
A member verifies the facility email and each public phone number with a one-time code. Patients
and the messages that Kulcare sends use only verified values (plan
docs/plans/2026-10-12-feat-facility-contact-verification-plan.md).
| Endpoint | Who | What it does |
|---|---|---|
POST /identity/facilities/:facility_id/contact_verification/request_code | A member with manage_facilities on a facility of the session tenant | With { channel: 'email' } or { channel: 'phone', value }. Sends a code. Returns { channel, value, expires_in_seconds }, never the code. |
POST /identity/facilities/:facility_id/contact_verification/confirm | The same | With the same body and code. Marks the value verified with method: code. |
POST /identity/admin/facilities/:facility_id/contact_verification/request_code | A platform admin | Sends a code to the facility. Only a practice user can enter it. |
POST /identity/admin/facilities/:facility_id/contact_verification/verify_by_call | A platform admin | Phone only. Marks a number verified with method: admin_call after a call (a landline). |
- Code:
backend/domains/identity/controllers/facility_contact_verifications_controller.rb,backend/domains/identity/controllers/admin/facility_contact_verifications_controller.rbandbackend/domains/identity/services/facility_contact_verifications/. - The policy is
FacilityPolicy#verify_contact?: tenant member,manage_facilities, facility in the session tenant. It has no platform-admin short cut. The admin routes useIdentity::Admin::FacilityPolicy#request_contact_code?and#verify_contact_by_call?. - A tenant-restricted session and a suspended practice cannot use the practice routes.
- The verified state is
identity.facilities.contact_verifications, keyed by the normal form of the value. Thevalueof a phone request is thenormalizedvalue of the profile read. A change of the value removes its verification. No PATCH can set the column. - A code that a practice user asks for binds to that user, the facility and the value, in the
user's
settings(Identity::BoundCode, keyfacility_contact_verification). A code that a platform admin sends lives inidentity.facility_contact_codes, which has only the tenant RLS policy, never on the facility row. - A save of a facility email or phone that equals the editor's verified sign-in email or phone
marks it verified with
method: account_match(practice route and setup only). - Rate limits:
Identity::FacilityContactVerifications::RateLimits, keys underrate_limit:facility_contact:. They fail closed. The daily caps per channel areFACILITY_CONTACT_SMS_DAILY_CAP(default 2000) andFACILITY_CONTACT_EMAIL_DAILY_CAP(default 5000). - The switch
FACILITY_CONTACT_VERIFICATION_ENFORCEDturns the patient gate on. It is on by default, except in production. After each change of it, runbin/rails typesense:reindex_collection[facilities]andbin/rails typesense:reindex_collection[practitioners]. - Operations:
bin/rails identity:facility_contacts:coverage(read only) andbin/rails identity:facility_contacts:notify_unverified(one email to eachmanage_facilitiesmember of a facility with an unverified contact; a second run sends nothing).
User management is also tenant-scoped (R17). A user with manage_users can list, see, and
change only users who are members of the session tenant. A user outside the tenant gives 404.
11. Audit records
Each record goes through Audit::Dispatch.log_activity. It holds the actor, the actor's role,
the tenant, the target, and the changes.
Event (metadata.event) | Written by |
|---|---|
role_permissions_changed | TenantPermissionsController#update after a save. It lists granted and revoked keys. |
role_permissions_change_rejected | TenantPermissionsController, for each rejected save, with the reason. A request for a tenant other than the session's is refused with 404 before this point, so it writes nothing. |
invite_created | InviteGuard, for each invite. |
invite_rejected | InviteGuard, for each rejected invite, with the reason and the keys that the inviter lacks. |
| Identifier change | Identity::IdentifierChanges::ConfirmService, for each confirmed change. |
12. Tests
Helpers
RbacHelpers (backend/test/support/rbac_helpers.rb):
| Helper | What it does |
|---|---|
rbac_membership(role:, tenant:, overrides:, tenant_rows:, user:) | Builds a member of one role, with optional per-user overrides and tenant rows. |
set_tenant_permission(tenant, role:, key:, enabled:) | Writes one tenant row, whatever rows the tenant has. |
with_permission_strictness(strict) { ... } | Runs the block in one failure mode. true raises. false denies and reports. |
sql_statements { ... } | Returns the SQL that the block sends. Use it to prove the cache. |
Test files
| File | What it proves |
|---|---|
backend/domains/identity/test/models/permission_resolution_order_test.rb | The resolution order, the cache, the RLS reads, and the failure modes (R26). |
backend/domains/identity/test/rbac/resolver_oracle_test.rb | A hand-written oracle agrees with the resolver on three policies. |
backend/domains/identity/test/services/permissions/effective_permissions_test.rb | The resolver entry points. |
backend/domains/identity/test/requests/my_permissions_test.rb | The endpoint. |
backend/domains/identity/test/requests/permission_denied_code_test.rb | The permission_denied code. |
backend/domains/identity/test/rbac/catalog_key_wiring_test.rb | Keys wired to policies, and the loud failures. |
backend/domains/identity/test/rbac/identity_role_checks_test.rb | The identity role checks that moved to keys. |
backend/domains/identity/test/rbac/role_ceiling_test.rb | The invite and role change ceiling. |
backend/domains/identity/test/services/permission_management/sync_tenant_permissions_test.rb | The matrix save and its rules. |
backend/domains/identity/test/services/permission_management/normalize_tenant_permissions_test.rb | The tenant row cleanup. |
backend/domains/identity/test/migrations/add_rbac_catalog_keys_test.rb | The catalog migration, the carried revokes, and the seed copy. |
backend/domains/identity/test/requests/identifier_change_test.rb | The verified identifier change. |
Matrix, defaults, and guard tests
Follow these rules when you add a key or a policy action:
- Policy matrix. Each domain has
backend/domains/<domain>/test/policies/<domain>_policy_matrix_test.rb. It lists policy, action, and key. The test stubs the membership check, calls the action, and asserts which key the action asked for. It needs no database. An action with no key and no keep-list entry fails with its name. - Defaults.
backend/domains/identity/test/rbac/permission_defaults_test.rbstates the non-owner roles that pass each key with no overrides. It checks them through the real resolver. A separate case checks that an owner passes every active key. - Key behavior.
backend/domains/identity/test/rbac/key_behavior_test.rbruns one real policy action for each key, with no stubs. It checks one member that holds the key and one that does not. - Catalog agreement.
backend/domains/identity/test/rbac/catalog_agreement_test.rb(see section 4). - Defaults snapshot.
backend/domains/identity/test/rbac/permission_defaults_snapshot_test.rb(see section 5). - Role-check inventory.
backend/domains/identity/test/rbac/role_check_inventory_test.rb(see section 8). - Authorize coverage.
backend/domains/shared/test/requests/authorize_coverage_test.rb(see authorize coverage).
Commands
Backend (macOS needs a serial run):
cd backend && OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES PARALLEL_WORKERS=1 RAILS_ENV=test \
mise exec ruby@3.4.9 -- bundle exec rails test domains/identity/test/rbacFrontend:
cd frontend
pnpm --filter @kulcare/auth-identity test:run
pnpm --filter @kulcare/shared-services test:run
pnpm --filter @kulcare/shell test:runFrontend tests for this flow:
frontend/packages/auth-identity/src/__tests__/usePermissions.test.tsxfrontend/packages/auth-identity/src/__tests__/RequireAccess.test.tsxfrontend/packages/shared-services/src/domains/identity/__tests__/myPermissions.test.tsfrontend/apps/shell/src/__tests__/permissionsBridge.test.tsxfrontend/apps/shell/src/pages/account/__tests__/TenantPermissionsPage.test.tsx
13. The billing boundary
Billing permission keys are out of scope for this RBAC work (KD1). A separate billing branch owns them and merges later.
The billing keys are manage_practice_finance, view_practice_finance, view_bills, add_bills, edit_bills,
view_own_commissions, view_own_statement, and every key in the billing category.
backend/domains/billing/policies/billing_summary_policy.rb also used to check a non-catalog
:admin key by mistake; #2276 removed that check.
- Do not add, change, or wire a billing key in an RBAC change.
- Do not change a billing policy, the vendor payment onboarding gate, or the billing and commission gates in the reports app.
- The guard tests list billing keys on their allowlist with the reason "owned by the billing branch".
NormalizeTenantPermissionsskips billing keys, by category and by name.EffectivePermissions::KNOWN_BAD_KEYSlists non-catalog keys that deny and report, with no raise. It is empty: its only entry,adminfromBillingSummaryPolicy, was removed with #2276.- When the billing branch merges, its keys must pass the catalog agreement guard and the defaults snapshot. Its migrations that derive keys must obey the missing-row rule. Run this work's migrations before the billing branch's new migrations.