Kulcare Docs

Permission Management API Reference

Identity domain API for managing tenant permissions and role-based access control

Base Information

PropertyValue
Base URL/identity
AuthenticationBearer JWT Token
Content-Typeapplication/json

Endpoints Overview

MethodEndpointDescription
GET/permissionsList all system permissions with defaults
GET/tenants/:tenant_id/permissionsList tenant-specific permission overrides
PUT/tenants/:tenant_id/permissionsUpdate permissions for a specific role

Important: How Permissions Work

The system uses a cascading permission model:

  1. System Defaults (GET /permissions): All available permissions with their default role assignments
  2. Tenant Overrides (GET /tenants/:tenant_id/permissions): Custom permissions set by the tenant

Frontend Usage Pattern: To show effective permissions in the UI:

  1. Call GET /permissions to get all available permissions with defaults
  2. Call GET /tenants/:tenant_id/permissions to get tenant overrides
  3. Merge them: if a permission has an override, use it; otherwise use the system default

1. List System Permissions

Get all available system permissions with their default role assignments. Used to show the UI a list of all possible permissions.

Request

GET /api/v1/identity/permissions

Query Parameters

ParameterTypeRequiredDescription
categorystringNoFilter by permission category (appointments, patients, billing, care_plans, reports, settings)
displaybooleanNoFilter by display status (true/false)

Example Request

curl -X GET "https://api.example.com/api/v1/identity/permissions?display=true" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json"

Response (200 OK)

{
  "data": [
    {
      "id": "019c6f7d-0b4c-7d62-8952-d788061ca293",
      "created_at": "2026-02-18T06:43:11Z",
      "updated_at": "2026-02-18T06:43:11Z",
      "feature_name": "manage_appointments",
      "feature_category": "appointments",
      "access_level": "manage",
      "display": true,
      "default_enabled_for_roles": ["owner", "admin", "staff"]
    },
    {
      "id": "019c6f7d-0b40-76a1-9ecd-643a57b5a71a",
      "created_at": "2026-02-18T06:43:11Z",
      "updated_at": "2026-02-18T06:43:11Z",
      "feature_name": "view_patients",
      "feature_category": "clinical",
      "access_level": "view",
      "display": true,
      "default_enabled_for_roles": ["owner", "admin", "staff", "practitioner"]
    }
  ],
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Response Fields

FieldTypeDescription
idUUIDUnique identifier for the permission
created_atdatetimeWhen the permission was created (ISO 8601)
updated_atdatetimeWhen the permission was last updated (ISO 8601)
feature_namestringPermission identifier (e.g., "manage_appointments", "view_patients")
feature_categorystringThe AREA the key belongs to. See the note below.
access_levelstringThe level the key sits at in its area: view, add, manage_own, manage, manage_all
displaybooleanWhether to show in UI
default_enabled_for_rolesarrayRoles that have this permission by default

feature_category changed meaning, and kept its name (#2421). It used to hold a loose grouping: appointments, patients, medical_records, billing, reports, settings, care_plans, packages, audit_trail, ai, growth, and four values no key ever used. It now holds an AREA, which is the unit an owner sets access by: one of clinical, appointments, patient_billing, marketing, my_earnings, practice_finance, revenue_share, payouts, tax_identity, team_settings, subscription.

The field name did not change, so a consumer that matches on the old values keeps parsing the response and silently matches nothing. The value set is the breaking part. billing is the one to look at first: it split into patient_billing (the patient's bill at the front desk) and practice_finance (the practice's own money), which are unrelated subjects that shared a name.

Read the area from this field and the level from access_level. Do not derive either from the key name.


2. List Tenant Permission Overrides

Get tenant-specific permission customizations (overrides). Note: This only returns permissions that have been customized by the tenant. If no overrides exist, returns an empty array.

Request

GET /api/v1/identity/tenants/:tenant_id/permissions

Path Parameters

ParameterTypeRequiredDescription
tenant_idUUIDYesThe tenant/clinic ID

Query Parameters

ParameterTypeRequiredDescription
displaybooleanNoFilter by display status (true/false)

Example Request

curl -X GET "https://api.example.com/api/v1/identity/tenants/550e8400-e29b-41d4-a716-446655440000/permissions" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json"

Response (200 OK)

{
  "data": [
    {
      "role": "owner",
      "permissions": [
        {
          "id": "tenant-permission-uuid",
          "permission_id": "019c6f7d-0b4c-7d62-8952-d788061ca293",
          "permission_name": "manage_appointments",
          "permission_category": "appointments",
          "enabled": true,
          "is_custom": true,
          "default_for_roles": ["owner", "admin", "staff"]
        }
      ]
    },
    {
      "role": "admin",
      "permissions": [
        {
          "id": "tenant-permission-uuid",
          "permission_id": "019c6f7d-0b40-76a1-9ecd-643a57b5a71a",
          "permission_name": "view_patients",
          "permission_category": "patients",
          "enabled": true,
          "is_custom": true,
          "default_for_roles": ["owner", "admin", "staff", "practitioner"]
        }
      ]
    }
  ],
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Response Fields

FieldTypeDescription
data[].rolestringRole name (owner, admin, staff, practitioner)
data[].permissions[]arrayList of customized permissions for this role
permissions[].idUUIDTenant permission override ID
permissions[].permission_idUUIDSystem permission ID
permissions[].permission_namestringPermission identifier
permissions[].permission_categorystringCategory grouping
permissions[].enabledbooleanWhether permission is enabled for this role
permissions[].is_custombooleanAlways true - indicates this is a tenant override
permissions[].default_for_rolesarraySystem default roles

Empty Response (No Overrides)

{
  "data": [],
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

3. Update Tenant Permissions

Update which permissions are enabled for a specific role. This is a sync operation - it replaces all existing permissions for that role with the new list.

Request

PUT /api/v1/identity/tenants/:tenant_id/permissions

Path Parameters

ParameterTypeRequiredDescription
tenant_idUUIDYesThe tenant/clinic ID

Request Body

ParameterTypeRequiredDescription
rolestringYesRole to update (owner, admin, staff, practitioner)
permission_idsarrayYesArray of permission UUIDs to enable

Example Request

curl -X PUT "https://api.example.com/api/v1/identity/tenants/550e8400-e29b-41d4-a716-446655440000/permissions" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "role": "staff",
    "permission_ids": [
      "019c6f7d-0b4c-7d62-8952-d788061ca293",
      "019c6f7d-0b40-76a1-9ecd-643a57b5a71a"
    ]
  }'

Response (200 OK)

{
  "data": {
    "message": "Permissions updated successfully"
  },
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Error Responses

StatusDescription
401 UnauthorizedMissing or invalid token
403 ForbiddenUser doesn't have permission to manage permissions
404 Not FoundTenant not found, or tenant_id is not the tenant the session acts in
422 Unprocessable ContentAn invalid role, or a permission_ids key from an owner-only area

An owner-only area belongs to one named person, so no role may hold it. The areas are subscription, payouts and tax_identity. The server refuses the WHOLE request and writes nothing, so a request that mixes one owner-only key with valid keys saves none of them. The owner gets the same refusal: the rule is about what a ROLE may hold, not about who asks.

Error Example (422, owner-only area)

{
  "data": null,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "payouts belongs to the owner alone and cannot be given to a role",
    "details": [
      { "field": null, "message": "payouts belongs to the owner alone and cannot be given to a role" }
    ]
  },
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Error Example (403)

{
  "data": null,
  "error": {
    "code": "forbidden",
    "message": "You are not authorized to perform this action",
    "details": [
      { "field": "policy", "message": "Identity::TenantPermissionPolicy" },
      { "field": "query", "message": "index?" }
    ]
  },
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Error Example (404)

{
  "data": null,
  "error": {
    "code": "TENANT_NOT_FOUND",
    "message": "Tenant not found",
    "details": [
      { "field": "resource_type", "message": "Tenant" },
      { "field": "id", "message": "550e8400-e29b-41d4-a716-446655440000" }
    ]
  },
  "meta": {
    "request_id": "f75d3cc5-b994-4fdd-83a2-ba8206207acb"
  }
}

Complete Permission Reference

All 27 available permissions in the system with their IDs:

Appointments

Permission NameIDDefault Roles
manage_appointments019c6f7d-0b4c-7d62-8952-d788061ca293owner, admin, staff
manage_own_appointments019c6f7d-0b6a-7562-8470-dd9ed2bc537dpractitioner
view_own_schedule019c6f7d-0b6c-75fb-af10-0a6c5aa8a759practitioner

Billing

Billing is three separate families, not one (#2277, #2274). manage_practice_finance used to gate all three, so granting someone the ability to settle a payout also let them raise a patient bill and cancel the Kulcare subscription. Pick the family by asking whose money is this — the patient's, the practice's, or Kulcare's. See Billing domain architecture.

Patient bills — the front desk's and the clinician's work.

Permission NameDefault Roles
view_billsowner, admin, practitioner
add_billsowner, admin, practitioner
edit_billsowner, admin, practitioner

Practice finance — commissions, revenue share, payouts, P&L.

Permission NameDefault Roles
view_practice_financeowner, admin
manage_practice_financeowner, admin

Kulcare subscription — the tenant's own plan, subscription invoices, payment methods and module add-ons. Deliberately narrower than the other two: owner only. An admin who should be able to change the plan is granted it at the role level, per tenant. has_permission? short-circuits on role_owner?, so an owner holds these with no row at all.

Permission NameDefault Roles
view_subscriptionowner
manage_subscriptionowner

The ID columns elsewhere in this document are environment-specific — reference.permissions ids are generated per database, so the values listed will not match your environment. Match on feature_name, which is unique and stable; the seed at backend/db/seeds/reference/015_seed_permissions.rb is the source of truth for the default roles above.

Care Plans

Permission NameIDDefault Roles
create_orders019c6f7d-0b5f-772f-9d14-a58fb9c81718owner, admin, practitioner
manage_encounters019c6f7d-0b49-745b-9a70-0fbab626fd27owner, admin, practitioner
manage_observations019c6f7d-0b51-7cb5-95a4-94cb83501b4aowner, admin, practitioner
manage_orders019c6f7d-0b62-768f-8c4d-14e06680913downer, admin
manage_prescriptions019c6f7d-0b5a-70e4-8699-a96612653e4downer, admin
prescribe019c6f7d-0b57-7f93-832a-d7163ee4ff1downer, admin, practitioner
view_encounters019c6f7d-0b46-797f-b28d-2744c656aecbowner, admin, staff, practitioner
view_observations019c6f7d-0b4f-7446-a041-ba692a8f1af4owner, admin, staff, practitioner
view_orders019c6f7d-0b5c-7b60-b560-ec3ed4a935d4owner, admin, staff, practitioner
view_prescriptions019c6f7d-0b54-7877-993d-99683ef2e92fowner, admin, staff, practitioner

Patients

Permission NameIDDefault Roles
manage_own_patients019c6f7d-0b6f-7dee-9ee2-54fb46c8359fpractitioner
manage_patients019c6f7d-0b43-7ce4-b566-bfba806c9ffdowner, admin, practitioner
view_patients019c6f7d-0b40-76a1-9ecd-643a57b5a71aowner, admin, staff, practitioner

Reports

Permission NameIDDefault Roles
view_analytics019c6f7d-0b3e-796e-8c8e-74c8e6e3250aowner, admin
view_reports019c6f7d-0b3b-70f6-a3a5-36c647196aabowner, admin

Settings

Permission NameIDDefault Roles
manage_facilities019c6f7d-0b25-7dd6-be7a-41d776538040owner, admin
manage_organizations019c6f7d-0b21-798b-b5c1-acef2eb6cbc1owner, admin
manage_practitioners019c6f7d-0b2b-725c-8a9d-7d870a7a9e97owner, admin, practitioner
manage_services019c6f7d-0b2f-7b56-b303-10ccb8320c08owner, admin, practitioner
manage_settings019c6f7d-0b36-7642-9bb1-030b58f235c8owner, admin, practitioner
manage_tenant019c6f7d-0b03-7be4-94ff-f84780fab103owner
manage_users019c6f7d-0b28-7934-81f7-0d080121456eowner, admin
view_practitioners019c6f7d-0b67-715d-94c4-f99184d6c080owner, admin, staff
view_services019c6f7d-0b64-7307-aef2-fdf41067a568owner, admin, staff, practitioner

Roles

RoleDescription
ownerFull access, can manage everything
adminAdministrative access, can manage most settings
staffFront desk or support staff
practitionerDoctors and healthcare providers

Authorization

Required Permissions

EndpointPermission Required
GET /permissionsAny authenticated user
GET /tenants/:tenant_id/permissionsOwner or admin of the tenant in the URL, which must be the session tenant
PUT /tenants/:tenant_id/permissionsOwner of the tenant in the URL, which must be the session tenant. Nobody can change the owner role

Other members get 403 from both endpoints. Each user gets their own permissions from GET /identity/my/permissions.

The tenant_id in the URL must be the session's tenant. Any other id gets 404, before the policy runs, on every route under /tenants/:tenant_id/permissions (including /access). This holds for an owner of another tenant too, and the answer is the same as for an id that does not exist, so the endpoint does not confirm that another tenant exists. The id is compared ignoring case, and only in the canonical hyphenated form. The refusal writes no audit record, so the attempt does not reach the other tenant's activity log (#2351).

Policy Rules

# identity/policies/tenant_permission_policy.rb
def index?
  in_this_tenant? && tenant_admin?   # tenant_admin? is true for owners too
end

def update?
  in_this_tenant? && tenant_owner?
end

def update_role?(role)
  update? && role.to_s != 'owner'
end

Only the owner can save, because a person who can save can give a role manage_subscription and so let that role cancel the Kulcare plan. SyncTenantPermissions repeats the same rules.


Complete Usage Example

Step 1: Get all available permissions

GET /api/v1/identity/permissions

Returns all system permissions with their default roles.

Step 2: Get tenant overrides

GET /api/v1/identity/tenants/:tenant_id/permissions

Returns only customized permissions (may be empty if no customization).

Step 3: Calculate effective permissions

On the frontend, merge the two responses:

// Pseudocode
const systemPermissions = systemResponse.data;
const tenantOverrides = tenantResponse.data;

// Build override map
const overrideMap = new Map();
tenantOverrides.forEach(roleGroup => {
  roleGroup.permissions.forEach(perm => {
    overrideMap.set(perm.permission_id, perm.enabled);
  });
});

// Calculate effective permissions
const effectivePermissions = systemPermissions.map(perm => {
  const override = overrideMap.get(perm.id);
  return {
    ...perm,
    enabled: override !== undefined ? override :
      perm.default_enabled_for_roles.includes(userRole)
  };
});

Step 4: Update permissions

PUT /api/v1/identity/tenants/:tenant_id/permissions
{
  "role": "staff",
  "permission_ids": ["uuid1", "uuid2", "uuid3"]
}

Response Format Summary

Success Response

{
  "data": { ... },
  "meta": {
    "request_id": "uuid"
  }
}

Error Response

{
  "data": null,
  "error": {
    "code": "ERROR_CODE",
    "message": "The first error message",
    "details": [{ "field": null, "message": "The first error message" }]
  },
  "meta": {
    "request_id": "uuid"
  }
}

On this page