Skip to content

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's AuditAspect.toLegacyUserRole catches the mismatch and emits the audit event with a null actorRole instead of throwing.
  • Each downstream service's GatewayHeadersFilter and JwtAuthenticationFilter catch the mismatch and leave the security context unauthenticated, so the request fails as a clean 401 Unauthorized rather than a 500.

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_ADMIN is 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_READ gates reading both levels and offences (/system-parameters/fine-categories and /system-parameters/offences), and FINE_CATEGORY_MANAGE gates creating, editing, activating and deactivating both. Only SUPER_ADMIN holds FINE_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 granted FINE_CATEGORY_MANAGE, it manages only its own organisation's levels and offences, and another organisation's record reads as 404.

Reads are also scoped by organisation, in application logic rather than by a finer-grained permission: - SUPER_ADMIN sees every organisation's levels and offences, and may filter them with organizationId. - Everyone else who holds FINE_CATEGORY_READ (officers, authority admins) sees only their own organisation's levels and the offences under them. The organizationId filter is ignored, another organisation's level or offence reads as 404, and a caller without an organisation gets an empty list.

The organisation comes from the gateway's X-Organisation-Id header (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 _MANAGE permissions: ORGANIZATION_MANAGE, CURRENCY_MANAGE, FINE_CATEGORY_MANAGE, NOTIFICATION_CHANNEL_MANAGE, and PAYMENT_METHOD_MANAGE each cover create/edit/activate/deactivate as one permission because system-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 / _ACTIVATE permissions 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_READ but PaymentsService still 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, and SUPER_ADMIN can 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.sql brought them onto this same model. FINE_CREATE's role grant decides whether the create endpoint is reachable at all; the fines domain (now owned by payments-service — see Data Model.md §4.11) additionally requires the caller's organisation to match the fine category's issuing organisation, except for SUPER_ADMIN — enforced in application logic, not via a finer-grained permission split, the same pattern PAYMENT_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 (and PermissionCatalogue) for fine disputes in payments-service (API Contracts.md §6.6.1). Filing and withdrawing also require the CITIZEN role, so SUPER_ADMIN's FINE_DISPUTE_CREATE grant 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_REVIEW is further limited to the fine's issuing organisation, except for SUPER_ADMIN — the same application-level scoping as FINE_STATUS_UPDATE. FINE_STATUS_UPDATE can no longer move a fine into or out of DISPUTED.

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_system is true for the six seeded roles; the CRUD API blocks renaming or deleting them, but permissions can still be freely reassigned on them.
  • role_permissions is a plain join table (no surrogate key) — a role either has a permission or it doesn't.
  • Deleting a permission that's still attached to a role, or a role that's a system role or still has users on it, returns 409 Conflict rather 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:

  1. Widen AuthenticatedUser.role and AuditEvent.actorRole from the fixed UserRole enum 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.
  2. Split the _MANAGE permissions (§2's note) into separate _CREATE / _UPDATE / _ACTIVATE permissions, now that those actions are gated per method.
  3. Enforce the PARKING_TICKET_* codes once the parking-ticket endpoints exist.
  4. Consider whether spring.jpa.open-in-view and lazy-loading assumptions around Role.permissions need tightening as the roles/permissions tables grow — today JwtService.buildToken and RoleService avoid relying on it by querying permission codes directly (RoleRepository.findPermissionCodesByRoleId) rather than traversing the lazy @ManyToMany collection.