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.
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'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.


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 _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, notification channels, and payment methods — 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.

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_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. 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).
  3. Split the _MANAGE permissions (§2's note) into separate _CREATE / _UPDATE / _ACTIVATE permissions once (2) is done and those actions are gated independently.
  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.