The framework's authorization layer is now live. Until now, every
authenticated user could do everything; the framework had only an
authentication gate. This chunk adds method-level @RequirePermission
annotations enforced by a Spring AOP aspect that consults the JWT's
roles claim and a metadata-driven role-permission map.
What landed
-----------
* New `Role` and `UserRole` JPA entities mapping the existing
identity__role + identity__user_role tables (the schema was
created in the original identity init but never wired to JPA).
RoleJpaRepository + UserRoleJpaRepository with a JPQL query that
returns a user's role codes in one round-trip.
* `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a
Set<String> of role codes and encodes them as a `roles` JWT claim
(sorted for deterministic tests). Refresh tokens NEVER carry roles
by design — see the rationale on `JwtIssuer.issueRefreshToken`. A
role revocation propagates within one access-token lifetime
(15 min default).
* `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`.
Missing claim → empty set, NOT an error (refresh tokens, system
tokens, and pre-P4.3 tokens all legitimately omit it).
* `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)`
before minting the access token. `AuthService.refresh` re-reads
the user's roles too — so a refresh always picks up the latest
set, since refresh tokens deliberately don't carry roles.
* New `AuthorizationContext` ThreadLocal in `platform-security.authz`
carrying an `AuthorizedPrincipal(id, username, roles)`. Separate
from `PrincipalContext` (which lives in platform-persistence and
carries only the principal id, for the audit listener). The two
contexts coexist because the audit listener has no business
knowing what roles a user has.
* `PrincipalContextFilter` now populates BOTH contexts on every
authenticated request, reading the JWT's `username` and `roles`
claims via `Jwt.getClaimAsStringList("roles")`. The filter is the
one and only place that knows about Spring Security types AND
about both vibe_erp contexts; everything downstream uses just the
Spring-free abstractions.
* `PermissionEvaluator` Spring bean: takes a role set + permission
key, returns boolean. Resolution chain:
1. The literal `admin` role short-circuits to `true` for every
key (the wildcard exists so the bootstrap admin can do
everything from the very first boot without seeding a complete
role-permission mapping).
2. Otherwise consults an in-memory `Map<role, Set<permission>>`
loaded from `metadata__role_permission` rows. The cache is
rebuilt by `refresh()`, called from `VibeErpPluginManager`
after the initial core load AND after every plug-in load.
3. Empty role set is always denied. No implicit grants.
* `@RequirePermission("...")` annotation in `platform-security.authz`.
`RequirePermissionAspect` is a Spring AOP @Aspect with @Around
advice that intercepts every annotated method, reads the current
request's `AuthorizationContext`, calls
`PermissionEvaluator.has(...)`, and either proceeds or throws
`PermissionDeniedException`.
* New `PermissionDeniedException` carrying the offending key.
`GlobalExceptionHandler` maps it to HTTP 403 Forbidden with
`"permission denied: 'partners.partner.deactivate'"` as the
detail. The key IS surfaced to the caller (unlike the 401's
generic "invalid credentials") because the SPA needs it to
render a useful "your role doesn't include X" message and
callers are already authenticated, so it's not an enumeration
vector.
* `BootstrapAdminInitializer` now creates the wildcard `admin`
role on first boot and grants it to the bootstrap admin user.
* `@RequirePermission` applied to four sensitive endpoints as the
demo: `PartnerController.deactivate`,
`StockBalanceController.adjust`, `SalesOrderController.confirm`,
`SalesOrderController.cancel`. More endpoints will gain
annotations as additional roles are introduced; v1 keeps the
blast radius narrow.
End-to-end smoke test
---------------------
Reset Postgres, booted the app, verified:
* Admin login → JWT length 265 (was 241), decoded claims include
`"roles":["admin"]`
* Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED
(admin wildcard short-circuits the permission check)
* Inserted a 'powerless' user via raw SQL with no role assignments
but copied the admin's password hash so login works
* Powerless login → JWT length 247, decoded claims have NO roles
field at all
* Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with
`"permission denied: 'orders.sales.cancel'"` in the body
* Powerless DELETE /partners/{id} → **403 Forbidden** with
`"permission denied: 'partners.partner.deactivate'"`
* Powerless GET /sales-orders, /partners, /catalog/items → all 200
(read endpoints have no @RequirePermission)
* Admin regression: catalog uoms, identity users, inventory
locations, printing-shop plates with i18n, metadata custom-fields
endpoint — all still HTTP 2xx
Build
-----
* `./gradlew build`: 15 subprojects, 163 unit tests (was 153),
all green. The 10 new tests cover:
- PermissionEvaluator: empty roles deny, admin wildcard, explicit
role-permission grant, multi-role union, unknown role denial,
malformed payload tolerance, currentHas with no AuthorizationContext,
currentHas with bound context (8 tests).
- JwtRoundTrip: roles claim round-trips through the access token,
refresh token never carries roles even when asked (2 tests).
What was deferred
-----------------
* **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak-
compatible OIDC client will reuse the same authorization layer
unchanged — the roles will come from OIDC ID tokens instead of
the local user store.
* **Permission key validation at boot.** The framework does NOT
yet check that every `@RequirePermission` value matches a
declared metadata permission key. The plug-in linter is the
natural place for that check to land later.
* **Role hierarchy**. Roles are flat in v1; a role with permission
X cannot inherit from another role. Adding a `parent_role` field
on the role row is a non-breaking change later.
* **Resource-aware permissions** ("the user owns THIS partner").
v1 only checks the operation, not the operand. Resource-aware
checks are post-v1.
* **Composite (AND/OR) permission requirements**. A single key
per call site keeps the contract simple. Composite requirements
live in service code that calls `PermissionEvaluator.currentHas`
directly.
* **Role management UI / REST**. The framework can EVALUATE
permissions but has no first-class endpoints for "create a
role", "grant a permission to a role", "assign a role to a
user". v1 expects these to be done via direct DB writes or via
the future SPA's role editor (P3.x); the wiring above is
intentionally policy-only, not management.