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. The ROLE_ prefix exists only inside Spring Security — see §1.3. |
| 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 PaymentMethodController |
Switched to permission checks (hasAuthority('PAYMENT_METHOD_READ') / hasAuthority('PAYMENT_METHOD_MANAGE')), per the matrix in §3. |
system-parameters-service's fine-category and notification-channel controllers |
Now switched to permission checks (FINE_CATEGORY_MANAGE/_READ, NOTIFICATION_CHANNEL_MANAGE/_READ) — see §2. |
payments-service's fines domain |
New FINE_CREATE/FINE_READ/FINE_STATUS_UPDATE permissions added (see §2) as part of consolidating fines into payments-service — see Data Model.md §4.11. |
payments-service's PaymentsController |
New PAYMENT_CREATE / PAYMENT_READ permissions added (see §2); switched from a broken, unused class-level check to per-endpoint @PreAuthorize, and JwtAuthenticationFilter/GatewayHeadersFilter/SecurityConfig were added so method security actually has a security context to check. |
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.
1.3 Role names are bare¶
Everywhere a role name leaves a service — the JWT role claim, /auth/validate, the gateway's X-User-Role header,
roleName on users, name on roles, and the docs — it is the bare name: ZRP_OFFICER, never ROLE_ZRP_OFFICER.
Clients must not strip or add a prefix.
The ROLE_ prefix exists only inside each service, where SecurityAuthorityUtils.toSpringAuthority turns the bare name
into a Spring Security authority. It keeps a role's authority apart from the bare permission-code authorities granted
alongside it. To keep that separation, auth-service refuses a role name that starts with ROLE_ or equals a permission
code: a role named USER_CREATE would otherwise read as that permission.
2. Permission catalog¶
Permission codes follow <MODULE>_<ACTION>. All 44 below are seeded by the auth-service migrations
(V2__create_roles_and_permissions.sql, V3__add_payments_service_permissions.sql,
V4__add_fine_permissions.sql, V7__add_read_and_parking_permissions.sql and
V8__add_fine_dispute_permissions.sql).
How permissions are seeded¶
The migrations seed a fresh database. On every start, auth-service's PermissionSeeder then checks the full
catalogue in security/PermissionCatalogue.java, which lists every code below with its default roles:
- A code missing from the database is inserted and granted to its default roles.
SUPER_ADMINis given any catalogue code it lacks.- Nothing is ever removed or rewritten. An admin's revocation on an existing code stays revoked. A catalogue code deleted through the API comes back on the next start.
To add a permission: add it to PermissionCatalogue with its default roles. It is live on the next start. A
migration that inserts it as well is optional.
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. |
USER_LIST |
List Users | List and search other users (admin console). GET /users needs it; GET /users/{id} stays USER_READ and /users/me only needs a session. |
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. |
SYSTEM_PARAMETERS_READ |
Read System Parameters | Open the admin console's System parameters screen. Each kind's API keeps its own read code. |
Note on fine levels and offences: fine categories are now per-organisation fine levels, with an offence catalogue under them (
Data Model.md§2.6,API Contracts.md§6.5). There are no new codes:FINE_CATEGORY_READgates reading both levels and offences (/system-parameters/fine-categoriesand/system-parameters/offences), andFINE_CATEGORY_MANAGEgates creating, editing, activating and deactivating both. OnlySUPER_ADMINholdsFINE_CATEGORY_MANAGE, so only a super admin manages levels and offences, for every organisation. Writes follow the same organisation scope as reads below: if a custom role is grantedFINE_CATEGORY_MANAGE, it manages only its own organisation's levels and offences, and another organisation's record reads as404.Reads are also scoped by organisation, in application logic rather than by a finer-grained permission: -
SUPER_ADMINsees every organisation's levels and offences, and may filter them withorganizationId. - Everyone else who holdsFINE_CATEGORY_READ(officers, authority admins) sees only their own organisation's levels and the offences under them. TheorganizationIdfilter is ignored, another organisation's level or offence reads as404, and a caller without an organisation gets an empty list.The organisation comes from the gateway's
X-Organisation-Idheader (or the token's claim on direct calls). payments-service forwards the caller's headers when it looks a level up while issuing a fine, so the same scoping applies there: another organisation's level is not found (404) for anyone but a super admin.
ORGANIZATION and CURRENCY¶
| Code | Name | Description |
|---|---|---|
ORGANIZATION_READ |
Read Organisations | List and view organisations (ORGANIZATION_MANAGE also allows it). |
CURRENCY_READ |
Read Currencies | List and view currencies and their rates (CURRENCY_MANAGE also allows it). |
PARKING_TICKET¶
| Code | Name | Description |
|---|---|---|
PARKING_TICKET_CREATE |
Create Parking Ticket | Issue a parking ticket. |
PARKING_TICKET_READ |
Read Parking Ticket | Search for or look up parking tickets. |
PARKING_TICKET_STATUS_UPDATE |
Update Parking Ticket Status | Change a parking ticket's status, with a reason. |
These codes exist so roles can be set up ahead of the parking-ticket endpoints (§6.7 of the API contract, still
planned). Nothing enforces them yet; the admin console gates its parking screens on PARKING_TICKET_READ.
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 and notification channels — 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.
PAYMENT¶
| Code | Name | Description |
|---|---|---|
PAYMENT_CREATE |
Create Payment | Create a payment (citizen self-pay or officer cash recording); also gates the temporary mock provider callback. |
PAYMENT_READ |
Read Payment | Look up a payment by id or reference number. |
Note: citizens hold
PAYMENT_READbutPaymentsServicestill restricts them to payments they made themselves (paidBy == currentUserId) — the permission only decides whether the endpoint is reachable at all, not which rows come back.ZRP_OFFICER,COUNCIL_OFFICER,PARKING_OFFICER,AUTHORITY_ADMIN, andSUPER_ADMINcan read any payment.
FINE¶
| Code | Name | Description |
|---|---|---|
FINE_CREATE |
Create Fine | Issue a traffic or council fine against a vehicle. |
FINE_READ |
Read Fine | Search for, filter, or look up a single fine. |
FINE_STATUS_UPDATE |
Update Fine Status | Manually clear, cancel, or otherwise change a fine's status, with a reason. |
Note: fines were deliberately left out of the original (V2/V3) permission migration and were role-gated directly until
V4__add_fine_permissions.sqlbrought them onto this same model.FINE_CREATE's role grant decides whether the create endpoint is reachable at all; the fines domain (now owned bypayments-service— seeData Model.md§4.11) additionally requires the caller's organisation to match the fine category's issuing organisation, except forSUPER_ADMIN— enforced in application logic, not via a finer-grained permission split, the same patternPAYMENT_READ's citizen-scoping note above describes.
FINE_DISPUTE¶
| Code | Name | Description |
|---|---|---|
FINE_DISPUTE_CREATE |
Dispute Fine | File or withdraw a dispute against a fine on an own linked vehicle. |
FINE_DISPUTE_REVIEW |
Review Fine Disputes | View the dispute queue and uphold or reject disputes on own organisation's fines. |
Note: added by
V8__add_fine_dispute_permissions.sql(andPermissionCatalogue) for fine disputes inpayments-service(API Contracts.md§6.6.1). Filing and withdrawing also require theCITIZENrole, soSUPER_ADMIN'sFINE_DISPUTE_CREATEgrant does not let a super admin dispute a fine; a citizen may dispute only a fine on one of their linked vehicles, and withdraw only a dispute they filed.FINE_DISPUTE_REVIEWis further limited to the fine's issuing organisation, except forSUPER_ADMIN— the same application-level scoping asFINE_STATUS_UPDATE.FINE_STATUS_UPDATEcan no longer move a fine into or out ofDISPUTED.
Former enforcement gap — now resolved¶
FineCategoryController and NotificationChannelController in system-parameters-service previously carried no
method- or class-level @PreAuthorize, leaving every write endpoint (create, activate, deactivate) reachable by any
authenticated user rather than just SUPER_ADMIN. Both controllers now enforce FINE_CATEGORY_MANAGE /
NOTIFICATION_CHANNEL_MANAGE on writes and FINE_CATEGORY_READ / NOTIFICATION_CHANNEL_READ on reads, mirroring
PaymentMethodController's existing per-method pattern — the permission catalog and role mapping below now reflect
what's actually enforced, not just the intended design.
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 |
✅ | ✅ | ||||
USER_LIST |
✅ | ✅ | ||||
AUDIT_LOG_READ |
✅ | ✅ | ||||
ORGANIZATION_READ |
✅ | ✅ | ✅ | ✅ | ✅ | |
ORGANIZATION_MANAGE |
✅ | |||||
CURRENCY_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
CURRENCY_MANAGE |
✅ | |||||
SYSTEM_PARAMETERS_READ |
✅ | ✅ | ||||
FINE_CATEGORY_READ |
✅ | ✅ | ✅ | ✅ | ✅ | |
FINE_CATEGORY_MANAGE |
✅ | |||||
NOTIFICATION_CHANNEL_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
NOTIFICATION_CHANNEL_MANAGE |
✅ | |||||
PAYMENT_METHOD_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
PAYMENT_METHOD_MANAGE |
✅ | |||||
PAYMENT_CREATE |
✅ | ✅ | ✅ | ✅ | ✅ | |
PAYMENT_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
FINE_CREATE |
✅ | ✅ | ✅ | |||
FINE_READ |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
FINE_STATUS_UPDATE |
✅ | ✅ | ||||
FINE_DISPUTE_CREATE |
✅ | ✅ | ||||
FINE_DISPUTE_REVIEW |
✅ | ✅ | ||||
PARKING_TICKET_CREATE |
✅ | ✅ | ||||
PARKING_TICKET_READ |
✅ | ✅ | ✅ | ✅ | ||
PARKING_TICKET_STATUS_UPDATE |
✅ | ✅ |
PermissionSeederTest checks that these defaults match the database the migrations build.
This matrix mirrors, endpoint for endpoint, what is currently enforced by @PreAuthorize in vehicle-service,
audit-service, auth-service, system-parameters-service (including FineCategoryController and
NotificationChannelController, now enforced), and payments-service (including its fines domain) today,
cross-checked against the Auth: lines in API Contracts.md, which name these permission codes
for fines, payments, fine categories, organisations, users and audit logs. Some older sections there (vehicles,
parking tickets, notifications) still list roles rather than codes.
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, optional permissionIds). Without permissionIds it 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. |
Role names are upper-cased and at most 50 characters. A name that starts with ROLE_, or that equals a permission
code (e.g. USER_CREATE), is refused with 400 on create and rename — see §1.3.
| 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. - Split the
_MANAGEpermissions (§2's note) into separate_CREATE/_UPDATE/_ACTIVATEpermissions, now that those actions are gated per method. - Enforce the
PARKING_TICKET_*codes once the parking-ticket endpoints exist. - 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.