Roles and Permissions
# TrafficGrid — Roles & Permissions
Version 1.0 | 2026
This document defines TrafficGrid's move from a fixed, enum-based role model to a database-backed permission
model. It is the source of truth for what each permission means and which roles carry it, and it is what the seed data
in auth-service's V2__create_roles_and_permissions.sql migration is built from.
1. Why permission-based access control¶
Until now, authorization across every service was expressed directly against a fixed UserRole enum (CITIZEN,
ZRP_OFFICER, COUNCIL_OFFICER, PARKING_OFFICER, AUTHORITY_ADMIN, SUPER_ADMIN) — every @PreAuthorize check
across the platform, e.g. hasAnyAuthority('ROLE_ZRP_OFFICER', 'ROLE_COUNCIL_OFFICER', ...), spelled out the exact set
of roles allowed to call an endpoint. Adding a new role, or changing what an existing role could do, meant a code change
and a redeploy of every service touched.
Permission-based access control (PBAC) splits this into two layers:
- Permissions are fine-grained, named capabilities —
VEHICLE_CREATE,USER_DEACTIVATE,AUDIT_LOG_READ. They map 1:1 to "can this caller do this one thing." - Roles are named bundles of permissions. A role is what gets assigned to a user; a permission is what gets checked at an endpoint.
Roles and permissions are now rows in auth-service's database (roles, permissions, role_permissions) rather than
compiled-in constants, managed through the roles/permissions CRUD API described in §4.
1.1 What changed vs. what didn't¶
| Area | Status |
|---|---|
users.role |
A foreign key (role_id) to the roles table, instead of a UserRole enum column. |
| Roles & permissions CRUD | In auth-service — see §4. |
| The six original roles | Seeded as rows with is_system = true (cannot be renamed or deleted), with the exact same names as the old UserRole enum constants. |
| JWT access-token claims | Carry both role (name string) and permissions (the flattened list of permission codes granted to that role). Refresh tokens omit permissions — they're only ever exchanged for a fresh access token. |
TokenClaimsDto / AuthenticatedUser / SecurityAuthorityUtils (core-common) |
Carry/parse permissions alongside role, and build the combined authority list (ROLE_x + every permission code) that downstream services grant. |
| API gateway | Forwards the resolved permissions as an X-User-Permissions header (comma-separated codes), alongside the existing X-User-Id / X-User-Role. |
@PreAuthorize checks in vehicle-service, audit-service, auth-service, and system-parameters-service's OrganizationController/CurrencyController |
Switched from role lists (hasAnyAuthority('ROLE_...')) to permission checks (hasAuthority('VEHICLE_CREATE')), per the matrix in §3. |
system-parameters-service's fine-category, notification-channel, and payment-method controllers |
Left untouched — see the pre-existing gap noted in §2. |
AuditEvent.actorRole |
Still the fixed UserRole enum (shared, persisted schema in audit-service) — see §1.2. |
Both the deserialization path (JWT → AuthenticatedUser) and the enforcement path (@PreAuthorize) for vehicle-service,
audit-service, and auth-service are now permission-based end to end. A direct call to a service (bypassing the
gateway) is also covered: each service's JwtAuthenticationFilter decodes the token itself via core-common's
JwtService.extractUser, which now populates AuthenticatedUser.permissions from the token's permissions claim the
same way the gateway's X-User-Permissions header does.
1.2 A consequence worth knowing¶
The JWT role claim (and the X-User-Role header) is still decoded into the fixed UserRole enum in a few places that
were out of scope for this phase — core-common's JwtService.extractUser still calls UserRole.valueOf(...) to
populate AuthenticatedUser.role, and audit-service persists actorRole as that same enum. A custom role (created
via the CRUD API) only maps cleanly onto AuthenticatedUser.role and AuditEvent.actorRole if its name matches one of
the six existing UserRole constants. Its permissions, however, now flow correctly regardless of its name — a
custom-named role's users get exactly its assigned permission codes as granted authorities, and every permission-based
@PreAuthorize check enforces correctly against them. What breaks for a non-matching custom role name is narrower than
before this phase and fails safely rather than crashing:
auth-service'sAuditAspect.toLegacyUserRolecatches the mismatch and emits the audit event with anullactorRoleinstead of throwing.- Each downstream service's
GatewayHeadersFilterandJwtAuthenticationFiltercatch the mismatch and leave the security context unauthenticated, so the request fails as a clean401 Unauthorizedrather than a500.
Widening AuthenticatedUser.role and AuditEvent.actorRole to a plain role-name string is the one piece of
§6 that would remove this constraint entirely.
2. Permission catalog¶
Permission codes follow <MODULE>_<ACTION>. All 30 below are seeded by the auth-service migration.
USER¶
| Code | Name | Description |
|---|---|---|
USER_CREATE |
Create User | Create officer/admin accounts. |
USER_READ |
Read User | View user account details. |
USER_ACTIVATE |
Activate User | Reactivate a deactivated user account. |
USER_DEACTIVATE |
Deactivate User | Deactivate a user account. |
ROLE¶
| Code | Name | Description |
|---|---|---|
ROLE_CREATE |
Create Role | Create a new role. |
ROLE_READ |
Read Role | View roles and their assigned permissions. |
ROLE_UPDATE |
Update Role | Edit a role's name or description. |
ROLE_DELETE |
Delete Role | Delete a non-system role that has no users assigned. |
ROLE_PERMISSION_ASSIGN |
Assign Role Permissions | Add, remove, or replace the permissions granted to a role. |
PERMISSION¶
| Code | Name | Description |
|---|---|---|
PERMISSION_CREATE |
Create Permission | Define a new permission. |
PERMISSION_READ |
Read Permission | View permissions. |
PERMISSION_UPDATE |
Update Permission | Edit a permission's metadata. |
PERMISSION_DELETE |
Delete Permission | Delete a permission that is not assigned to any role. |
VEHICLE¶
| Code | Name | Description |
|---|---|---|
VEHICLE_CREATE |
Create Vehicle | Register a new vehicle. |
VEHICLE_READ |
Read Vehicle | View or search vehicle records. |
VEHICLE_DOCUMENT_READ |
Read Vehicle Documents | View a vehicle's documents. |
VEHICLE_DOCUMENT_CREATE |
Create Vehicle Document | Add a document to a vehicle. |
VEHICLE_DOCUMENT_UPDATE |
Update Vehicle Document | Update a vehicle document's expiry date. |
CITIZEN_VEHICLE¶
| Code | Name | Description |
|---|---|---|
CITIZEN_VEHICLE_READ |
Read Linked Vehicles | View vehicles linked to own citizen account. |
CITIZEN_VEHICLE_LINK |
Link Vehicle | Link a vehicle to own citizen account. |
CITIZEN_VEHICLE_UNLINK |
Unlink Vehicle | Remove a vehicle linked to own citizen account. |
AUDIT¶
| Code | Name | Description |
|---|---|---|
AUDIT_LOG_READ |
Read Audit Logs | Query the platform audit trail. |
SYSTEM_PARAMETERS¶
| Code | Name | Description |
|---|---|---|
ORGANIZATION_MANAGE |
Manage Organisations | Create, edit, activate, or deactivate organisations. |
CURRENCY_MANAGE |
Manage Currencies | Create, edit, activate, or deactivate currencies. |
FINE_CATEGORY_READ |
Read Fine Categories | View fine categories. |
FINE_CATEGORY_MANAGE |
Manage Fine Categories | Create, edit, activate, or deactivate fine categories. |
NOTIFICATION_CHANNEL_READ |
Read Notification Channels | View notification channels. |
NOTIFICATION_CHANNEL_MANAGE |
Manage Notification Channels | Create, edit, activate, or deactivate notification channels. |
PAYMENT_METHOD_READ |
Read Payment Methods | View payment methods. |
PAYMENT_METHOD_MANAGE |
Manage Payment Methods | Create, edit, activate, or deactivate payment methods. |
Note on
_MANAGEpermissions:ORGANIZATION_MANAGE,CURRENCY_MANAGE,FINE_CATEGORY_MANAGE,NOTIFICATION_CHANNEL_MANAGE, andPAYMENT_METHOD_MANAGEeach cover create/edit/activate/deactivate as one permission becausesystem-parameters-service's controllers currently gate all of those actions identically (class-level@PreAuthorize, or — for fine categories, notification channels, and payment methods — no method security at all yet; see the gap noted below). Splitting them into separate_CREATE/_UPDATE/_ACTIVATEpermissions is straightforward once that service enforces them separately.
Known enforcement gap (pre-existing, not introduced by this change)¶
FineCategoryController, NotificationChannelController, and PaymentMethodController in system-parameters-service
currently carry no method- or class-level @PreAuthorize — every write endpoint (create, activate, deactivate) is
reachable by any authenticated user, not just SUPER_ADMIN, contradicting the Auth: SUPER requirement documented
in API Contracts.md. The permission catalog and role mapping below reflect the intended design
(_MANAGE restricted to SUPER_ADMIN); wiring that enforcement into system-parameters-service is a separate,
pre-existing fix (already tracked in the Implementation Tracker) and is not part of this change.
3. Role → permission matrix¶
Seeded roles are marked is_system = true: the CRUD API refuses to rename or delete them, though their permission sets
can still be edited like any other role's.
| Permission | CITIZEN | ZRP_OFFICER | COUNCIL_OFFICER | PARKING_OFFICER | AUTHORITY_ADMIN | SUPER_ADMIN |
|---|---|---|---|---|---|---|
USER_CREATE |
✅ | ✅ | ||||
USER_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
USER_ACTIVATE |
✅ | ✅ | ||||
USER_DEACTIVATE |
✅ | ✅ | ||||
ROLE_CREATE |
✅ | |||||
ROLE_READ |
✅ | |||||
ROLE_UPDATE |
✅ | |||||
ROLE_DELETE |
✅ | |||||
ROLE_PERMISSION_ASSIGN |
✅ | |||||
PERMISSION_CREATE |
✅ | |||||
PERMISSION_READ |
✅ | |||||
PERMISSION_UPDATE |
✅ | |||||
PERMISSION_DELETE |
✅ | |||||
VEHICLE_CREATE |
✅ | ✅ | ✅ | ✅ | ||
VEHICLE_READ |
✅ | ✅ | ✅ | ✅ | ✅ | |
VEHICLE_DOCUMENT_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
VEHICLE_DOCUMENT_CREATE |
✅ | ✅ | ✅ | ✅ | ✅ | |
VEHICLE_DOCUMENT_UPDATE |
✅ | ✅ | ✅ | ✅ | ✅ | |
CITIZEN_VEHICLE_READ |
✅ | ✅ | ||||
CITIZEN_VEHICLE_LINK |
✅ | ✅ | ||||
CITIZEN_VEHICLE_UNLINK |
✅ | ✅ | ||||
AUDIT_LOG_READ |
✅ | ✅ | ||||
ORGANIZATION_MANAGE |
✅ | |||||
CURRENCY_MANAGE |
✅ | |||||
FINE_CATEGORY_READ |
✅ | ✅ | ✅ | ✅ | ||
FINE_CATEGORY_MANAGE |
✅ | |||||
NOTIFICATION_CHANNEL_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
NOTIFICATION_CHANNEL_MANAGE |
✅ | |||||
PAYMENT_METHOD_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
PAYMENT_METHOD_MANAGE |
✅ |
This matrix mirrors, endpoint for endpoint, what is currently enforced by @PreAuthorize in vehicle-service,
audit-service, and auth-service today, cross-checked against the intended Auth: column
in API Contracts.md for the modules where enforcement isn't wired up yet (see the gap note in §2).
4. CRUD API (auth-service)¶
Every endpoint below is gated by its own permission (PERMISSION_CREATE, ROLE_DELETE, etc. — see the catalog in
§2), each of which only SUPER_ADMIN holds per the seeded matrix in §3.
In effect, only super admins can manage the permission system itself today — a role granted a subset of these (e.g. just
ROLE_READ and PERMISSION_READ, for a read-only admin console) works immediately, since enforcement is already
per-permission rather than a single blanket SUPER_ADMIN gate.
Permissions — /api/v1/permissions¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/permissions |
Create a permission (code, name, description, module). |
GET |
/api/v1/permissions |
Paginated list of all permissions. |
GET |
/api/v1/permissions/{id} |
Fetch a single permission. |
PUT |
/api/v1/permissions/{id} |
Replace a permission's code/name/description/module. |
DELETE |
/api/v1/permissions/{id} |
Delete a permission. 409 Conflict if any role still has it assigned. |
Roles — /api/v1/roles¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/roles |
Create a role (name, description). Starts with no permissions. |
GET |
/api/v1/roles |
Paginated list of all roles, each including its assigned permissions. |
GET |
/api/v1/roles/{id} |
Fetch a single role with its permissions. |
PUT |
/api/v1/roles/{id} |
Replace a role's name/description. 400 if the role is a system role and the name is changing. |
DELETE |
/api/v1/roles/{id} |
Delete a role. 409 Conflict if it's a system role or any user is still assigned to it. |
PUT |
/api/v1/roles/{id}/permissions |
Replace the role's entire permission set (permissionIds). |
POST |
/api/v1/roles/{id}/permissions/{permissionId} |
Add a single permission to the role. |
DELETE |
/api/v1/roles/{id}/permissions/{permissionId} |
Remove a single permission from the role. |
User creation now takes a roleId¶
POST /api/v1/users (admin-created officer/admin accounts) now takes roleId (UUID, referencing a row in roles)
instead of a role enum value — pick it from GET /api/v1/roles. Citizen self-registration
(POST /api/v1/auth/register) is unaffected: it still always assigns the seeded CITIZEN role internally.
5. Data model¶
roles permissions role_permissions
------------------------ -------------------------- ------------------------
id UUID PK id UUID PK role_id UUID FK -> roles.id
name VARCHAR UNIQUE code VARCHAR UNIQUE permission_id UUID FK -> permissions.id
description VARCHAR name VARCHAR PRIMARY KEY (role_id, permission_id)
is_system BOOLEAN description VARCHAR
created_at TIMESTAMP module VARCHAR
updated_at TIMESTAMP created_at TIMESTAMP
updated_at TIMESTAMP
users
------------------------
...
role_id UUID FK -> roles.id (replaces the old `role` enum column)
...
roles.is_systemistruefor the six seeded roles; the CRUD API blocks renaming or deleting them, but permissions can still be freely reassigned on them.role_permissionsis a plain join table (no surrogate key) — a role either has a permission or it doesn't.- Deleting a
permissionthat's still attached to a role, or arolethat's a system role or still has users on it, returns409 Conflictrather than cascading.
6. Follow-up (not yet done)¶
The JWT/deserialization plumbing and the permission-based @PreAuthorize switch are both done (see §1.1).
What's left, only if/when requested:
- Widen
AuthenticatedUser.roleandAuditEvent.actorRolefrom the fixedUserRoleenum to a plain role-name string, removing the last constraint noted in §1.2 — a custom role would then work identically to a seeded one everywhere, not just for permission checks. - Wire the enforcement gap noted in §2 for
system-parameters-service's fine-category, notification-channel, and payment-method write endpoints (a pre-existing gap, not introduced by this change). - Split the
_MANAGEpermissions (§2's note) into separate_CREATE/_UPDATE/_ACTIVATEpermissions once (2) is done and those actions are gated independently. - Consider whether
spring.jpa.open-in-viewand lazy-loading assumptions aroundRole.permissionsneed tightening as the roles/permissions tables grow — todayJwtService.buildTokenandRoleServiceavoid relying on it by querying permission codes directly (RoleRepository.findPermissionCodesByRoleId) rather than traversing the lazy@ManyToManycollection.