Skip to main content

Permissions System

LAMISPlus uses a permission-based RBAC (Role-Based Access Control) model. Permissions are atomic codes (e.g. TRIAGE_CREATE) assigned to roles. Users inherit permissions through their roles. Both the backend and frontend enforce them independently.

Permission Lifecycle

1. Plugin declares permissions (YAML or Java)

2. Plugin loads → PluginSecurityManager seeds permissions into DB

3. ApplicationReadyEvent → DefaultRoleSeeder re-syncs all tenant roles

4. Admin assigns role to user

5. Login → JWT includes user's full permission list

6. Backend: @PreAuthorize enforces per endpoint
Frontend: PermissionGate / ProtectedRoute enforces per component

Declaring Permissions (Backend Plugin)

Option A, YAML (Preferred)

Place plugin-permissions.yml in src/main/resources/:

pluginId: ehr
permissions:
- permissionCode: TRIAGE_VIEW
description: View triage records
category: Triage
action: READ
permissionExpression: hasAuthority('TRIAGE_VIEW')

- permissionCode: TRIAGE_CREATE
description: Capture triage vitals
category: Triage
action: CREATE
permissionExpression: hasAuthority('TRIAGE_CREATE')

- permissionCode: TRIAGE_UPDATE
description: Edit triage records
category: Triage
action: UPDATE
permissionExpression: hasAuthority('TRIAGE_UPDATE')

- permissionCode: TRIAGE_DELETE
description: Delete triage records
category: Triage
action: DELETE
permissionExpression: hasAuthority('TRIAGE_DELETE')

Option B, Java

@Override
public List<PluginPermission> getSecurityPermissions() {
return List.of(
createPermission("TRIAGE_VIEW", "View triage records", "Triage", "READ"),
createPermission("TRIAGE_CREATE", "Capture vitals", "Triage", "CREATE"),
createPermission("TRIAGE_UPDATE", "Edit triage records", "Triage", "UPDATE"),
createPermission("TRIAGE_DELETE", "Delete triage records","Triage", "DELETE")
);
}

Enforcing Permissions (Backend)

Every controller method must have @PreAuthorize:

@GetMapping("/triage")
@PreAuthorize("hasAuthority('TRIAGE_VIEW')")
public List<TriageDTO> getAll() { ... }

@PostMapping("/triage")
@PreAuthorize("hasAuthority('TRIAGE_CREATE')")
public TriageDTO create(@Valid @RequestBody TriageRequest req) { ... }

@PutMapping("/triage/{uuid}")
@PreAuthorize("hasAuthority('TRIAGE_UPDATE')")
public TriageDTO update(@PathVariable UUID uuid, @RequestBody TriageRequest req) { ... }

@DeleteMapping("/triage/{uuid}")
@PreAuthorize("hasAuthority('TRIAGE_DELETE')")
public void delete(@PathVariable UUID uuid) { ... }

@EnableMethodSecurity must be present in the plugin's Spring config (or inherited from the core context).

Default Roles

Default roles are defined in lamisplus-core/src/main/resources/default-roles.yml:

default-roles:
- roleName: Doctor
description: Clinical doctor role
permissions:
- TRIAGE_VIEW
- TRIAGE_CREATE
- CONSULTATION_VIEW
- CONSULTATION_CREATE
- LAB_VIEW
- LAB_CREATE
- DRUG_DISPENSING_VIEW

- roleName: Nurse
description: Nursing staff role
permissions:
- TRIAGE_VIEW
- TRIAGE_CREATE
- TRIAGE_UPDATE
- PATIENT_VIEW

- roleName: Lab Scientist
description: Laboratory staff role
permissions:
- LAB_VIEW
- LAB_CREATE
- LAB_UPDATE

These roles are automatically created for every new tenant via DefaultRoleSeeder, which fires on TenantCreatedEvent. On every startup (ApplicationReadyEvent), the seeder also re-syncs existing roles to add any permissions that were registered after the tenant was first created (e.g. from a newly installed plugin).

Permission Seeding Flow

ApplicationReadyEvent
→ DefaultRoleSeeder.resyncAllRolePermissionsOnStartup()
→ For each active tenant:
For each role in default-roles.yml:
→ Find role in DB
→ Add any missing permissions
→ Save

This means:

  • Installing a new plugin automatically makes its permissions available to be added to existing roles
  • The resync is additive, it never removes permissions that were manually added by an admin

Frontend Enforcement

Route-Level Gate

// routes.tsx
{
path: 'triage/record',
element: (
<PluginWrapper>
<ProtectedRoute requiredPermission="TRIAGE_CREATE">
<RecordTriageDetails />
</ProtectedRoute>
</PluginWrapper>
),
}

ProtectedRoute checks the current user's permission list from Redux state. If the permission is absent, it renders nothing (or a redirect to /unauthorized).

Component/Action-Level Gate

// Inside a page component
<PermissionGate permission="TRIAGE_CREATE">
<Button onClick={handleCaptureTriage}>New Triage</Button>
</PermissionGate>

<PermissionGate permission="TRIAGE_DELETE">
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</PermissionGate>

Programmatic Check

import { usePermissions } from '@/hooks/usePermissions';

const { hasPermission } = usePermissions();

const canCreate = hasPermission('TRIAGE_CREATE');
const canDelete = hasPermission('TRIAGE_DELETE');

Permission Code Naming Convention

Permission codes follow the pattern {RESOURCE}_{ACTION}:

SuffixMeaning
_VIEWRead / list
_CREATECreate new records
_UPDATEEdit existing records
_DELETEDelete / archive records
_MANAGEAdmin-level configuration
_EXPORTExport data to file

Examples: PATIENT_VIEW, LAB_CREATE, DRUG_DISPENSING_UPDATE, BILLING_MANAGE.

Managing Roles in the Admin UI

  1. Log in as Tenant Admin or Super Admin
  2. Navigate to System → Roles
  3. Create a new role or edit an existing one
  4. Check the permissions to assign from the full permission catalogue
  5. Save, users with this role will have the permissions on next login (JWT re-issue)

Troubleshooting

Permission not showing in the UI after installing a plugin: The DefaultRoleSeeder resync runs on startup but is @Async. Wait a few seconds after startup, then check if the permission code appears in the permission list endpoint (GET /api/v1/core/permissions). If not, check the plugin's plugin-permissions.yml file is valid and the pluginId matches the plugin descriptor.

403 on an endpoint that should be accessible:

  1. Confirm the user's JWT contains the required permission code (decode at jwt.io)
  2. Confirm the role assigned to the user has the permission in the DB
  3. Confirm the controller method has the matching @PreAuthorize("hasAuthority('...')")
  4. Confirm @EnableMethodSecurity is active in the Spring context