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}:
| Suffix | Meaning |
|---|---|
_VIEW | Read / list |
_CREATE | Create new records |
_UPDATE | Edit existing records |
_DELETE | Delete / archive records |
_MANAGE | Admin-level configuration |
_EXPORT | Export data to file |
Examples: PATIENT_VIEW, LAB_CREATE, DRUG_DISPENSING_UPDATE, BILLING_MANAGE.
Managing Roles in the Admin UI
- Log in as Tenant Admin or Super Admin
- Navigate to System → Roles
- Create a new role or edit an existing one
- Check the permissions to assign from the full permission catalogue
- 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:
- Confirm the user's JWT contains the required permission code (decode at jwt.io)
- Confirm the role assigned to the user has the permission in the DB
- Confirm the controller method has the matching
@PreAuthorize("hasAuthority('...')") - Confirm
@EnableMethodSecurityis active in the Spring context