Authorization (Pundit policies)
This guide tells you how the backend decides who can do what. Read it before you add a
controller action that changes tenant data. Read it before you write or change a *Policy.
For the RBAC model (roles, permission keys, the resolver, how to add a key), read
identity/roles-and-permissions.md.
Companion reading:
row-level-security.md. RLS and Pundit are two different layers. Both must hold.
- RLS (Postgres,
Shared::Tenancy) controls tenant isolation. It decides if a connection can see a row. The database enforces it.- Pundit policies (this guide) control authorization. They decide if a user can do an action on a row that they can see. The app enforces them.
RLS stops tenant A from a read or a change of the rows of tenant B. RLS does not stop a member of tenant A with few permissions from a change to other rows of tenant A. The policy does that. A controller that skips the policy but keeps RLS is still a hole.
1. The model
Each controller action calls authorize(record) or policy_scope(scope). Pundit finds
<Record>Policy and calls the predicate for the action (update?, show?). Policies extend
Shared::BasePolicy. pundit_user is current_user (Shared::BaseController). The base
policy denies every action, so a policy must grant each action explicitly.
A policy gates an action on a permission key with has_permission?(:key). The key is a row of
the catalog (reference.permissions). One resolver, Identity::Permissions::EffectivePermissions,
gives the answer for each member. The browser gets the same answer from
GET /identity/my/permissions, so the UI and the server agree.
2. The pieces
| Piece | What it is | Where |
|---|---|---|
authorize(record) | Gate for one action. It raises Pundit::NotAuthorizedError (403) on a denial. | Each controller action |
<Record>Policy | The rules. One predicate for each action. | backend/domains/<domain>/policies/*_policy.rb |
Shared::BasePolicy | Base class: tenant helpers, permission helpers, deny by default. | backend/domains/shared/policies/base_policy.rb |
Identity::Permissions::EffectivePermissions | The resolver. It computes the effective permission set of one membership. | backend/domains/identity/services/permissions/effective_permissions.rb |
pundit_user | The user that policies get (current_user). | backend/domains/shared/controllers/base_controller.rb |
rescue_from Pundit::NotAuthorizedError | Renders the 403 with the code permission_denied or forbidden. | backend/domains/shared/concerns/schema_error_handler.rb |
BasePolicy helpers
| Helper | True when |
|---|---|
tenant_member? | The user has an active membership in Current.tenant. |
has_permission?(:key) | The resolver grants the key to the user's membership in Current.tenant. An owner passes every key. |
has_any_permission?(*keys) | At least one key passes. |
has_all_permissions?(*keys) | Every key passes. |
record_in_tenant? | record.tenant_id == Current.tenant.id. |
tenant_admin?, tenant_owner?, tenant_staff? | The membership role is owner or admin, owner, or one of owner, admin, and staff. Use these only for the keep list in section 4. |
The permission helpers live in Shared::BasePolicy::PermissionChecks. Policies and their
Scope classes both include it. The module reuses Shared::Current.membership when it is the
user's active membership in the policy tenant. So a request that authorizes many records looks
up the membership one time. The resolver caches the effective set for the request.
3. Write a policy
class Identity::OrganizationPolicy < Shared::BasePolicy
def show? = platform_admin? || (tenant_member? && record_in_tenant?)
def update? = platform_admin? || (has_permission?(:manage_organizations) && record_in_tenant?)
class Scope < Shared::BasePolicy::Scope
def resolve
return scope.all if platform_admin?
tenant_member? ? scope.where(tenant_id: current_tenant.id) : scope.none
end
end
endRules:
- A read predicate (
show?,index?) needstenant_member?or aview_*key. - A write predicate (
create?,update?,destroy?) needs a key (manage_*,create_*) andrecord_in_tenant?. - Give the key as a literal symbol or string. Do not build a key at runtime
(
"manage_#{type}"). The guard tests read keys from the source, and they fail on a key that the code builds at runtime. - Use only an active catalog key. To add a key, follow Add a permission key.
- A scope that denies returns
scope.none. It does not raise.
4. Permission first, and the keep list
Gate an action on a permission key, not on a role. A tenant can change the roles of a key on the Roles & Permissions screen. A tenant cannot change a role check. So a role check that gates an action ignores the tenant's matrix.
Do this:
def update? = has_permission?(:manage_vendors) && record_in_tenant?Do not do this:
def update? = tenant_admin? && record_in_tenant?When you replace a role check with a key, the defaults of the key must be the roles that pass
the check today. Write a characterization test of the allowed roles before the change (see
section 9). Example: backend/domains/identity/test/rbac/identity_role_checks_test.rb.
Some checks stay role-based, because they are structural and not a tenant choice. This is the keep list:
| Check | Why it stays a role check |
|---|---|
TenantPermissionPolicy and the Roles & Permissions page | Only owners and admins edit the matrix. An admin keeps it when an owner revokes manage_users from admins (KD4). |
Invite ROLE_HIERARCHY in Identity::Permissions::RoleCeiling | Nobody gives a role that ranks above their own. |
| Profile switch and the invite-role mapping | They follow the membership itself. No tenant key applies. |
| Admin invite recipient check and pending-invite resend matching | A forwarded admin invite cannot grant its role to another recipient. Resend matching selects the invite type, not the caller's authority. Server permissions still apply. |
| Referral source owner agreement time | It establishes historical affiliation from membership and practitioner-role dates. An owner need not have a staff invite. It does not grant an action permission. |
| Display fields that show the role | They show data. They do not gate an action. |
admin_user? in TranscriptionSessionsController | The transcript admin view shows every transcript. |
owns_practitioner_role? in PractitionerSchedulePolicy | It checks who owns the record. |
Connect referrals persona view, Connect cross-tenant dashboard fan-out, useUserOwnedFacilities | Persona views and cross-tenant views. |
New-tenant creation in FacilitiesController | The tenant does not exist yet. |
| Notification recipient resolution | It selects who gets a message. It does not gate an action. |
Clinic settings pricing tabs (ServicesTab.tsx, ProceduresTab.tsx under frontend/apps/cms/src/features/settings/clinicSettings/components/tabs/) | No catalog key fits. |
Message-credit gates (msg_credit_order_policy.rb, msg_credit_plan_policy.rb) | They touch Cashfree payments. Their owner is not decided. |
Billing gates, and VendorPolicy#onboard? (vendor payment onboarding) | The billing branch owns billing permissions (KD1). |
Each direct role check in the backend and the frontend has a line in
backend/domains/identity/test/rbac/role_check_inventory.yml. 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 add a role check from the keep list, add its line in the same
commit.
Row scope is not an action gate
An action key answers "may you do this at all". It does not answer "to whose rows". When an
action is open to a member but each member may only reach their own rows, that second question is
row scope. Put it in a Pundit Scope, or in a policy predicate the controller consults, and keep
the action's authorize call as it is. Both still run.
Clinical::ScheduleVisibility is the worked example (#2658). Reading a queue still asks
QueuePolicy#show?, and the calendar still asks AppointmentPolicy#index? — unchanged. On top of
that, view_all_schedules? decides whose schedule the answer may cover:
| Caller | Schedules they may read |
|---|---|
| No identified user | none — an authorization gate must not answer "yes" for a subject it cannot name |
| Tenant owner, or Kulcare platform admin | every practitioner |
Holds manage_appointments | every practitioner |
| Not a practitioner member of this tenant (front desk, admin staff) | every practitioner — they work the whole facility and have no own schedule |
| Any other practitioner | their own only |
Clinical::ScheduleScoped applies it, in two shapes, because the endpoints come in two shapes:
#scoped_schedule_practitioner_idfor an action that takes apractitioner_id—QueueController#practitioner_queue,QueueController#rounds,AppointmentsController#calendar_summary,AppointmentsController#time_slots. A restricted caller who sends none is narrowed to their own; one who names somebody else gets a 403 rather than a quiet substitution.#restricted_schedule_practitioner_idfor an action that takes none —QueueController#show, the whole-facility queue. It narrows a restricted caller and leaves everyone else exactly as they were, so a permissive caller gains no new filter.
The CMS twin is frontend/apps/cms/src/hooks/useRestrictedPractitioner.ts.
Four things to know before you reuse it:
- Ask the membership, not the practitioner record.
practitioner_member?readspermission_membership.role, notuser.practitioner.identity.practitionershas notenant_idand no RLS, so that record is platform-wide: a front-desk clerk at this tenant who also holds a practitioner record at another would have been restricted here while the UI still offered them every doctor — 403s and an empty calendar for someone the rule was never about. The frontend's three-clauseisPractitionercollapses to the same question, becauseGetAvailableProfilesService#get_facilities_for_roleonly populates a facility'spractitioner_id/role_idon itspractitionerbranch. - Narrow the endpoint the product calls, not the one that looks canonical. The first cut of
this gated
#practitioner_queue, which no app calls at all. The CMS queue screen reads the whole-facility#showand filters by doctor in the browser, so every colleague's patient list was still being delivered to a restricted practitioner on the very page the restriction exists for. A green policy test is not evidence that a payload stopped moving — check which route the frontend actually fetches. - It is not a new permission. #2658 asked for a
view_all_practitioner_scheduleskey. None was added: the catalog already says this withmanage_appointments(facility-wide) againstmanage_own_appointments(own schedule). Do not seed a key a policy can already express. - It restricts nobody by default.
manage_appointmentsdefaults to owner, admin, staff AND practitioner. The rule bites only once a tenant revokes Appointments: Manage from the practitioner role on the Roles & Permissions screen, which is the configuration the CMS gate was built for. A green test here is not a claim that practitioners are locked down out of the box.
A refusal is reported through Rails.error.report (ids only, no PHI) before it is raised. The
Pundit handler renders a 403 and logs nothing, and Audit::ActivityInstrumentation only watches
2xx writes, so without that call a caller walking other doctors' ids would leave no trace at all.
Two neighbours are deliberately not scoped this way, and both are still readable by any tenant member:
GET /clinical/appointments(#index) serves a patient's visit history as well as the calendar, so narrowing it to the caller's own practitioner would hide other doctors' visits from a patient's record. The month and week counts are closed; the day rows on the same screen ride this route.GET /clinical/practitioners/:practitioner_id/unavailabilitiesturns onview_practitioners/manage_schedules, a different key pair from the one #2658 reasons about. "When is Dr B off" is schedule data of the same kind.
Both exposures are older than #2658 and each needs its own decision.
Row scope goes where the payload is built
A policy predicate only helps if something consults it. When the narrowing has to reach a query,
pass the answer down to the service rather than filtering after the fact —
Clinical::Encounters::TodaysQueueService takes a practitioner_id and applies it inside
practitioners_with_appointments. That parameter replaced a current_user: one that was assigned
and never read: a dead parameter shaped exactly like the narrowing the endpoint did not have, which
is most of the reason the hole survived this long. If you add a seam like that, make it do
something or delete it.
5. Loud failures
A permission bug must fail a test. It must not deny in silence.
| Case | Test and development | Production |
|---|---|---|
| A non-owner check with a key that is not in the catalog | Raises Identity::Permissions::UnknownPermissionError | Denies and reports |
A key in EffectivePermissions::KNOWN_BAD_KEYS (empty today; admin left BillingSummaryPolicy with #2276) | Denies and reports. No raise. | Denies and reports |
A hidden key (display: false) | Denies. No report. | Denies. No report. |
| An internal error in the check, for example a database error | Raises | Denies and reports. An owner still passes. |
| The RLS context of the caller names another tenant | Raises EffectivePermissions::TenantContextMismatch | Reports, then reads the rows of the membership's own tenant |
EffectivePermissions.strict? selects the mode. It follows Rails.env.local?. A test can
force a mode with with_permission_strictness(false) { ... } from RbacHelpers.
Reports go through Rails.error.report(error, handled: true, context: {...}), and AppSignal
collects them. The context holds only IDs: tenant, membership, and key. Never put a name, an
email, or a phone number in the context.
The 403 code
A 403 from a permission denial has the code permission_denied. Every other Pundit 403 has
the code forbidden. UserMembership#has_permission? records each denied key on
Shared::Current.denied_permissions, and render_authorization_error reads that record. The
browser refetches its permissions when it gets permission_denied, so do not change this code
string. A controller that denies outside Pundit on a key must send the same code
(Shared::Concerns::SchemaErrorHandler::PERMISSION_DENIED_CODE). Example:
backend/domains/identity/controllers/concerns/invite_guard.rb.
6. Authorize coverage
Each controller action must call authorize or policy_scope. An action that calls neither
needs an entry in backend/config/authorize_allowlist.yml. The entry key is
controller#action, and the entry must give a reviewed reason.
An after_action in Shared::BaseController checks pundit_policy_authorized? or
pundit_policy_scoped? after each action. When an action has no call and no allowlist entry,
the check raises in test and reports in production. It never raises in production, because a
raise after the response body exists gives a 500 error.
A route sweep test, backend/domains/shared/test/requests/authorize_coverage_test.rb, calls
every route. It also finds each route whose controller does not inherit Shared::BaseController,
and it requires an allowlist entry for that route. A second test fails on an allowlist entry
that matches no action.
To add an allowlist entry:
- Make sure that the action cannot use
authorizeorpolicy_scope. - Write the reason in the entry. Say what protects the action.
- Get a security review for each entry on a patient, clinical, or identity controller.
An action with no record yet can check the key on the membership before the record exists, for
example Shared::Current.membership&.has_permission?('manage_facilities'). That is an explicit
check. Give it an allowlist entry with that reason.
7. The superadmin short-circuit
Shared::BasePolicy has no platform admin bypass. record_in_tenant? is strict, and
has_permission? needs a membership in Current.tenant. So a naive policy denies a superadmin
who works across tenants, because the record's tenant is not the admin's session tenant.
A tenant-scoped resource can have a cross-tenant controller path, for example
FacilitiesController#scoped_facilities with unscoped_by_tenant. Its policy must then start
each action with platform_admin?. Its Scope must return scope.all for a platform admin.
Without this, a new policy locks superadmins out of their cross-tenant work.
OrganizationPolicy and FacilityPolicy use this pattern.
8. Anti-pattern: the no-op authorize stub
Do not define a local authorize that does nothing:
# WRONG. This turns off ALL authorization in this controller.
def authorize(resource, action = nil)
# TODO
endThis method hides Pundit::Authorization#authorize. Each authorize @record call then does
nothing, and the policy never runs. The controller looks authorized, and RLS still blocks
cross-tenant access, so a quick test passes. But any member of the tenant can then do admin-only
changes in that tenant. If you find a stub like this, remove it and use Pundit.
9. Test policies (Minitest)
Backend domain tests use Minitest. Do not add new RSpec specs under backend/domains.
Include RbacHelpers from backend/test/support/rbac_helpers.rb. rbac_membership builds a
member of one role in one tenant, with optional per-user overrides and optional tenant rows.
# frozen_string_literal: true
require 'test_helper'
class Identity::OrganizationPolicyTest < ActiveSupport::TestCase
include RbacHelpers
setup do
@tenant = create(:identity_tenant)
Shared::Current.tenant = @tenant
@organization = create(:identity_organization, tenant: @tenant)
end
teardown do
Shared::Current.reset
end
def update?(membership)
Identity::OrganizationPolicy.new(membership.user, @organization).update?
end
test 'update? follows manage_organizations' do
assert update?(rbac_membership(role: 'admin', tenant: @tenant))
assert_not update?(rbac_membership(role: 'staff', tenant: @tenant))
end
test 'a tenant row and an override change the answer' do
assert_not update?(rbac_membership(role: 'admin', tenant: @tenant,
tenant_rows: { manage_organizations: false }))
assert update?(rbac_membership(role: 'staff', tenant: @tenant,
overrides: { manage_organizations: true }))
end
test 'an owner passes with the key revoked' do
assert update?(rbac_membership(role: 'owner', tenant: @tenant,
overrides: { manage_organizations: false }))
end
endNotes:
- Set
Shared::Current.tenant. The helpers read it. Without it, every check denies. - The test database has the catalog.
seed_reference_permissions!inbackend/test/test_helper.rbloads the seed, so the defaults apply in tests. - For each action, test one member that passes and one member that fails. Also test that a tenant row or an override changes the answer.
- For a superadmin, stub
user.platform_admin?to return true. - A request test that stubs
authorizetests the action body only. The policy test proves the authorization.
Each domain also has a policy matrix test,
backend/domains/<domain>/test/policies/<domain>_policy_matrix_test.rb. It lists each
permission-driven action with the key it checks. When you add a policy action, add its row. See
Tests for the matrix, defaults, and guard tests.
Examples to read:
backend/domains/identity/test/rbac/identity_role_checks_test.rb: characterization tests and key behavior.backend/domains/identity/test/rbac/catalog_key_wiring_test.rb: keys wired to policies, and the loud failures.backend/domains/identity/test/models/permission_resolution_order_test.rb: the resolution order.