Permission Management API Reference
Identity domain API for managing tenant permissions and role-based access control
Base Information
| Property | Value |
|---|---|
| Base URL | /identity |
| Authentication | Bearer JWT Token |
| Content-Type | application/json |
Endpoints Overview
| Method | Endpoint | Description |
|---|---|---|
| GET | /permissions | List all system permissions with defaults |
| GET | /tenants/:tenant_id/permissions | List tenant-specific permission overrides |
| PUT | /tenants/:tenant_id/permissions | Update permissions for a specific role |
Important: How Permissions Work
The system uses a cascading permission model:
- System Defaults (
GET /permissions): All available permissions with their default role assignments - Tenant Overrides (
GET /tenants/:tenant_id/permissions): Custom permissions set by the tenant
Frontend Usage Pattern: To show effective permissions in the UI:
- Call
GET /permissionsto get all available permissions with defaults - Call
GET /tenants/:tenant_id/permissionsto get tenant overrides - 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/permissionsQuery Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
category | string | No | Filter by permission category (appointments, patients, billing, care_plans, reports, settings) |
display | boolean | No | Filter 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
| Field | Type | Description |
|---|---|---|
id | UUID | Unique identifier for the permission |
created_at | datetime | When the permission was created (ISO 8601) |
updated_at | datetime | When the permission was last updated (ISO 8601) |
feature_name | string | Permission identifier (e.g., "manage_appointments", "view_patients") |
feature_category | string | The AREA the key belongs to. See the note below. |
access_level | string | The level the key sits at in its area: view, add, manage_own, manage, manage_all |
display | boolean | Whether to show in UI |
default_enabled_for_roles | array | Roles that have this permission by default |
feature_categorychanged 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 ofclinical,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.
billingis the one to look at first: it split intopatient_billing(the patient's bill at the front desk) andpractice_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/permissionsPath Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
tenant_id | UUID | Yes | The tenant/clinic ID |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
display | boolean | No | Filter 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
| Field | Type | Description |
|---|---|---|
data[].role | string | Role name (owner, admin, staff, practitioner) |
data[].permissions[] | array | List of customized permissions for this role |
permissions[].id | UUID | Tenant permission override ID |
permissions[].permission_id | UUID | System permission ID |
permissions[].permission_name | string | Permission identifier |
permissions[].permission_category | string | Category grouping |
permissions[].enabled | boolean | Whether permission is enabled for this role |
permissions[].is_custom | boolean | Always true - indicates this is a tenant override |
permissions[].default_for_roles | array | System 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/permissionsPath Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
tenant_id | UUID | Yes | The tenant/clinic ID |
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
role | string | Yes | Role to update (owner, admin, staff, practitioner) |
permission_ids | array | Yes | Array 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
| Status | Description |
|---|---|
| 401 Unauthorized | Missing or invalid token |
| 403 Forbidden | User doesn't have permission to manage permissions |
| 404 Not Found | Tenant not found, or tenant_id is not the tenant the session acts in |
| 422 Unprocessable Content | An 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 Name | ID | Default Roles |
|---|---|---|
manage_appointments | 019c6f7d-0b4c-7d62-8952-d788061ca293 | owner, admin, staff |
manage_own_appointments | 019c6f7d-0b6a-7562-8470-dd9ed2bc537d | practitioner |
view_own_schedule | 019c6f7d-0b6c-75fb-af10-0a6c5aa8a759 | practitioner |
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 Name | Default Roles |
|---|---|
view_bills | owner, admin, practitioner |
add_bills | owner, admin, practitioner |
edit_bills | owner, admin, practitioner |
Practice finance — commissions, revenue share, payouts, P&L.
| Permission Name | Default Roles |
|---|---|
view_practice_finance | owner, admin |
manage_practice_finance | owner, 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 Name | Default Roles |
|---|---|
view_subscription | owner |
manage_subscription | owner |
The ID columns elsewhere in this document are environment-specific —
reference.permissionsids are generated per database, so the values listed will not match your environment. Match onfeature_name, which is unique and stable; the seed atbackend/db/seeds/reference/015_seed_permissions.rbis the source of truth for the default roles above.
Care Plans
| Permission Name | ID | Default Roles |
|---|---|---|
create_orders | 019c6f7d-0b5f-772f-9d14-a58fb9c81718 | owner, admin, practitioner |
manage_encounters | 019c6f7d-0b49-745b-9a70-0fbab626fd27 | owner, admin, practitioner |
manage_observations | 019c6f7d-0b51-7cb5-95a4-94cb83501b4a | owner, admin, practitioner |
manage_orders | 019c6f7d-0b62-768f-8c4d-14e06680913d | owner, admin |
manage_prescriptions | 019c6f7d-0b5a-70e4-8699-a96612653e4d | owner, admin |
prescribe | 019c6f7d-0b57-7f93-832a-d7163ee4ff1d | owner, admin, practitioner |
view_encounters | 019c6f7d-0b46-797f-b28d-2744c656aecb | owner, admin, staff, practitioner |
view_observations | 019c6f7d-0b4f-7446-a041-ba692a8f1af4 | owner, admin, staff, practitioner |
view_orders | 019c6f7d-0b5c-7b60-b560-ec3ed4a935d4 | owner, admin, staff, practitioner |
view_prescriptions | 019c6f7d-0b54-7877-993d-99683ef2e92f | owner, admin, staff, practitioner |
Patients
| Permission Name | ID | Default Roles |
|---|---|---|
manage_own_patients | 019c6f7d-0b6f-7dee-9ee2-54fb46c8359f | practitioner |
manage_patients | 019c6f7d-0b43-7ce4-b566-bfba806c9ffd | owner, admin, practitioner |
view_patients | 019c6f7d-0b40-76a1-9ecd-643a57b5a71a | owner, admin, staff, practitioner |
Reports
| Permission Name | ID | Default Roles |
|---|---|---|
view_analytics | 019c6f7d-0b3e-796e-8c8e-74c8e6e3250a | owner, admin |
view_reports | 019c6f7d-0b3b-70f6-a3a5-36c647196aab | owner, admin |
Settings
| Permission Name | ID | Default Roles |
|---|---|---|
manage_facilities | 019c6f7d-0b25-7dd6-be7a-41d776538040 | owner, admin |
manage_organizations | 019c6f7d-0b21-798b-b5c1-acef2eb6cbc1 | owner, admin |
manage_practitioners | 019c6f7d-0b2b-725c-8a9d-7d870a7a9e97 | owner, admin, practitioner |
manage_services | 019c6f7d-0b2f-7b56-b303-10ccb8320c08 | owner, admin, practitioner |
manage_settings | 019c6f7d-0b36-7642-9bb1-030b58f235c8 | owner, admin, practitioner |
manage_tenant | 019c6f7d-0b03-7be4-94ff-f84780fab103 | owner |
manage_users | 019c6f7d-0b28-7934-81f7-0d080121456e | owner, admin |
view_practitioners | 019c6f7d-0b67-715d-94c4-f99184d6c080 | owner, admin, staff |
view_services | 019c6f7d-0b64-7307-aef2-fdf41067a568 | owner, admin, staff, practitioner |
Roles
| Role | Description |
|---|---|
owner | Full access, can manage everything |
admin | Administrative access, can manage most settings |
staff | Front desk or support staff |
practitioner | Doctors and healthcare providers |
Authorization
Required Permissions
| Endpoint | Permission Required |
|---|---|
| GET /permissions | Any authenticated user |
| GET /tenants/:tenant_id/permissions | Owner or admin of the tenant in the URL, which must be the session tenant |
| PUT /tenants/:tenant_id/permissions | Owner 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'
endOnly 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/permissionsReturns all system permissions with their default roles.
Step 2: Get tenant overrides
GET /api/v1/identity/tenants/:tenant_id/permissionsReturns 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"
}
}