Kulcare Docs

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:


1. The model

PartWhat it isWhere
RoleOne of five membership roles: owner, admin, staff, practitioner, marketing. A member has one role in each tenant.Identity::UserMembership#role
Permission keyA 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)
AreaThe group a key belongs to, for example clinical or payouts. Stored in feature_category.Reference::Permission::AREAS
LevelThe lowest rung of the area's ladder that grants the key: view, add, manage_own, manage, manage_all.reference.permissions.access_level
AppDerived from the area, never stored, so the two cannot disagree.Reference::Permission::AREA_APPS
Access tableWhich apps exist, which areas sit in them, and which levels each area offers.Reference::AccessTable
Catalog sourceThe seed file. It is the single source of the catalog (KTD3).backend/db/seeds/reference/015_seed_permissions.rb
Tenant rowA change that a tenant made to one key for one role. enabled: true grants. enabled: false revokes.identity.tenant_permissions (Identity::TenantPermission)
Per-user overrideA grant or a revoke for one member.membership.permissions['permission_overrides']
ResolverThe 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 marketing role holds the Marketing area and nothing else. It is a job, not a narrower staff.

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:

PathCallerTenant status
Sign-up with the Facility Owner / Manager card (signed_up_as: 'facility')Identity::Users::ProfileOrchestrationService calls SetupService with draft: truedraft
The facility setup wizardIdentity::Facilities::WizardSetupService calls SetupService with draft: falseactive

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

PieceFile
Resolverbackend/domains/identity/services/permissions/effective_permissions.rb
Unknown key errorbackend/domains/identity/services/permissions/unknown_permission_error.rb
Invite and role ceilingbackend/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 endpointbackend/domains/identity/controllers/my_permissions_controller.rb
Matrix endpoints and their auditbackend/domains/identity/controllers/tenant_permissions_controller.rb
Matrix savebackend/domains/identity/services/permission_management/sync_tenant_permissions.rb
Tenant row cleanupbackend/domains/identity/services/permission_management/normalize_tenant_permissions.rb
Tenant row cleanup taskbackend/lib/tasks/rbac/normalize_tenant_permissions.rake
Invite guard and invite auditbackend/domains/identity/controllers/concerns/invite_guard.rb
Catalog model (CATEGORIES, ROLES)backend/domains/reference/models/permission.rb
Browser bridgefrontend/apps/shell/src/components/PermissionsBridge.tsx
Browser storefrontend/packages/auth-identity/src/store.ts
Browser hookfrontend/packages/auth-identity/src/hooks/usePermissions.ts
Browser gate componentfrontend/packages/auth-identity/src/components/RequireAccess.tsx
Matrix screenfrontend/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').keys
  • for_membership includes the per-user overrides of the member.
  • for_role gives 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_membership and for_role keep one instance for each request on Shared::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, because RlsContext.with changes that value in a request.
  • Ten checks for one membership in one request run the queries one time.
  • A save or destroy of a TenantPermission clears 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.
  • permissions is the sorted list of granted active keys. An owner gets every active key and owner: true.
  • invitable_roles comes from Identity::Permissions::RoleCeiling#invitable_roles. It is empty without manage_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 not permission_denied, so it cannot start a refetch loop.

The browser flow

PartWhat it doesWhere
myPermissionsQueryOptions, useMyPermissionsThe 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
PermissionsBridgeRuns 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
PermissionsErrorNoticeShows "Could not check your permissions. Retrying." after a failed load.Same file
Auth storeenabledPermissions, permissionsOwner, permissionsRole, permissionsLoading, permissionsErrorfrontend/packages/auth-identity/src/store.ts
usePermissionshasPermission, canAccess, hasAllPermissions, isOwner, role, isLoading, errorfrontend/packages/auth-identity/src/hooks/usePermissions.ts
RequireAccessRenders 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.
  • setMyPermissions drops 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 a kulcare-forbidden window event with the server's error code. On the code permission_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/auth package.

  • Use catalog keys (manage_users, view_patients). Do not use dotted names such as patient.create. The catalog keys are in PERMISSIONS (frontend/packages/auth-identity/src/constants/permissions.ts).

  • Use RequireAccess to show or hide a control. If you call hasPermission directly, check isLoading first. 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 pass mode="any":

    <RequireAccess permissions={APPOINTMENT_READ} mode="any" fallback={<UnauthorizedMessage />}>

    Gate the WRITES separately, at the control — hand the handler through as undefined when 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_KEYS and REPORTS_APP_KEYS list every key an area grants. A role at None holds none of them and a role at any other rung holds at least one, so canAccess(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, and Billing::BillingAddressPolicy checks manage_tax_identity on every write. The page reads canManageTaxIdentity from usePermissions(), not canManageBilling (practice finance) and not the role name. It used to enable the button for every admin, whom the server then refused (#2338). tax_identity is 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.

  • hasPermission returns true for every key when the server's owner flag is true.

  • Use role from usePermissions() 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.

  1. 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 to ALL_SEEDED_PERMISSIONS.
  2. Keep the section above the first top-level def in the file. NormalizeTenantPermissions.current_seed_defaults reads only the part before that def.
  3. Give feature_name, feature_category, access_level, description, and default_enabled_for_roles.
  4. Use an AREA from Reference::Permission::AREAS for feature_category, a LEVEL from Reference::Permission::ACCESS_LEVELS, and roles from Reference::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. payouts and tax_identity are the examples.
  5. Add display: false only for a key that the matrix must not show.
  6. Write a data migration that inserts the key with ON CONFLICT (feature_name) DO NOTHING. Give it a hard-coded copy of the definition.
  7. Make the migration down a documented no-op. A delete cascades to every tenant row of the key.
  8. Add a migration test that asserts the migration copy equals the seed section.
  9. If a policy ORs the new key with a current key, copy the revokes of the current key (see section 6).
  10. 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.
  11. Add the key and its default roles to the defaults snapshot, backend/domains/identity/test/rbac/permission_defaults_snapshot.yml.
  12. Add at least one backend check with a literal key: has_permission?(:your_key).
  13. If a frontend gate uses the key, add it to PERMISSIONS in frontend/packages/auth-identity/src/constants/permissions.ts.
  14. Add the key to the policy matrix test of its domain and to the defaults test.
  15. 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.
  16. 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).

  1. Change default_enabled_for_roles in the seed.
  2. Write a data migration that names the key and sets its new default_enabled_for_roles.
  3. If some tenants must keep the old access, write their tenant rows in the same migration.
  4. Update the defaults snapshot, backend/domains/identity/test/rbac/permission_defaults_snapshot.yml, in the same commit.
  5. Run the snapshot test. It fails when the seed and the snapshot differ.
  6. 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, true when the old key's default_enabled_for_roles includes the role, and false when 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_reference makes the two equal.
  • It snapshots the effective access of every tenant, role, and key before and after the delete. On any difference it raises AccessChanged and 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 is deploy:normalize_tenant_permissions in backend/config/deploy.rb. The hooks are in backend/config/deploy/staging.rb and backend/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:

  1. Write a characterization test of the roles that pass today.
  2. Add a key whose defaults are those roles, or use a current key with those defaults.
  3. Replace the check with has_permission?(:key).
  4. Update the inventory line.
  5. 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 code permission_denied.
  • A staff invite with staff_role: admin makes an admin membership. The ceiling compares the admin set.
  • StaffInvite::MEMBERSHIP_ROLE_BY_STAFF_ROLE decides which membership role an accepted invite writes. A role added to RoleCeiling::INVITABLE_ROLES must 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. marketing did exactly that: the screen offered it, the ceiling vetted marketing, and the invite wrote staff. That gave a marketing hire the whole staff catalog. staff_invite_membership_role_test.rb now 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 writes staff, with the reason. practitioner is one, because PractitionerInvite carries the real practitioner invite.
  • GET /identity/my/permissions returns invitable_roles from 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.

PartWhere
Endpointsbackend/domains/identity/controllers/membership_permissions_controller.rb
Who may read and writebackend/domains/identity/policies/membership_permission_policy.rb
The write itselfbackend/domains/identity/services/permission_management/sync_membership_permissions.rb
The screenfrontend/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, false or null.
  • Every save, and every refused save, writes an Audit::ActivityLog row 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:

EndpointWhat it does
POST /identity/reauth_codesFor 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_codeSends 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/confirmApplies the change when the code matches.
  • Code: backend/domains/identity/controllers/identifier_changes_controller.rb and backend/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 shared users.verification_code column, 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 sends current_code instead of current_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.

EndpointWhat it does
POST /identity/email_verification/request_codeSends a one-time code to the CURRENT email. No body. Returns { email }.
POST /identity/email_verification/confirmWith { 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.rb and backend/domains/identity/services/email_verifications/.
  • The code uses the same mechanism as the identifier change: Identity::BoundCode under the email_verification key, 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_verified run 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).

EndpointWhoWhat it does
POST /identity/facilities/:facility_id/contact_verification/request_codeA member with manage_facilities on a facility of the session tenantWith { 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/confirmThe sameWith the same body and code. Marks the value verified with method: code.
POST /identity/admin/facilities/:facility_id/contact_verification/request_codeA platform adminSends a code to the facility. Only a practice user can enter it.
POST /identity/admin/facilities/:facility_id/contact_verification/verify_by_callA platform adminPhone 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.rb and backend/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 use Identity::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. The value of a phone request is the normalized value 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, key facility_contact_verification). A code that a platform admin sends lives in identity.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 under rate_limit:facility_contact:. They fail closed. The daily caps per channel are FACILITY_CONTACT_SMS_DAILY_CAP (default 2000) and FACILITY_CONTACT_EMAIL_DAILY_CAP (default 5000).
  • The switch FACILITY_CONTACT_VERIFICATION_ENFORCED turns the patient gate on. It is on by default, except in production. After each change of it, run bin/rails typesense:reindex_collection[facilities] and bin/rails typesense:reindex_collection[practitioners].
  • Operations: bin/rails identity:facility_contacts:coverage (read only) and bin/rails identity:facility_contacts:notify_unverified (one email to each manage_facilities member 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_changedTenantPermissionsController#update after a save. It lists granted and revoked keys.
role_permissions_change_rejectedTenantPermissionsController, 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_createdInviteGuard, for each invite.
invite_rejectedInviteGuard, for each rejected invite, with the reason and the keys that the inviter lacks.
Identifier changeIdentity::IdentifierChanges::ConfirmService, for each confirmed change.

12. Tests

Helpers

RbacHelpers (backend/test/support/rbac_helpers.rb):

HelperWhat 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

FileWhat it proves
backend/domains/identity/test/models/permission_resolution_order_test.rbThe resolution order, the cache, the RLS reads, and the failure modes (R26).
backend/domains/identity/test/rbac/resolver_oracle_test.rbA hand-written oracle agrees with the resolver on three policies.
backend/domains/identity/test/services/permissions/effective_permissions_test.rbThe resolver entry points.
backend/domains/identity/test/requests/my_permissions_test.rbThe endpoint.
backend/domains/identity/test/requests/permission_denied_code_test.rbThe permission_denied code.
backend/domains/identity/test/rbac/catalog_key_wiring_test.rbKeys wired to policies, and the loud failures.
backend/domains/identity/test/rbac/identity_role_checks_test.rbThe identity role checks that moved to keys.
backend/domains/identity/test/rbac/role_ceiling_test.rbThe invite and role change ceiling.
backend/domains/identity/test/services/permission_management/sync_tenant_permissions_test.rbThe matrix save and its rules.
backend/domains/identity/test/services/permission_management/normalize_tenant_permissions_test.rbThe tenant row cleanup.
backend/domains/identity/test/migrations/add_rbac_catalog_keys_test.rbThe catalog migration, the carried revokes, and the seed copy.
backend/domains/identity/test/requests/identifier_change_test.rbThe 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.rb states 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.rb runs 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/rbac

Frontend:

cd frontend
pnpm --filter @kulcare/auth-identity test:run
pnpm --filter @kulcare/shared-services test:run
pnpm --filter @kulcare/shell test:run

Frontend tests for this flow:

  • frontend/packages/auth-identity/src/__tests__/usePermissions.test.tsx
  • frontend/packages/auth-identity/src/__tests__/RequireAccess.test.tsx
  • frontend/packages/shared-services/src/domains/identity/__tests__/myPermissions.test.ts
  • frontend/apps/shell/src/__tests__/permissionsBridge.test.tsx
  • frontend/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".
  • NormalizeTenantPermissions skips billing keys, by category and by name.
  • EffectivePermissions::KNOWN_BAD_KEYS lists non-catalog keys that deny and report, with no raise. It is empty: its only entry, admin from BillingSummaryPolicy, 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.

On this page