Kulcare Docs
Developer

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

PieceWhat it isWhere
authorize(record)Gate for one action. It raises Pundit::NotAuthorizedError (403) on a denial.Each controller action
<Record>PolicyThe rules. One predicate for each action.backend/domains/<domain>/policies/*_policy.rb
Shared::BasePolicyBase class: tenant helpers, permission helpers, deny by default.backend/domains/shared/policies/base_policy.rb
Identity::Permissions::EffectivePermissionsThe resolver. It computes the effective permission set of one membership.backend/domains/identity/services/permissions/effective_permissions.rb
pundit_userThe user that policies get (current_user).backend/domains/shared/controllers/base_controller.rb
rescue_from Pundit::NotAuthorizedErrorRenders the 403 with the code permission_denied or forbidden.backend/domains/shared/concerns/schema_error_handler.rb

BasePolicy helpers

HelperTrue 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
end

Rules:

  • A read predicate (show?, index?) needs tenant_member? or a view_* key.
  • A write predicate (create?, update?, destroy?) needs a key (manage_*, create_*) and record_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:

CheckWhy it stays a role check
TenantPermissionPolicy and the Roles & Permissions pageOnly 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::RoleCeilingNobody gives a role that ranks above their own.
Profile switch and the invite-role mappingThey follow the membership itself. No tenant key applies.
Admin invite recipient check and pending-invite resend matchingA 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 timeIt 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 roleThey show data. They do not gate an action.
admin_user? in TranscriptionSessionsControllerThe transcript admin view shows every transcript.
owns_practitioner_role? in PractitionerSchedulePolicyIt checks who owns the record.
Connect referrals persona view, Connect cross-tenant dashboard fan-out, useUserOwnedFacilitiesPersona views and cross-tenant views.
New-tenant creation in FacilitiesControllerThe tenant does not exist yet.
Notification recipient resolutionIt 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:

CallerSchedules they may read
No identified usernone — an authorization gate must not answer "yes" for a subject it cannot name
Tenant owner, or Kulcare platform adminevery practitioner
Holds manage_appointmentsevery 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 practitionertheir own only

Clinical::ScheduleScoped applies it, in two shapes, because the endpoints come in two shapes:

  • #scoped_schedule_practitioner_id for an action that takes a practitioner_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_id for 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? reads permission_membership.role, not user.practitioner. identity.practitioners has no tenant_id and 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-clause isPractitioner collapses to the same question, because GetAvailableProfilesService#get_facilities_for_role only populates a facility's practitioner_id / role_id on its practitioner branch.
  • 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 #show and 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_schedules key. None was added: the catalog already says this with manage_appointments (facility-wide) against manage_own_appointments (own schedule). Do not seed a key a policy can already express.
  • It restricts nobody by default. manage_appointments defaults 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/unavailabilities turns on view_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.

CaseTest and developmentProduction
A non-owner check with a key that is not in the catalogRaises Identity::Permissions::UnknownPermissionErrorDenies 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 errorRaisesDenies and reports. An owner still passes.
The RLS context of the caller names another tenantRaises EffectivePermissions::TenantContextMismatchReports, 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:

  1. Make sure that the action cannot use authorize or policy_scope.
  2. Write the reason in the entry. Say what protects the action.
  3. 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
end

This 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
end

Notes:

  • Set Shared::Current.tenant. The helpers read it. Without it, every check denies.
  • The test database has the catalog. seed_reference_permissions! in backend/test/test_helper.rb loads 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 authorize tests 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.

On this page