Commit 75bf870568f514d0145e5bb4214e070f031fae88

Authored by zichun
1 parent 523ae0e3

feat(security): P4.3 — @RequirePermission + JWT roles claim + AOP enforcement

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.
Showing 27 changed files with 882 additions and 44 deletions
CLAUDE.md
@@ -96,8 +96,8 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -96,8 +96,8 @@ plugins (incl. ref) depend on: api/api-v1 only
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 - **15 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. 98 - **15 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99 -- **153 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.  
100 -- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. 99 +- **163 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.
  100 +- **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 - **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** is the first PBC to consume one cross-PBC facade (`CatalogApi`); **pbc-orders-sales** is the first to consume *two simultaneously* (`PartnersApi` + `CatalogApi`) in one transaction. The Gradle build still refuses any direct dependency between PBCs — Spring DI wires the interfaces to their concrete adapters at runtime. 101 - **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** is the first PBC to consume one cross-PBC facade (`CatalogApi`); **pbc-orders-sales** is the first to consume *two simultaneously* (`PartnersApi` + `CatalogApi`) in one transaction. The Gradle build still refuses any direct dependency between PBCs — Spring DI wires the interfaces to their concrete adapters at runtime.
102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 - **Package root** is `org.vibeerp`. 103 - **Package root** is `org.vibeerp`.
PROGRESS.md
@@ -10,27 +10,27 @@ @@ -10,27 +10,27 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.11 (post-P5.5) |  
14 -| **Latest commit** | `a8eb2a6 feat(pbc): P5.5 — pbc-orders-sales + first PBC consuming TWO cross-PBC facades` | 13 +| **Latest version** | v0.12 (post-P4.3) |
  14 +| **Latest commit** | `feat(security): P4.3 — @RequirePermission + JWT roles claim + AOP enforcement` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 | **Modules** | 15 | 16 | **Modules** | 15 |
17 -| **Unit tests** | 153, all green |  
18 -| **End-to-end smoke runs** | All cross-cutting services + all 5 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; sales orders validate customer + items via the cross-PBC seams in one transaction; state machine DRAFT → CONFIRMED → CANCELLED enforced | 17 +| **Unit tests** | 163, all green |
  18 +| **End-to-end smoke runs** | All cross-cutting services + all 5 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; sales orders validate via three cross-PBC seams; **`@RequirePermission` enforces 403 on non-admin users hitting protected endpoints** |
19 | **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) | 19 | **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; Tier 1 customization live; first business workflow PBC online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader + custom-field validator, ICU4J translator). Five real PBCs (identity, catalog, partners, inventory, orders-sales) validate the modular-monolith template across five different aggregate shapes. **pbc-orders-sales is the first PBC to consume TWO cross-PBC facades simultaneously**: it injects `PartnersApi` to resolve the customer AND `CatalogApi` to resolve every line's item, in the same transaction, while still having no compile-time dependency on either source PBC. The Gradle build refuses any direct dependency between PBCs; Spring DI wires the interfaces to their concrete adapters at runtime. State machine DRAFT → CONFIRMED → CANCELLED is enforced; CONFIRMED orders are immutable except for cancellation. 25 +**Foundation complete; Tier 1 customization live; authorization enforced.** All eight cross-cutting platform services are live, plus the **authorization layer (P4.3)**: `@RequirePermission` annotations on controller methods are enforced by a Spring AOP aspect that consults a `PermissionEvaluator` reading the JWT's `roles` claim from a per-request `AuthorizationContext`. The framework can now distinguish "authenticated but not authorized" (403) from "not authenticated" (401). Five real PBCs validate the modular-monolith template; pbc-orders-sales remains the most rigorous test (consumes two cross-PBC facades in one transaction). State machine DRAFT → CONFIRMED → CANCELLED is enforced.
26 26
27 -The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the stock movement ledger that pairs with sales-order shipping, the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA. 27 +The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the stock movement ledger that pairs with sales-order shipping, the workflow engine (Flowable), and eventually the React SPA.
28 28
29 ## Total scope (the v1.0 cut line) 29 ## Total scope (the v1.0 cut line)
30 30
31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32
33 -That target breaks down into roughly 30 work units across 8 phases. About **20 are done** as of today. Below is the full list with status. 33 +That target breaks down into roughly 30 work units across 8 phases. About **21 are done** as of today. Below is the full list with status.
34 34
35 ### Phase 1 — Platform completion (foundation) 35 ### Phase 1 — Platform completion (foundation)
36 36
@@ -72,7 +72,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **20 a @@ -72,7 +72,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **20 a
72 |---|---|---| 72 |---|---|---|
73 | P4.1 | Built-in JWT auth + Argon2id + bootstrap admin | ✅ DONE — `540d916` | 73 | P4.1 | Built-in JWT auth + Argon2id + bootstrap admin | ✅ DONE — `540d916` |
74 | P4.2 | OIDC integration (Keycloak-compatible) | 🔜 Pending | 74 | P4.2 | OIDC integration (Keycloak-compatible) | 🔜 Pending |
75 -| P4.3 | Permission checking against `metadata__role_permission` | 🔜 Pending | 75 +| P4.3 | Permission checking against `metadata__role_permission` | ✅ DONE — `<this commit>` |
76 76
77 ### Phase 5 — Core PBCs 77 ### Phase 5 — Core PBCs
78 78
@@ -121,6 +121,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -121,6 +121,7 @@ These are the cross-cutting platform services already wired into the running fra
121 | Service | Module | What it does | 121 | Service | Module | What it does |
122 |---|---|---| 122 |---|---|---|
123 | **Auth** (P4.1) | `platform-security`, `pbc-identity` | Username/password login → HMAC-SHA256 JWT (15min access + 7d refresh). Bootstrap admin printed to logs on first boot. Spring Security filter chain enforces auth on every endpoint except `/actuator/health`, `/api/v1/_meta/**`, `/api/v1/auth/login`, `/api/v1/auth/refresh`. Principal bridges into the audit listener so `created_by` columns carry real user UUIDs. | 123 | **Auth** (P4.1) | `platform-security`, `pbc-identity` | Username/password login → HMAC-SHA256 JWT (15min access + 7d refresh). Bootstrap admin printed to logs on first boot. Spring Security filter chain enforces auth on every endpoint except `/actuator/health`, `/api/v1/_meta/**`, `/api/v1/auth/login`, `/api/v1/auth/refresh`. Principal bridges into the audit listener so `created_by` columns carry real user UUIDs. |
  124 +| **Authorization** (P4.3) | `platform-security.authz` | `@RequirePermission("partners.partner.deactivate")` on controller methods, enforced by a Spring AOP `@Aspect`. JWT access tokens carry a `roles` claim populated from `identity__user_role` at login time. `PrincipalContextFilter` populates a per-request `AuthorizationContext` with the principal's role set; the aspect consults `PermissionEvaluator.has(roles, key)` which special-cases the wildcard `admin` role and otherwise reads `metadata__role_permission`. Failed checks throw `PermissionDeniedException` → 403 with the offending key in the body. The bootstrap admin gets the `admin` role on first boot; non-admin users with no role assignments get 403 on every protected endpoint. |
124 | **Plug-in HTTP** (P1.3) | `platform-plugins` | Plug-ins call `context.endpoints.register(method, path, handler)` to mount lambdas under `/api/v1/plugins/<plugin-id>/<path>`. Path templates with `{var}` extraction via Spring's `AntPathMatcher`. Plug-in code never imports Spring MVC types. | 125 | **Plug-in HTTP** (P1.3) | `platform-plugins` | Plug-ins call `context.endpoints.register(method, path, handler)` to mount lambdas under `/api/v1/plugins/<plugin-id>/<path>`. Path templates with `{var}` extraction via Spring's `AntPathMatcher`. Plug-in code never imports Spring MVC types. |
125 | **Plug-in linter** (P1.2) | `platform-plugins` | At plug-in load time (before any plug-in code runs), ASM-walks every `.class` entry for references to `org.vibeerp.platform.*` or `org.vibeerp.pbc.*`. Forbidden references unload the plug-in with a per-class violation report. | 126 | **Plug-in linter** (P1.2) | `platform-plugins` | At plug-in load time (before any plug-in code runs), ASM-walks every `.class` entry for references to `org.vibeerp.platform.*` or `org.vibeerp.pbc.*`. Forbidden references unload the plug-in with a per-class violation report. |
126 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. | 127 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. |
@@ -169,7 +170,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, @@ -169,7 +170,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
169 - **File store.** No abstraction; no S3 backend. 170 - **File store.** No abstraction; no S3 backend.
170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home. 171 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. 172 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
172 -- **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3.  
173 - **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending. 173 - **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending.
174 - **Sales-order shipping flow.** Sales orders can be created and confirmed but cannot yet *ship* — that requires the inventory movement ledger (deferred from P5.3) so the framework can atomically debit stock when an order ships. 174 - **Sales-order shipping flow.** Sales orders can be created and confirmed but cannot yet *ship* — that requires the inventory movement ledger (deferred from P5.3) so the framework can atomically debit stock when an order ships.
175 - **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up that will also unlock sales-order shipping. 175 - **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up that will also unlock sales-order shipping.
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 15 modules, runs 153 unit tests) 80 +# Build everything (compiles 15 modules, runs 163 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -97,9 +97,9 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -97,9 +97,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 | Modules | 15 | 99 | Modules | 15 |
100 -| Unit tests | 153, all green | 100 +| Unit tests | 163, all green |
101 | Real PBCs | 5 of 10 | 101 | Real PBCs | 5 of 10 |
102 -| Cross-cutting services live | 8 | 102 +| Cross-cutting services live | 9 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
105 105
gradle/libs.versions.toml
@@ -19,6 +19,7 @@ assertk = &quot;0.28.1&quot; @@ -19,6 +19,7 @@ assertk = &quot;0.28.1&quot;
19 spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springBoot" } 19 spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springBoot" }
20 spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springBoot" } 20 spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springBoot" }
21 spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "springBoot" } 21 spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "springBoot" }
  22 +spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springBoot" }
22 spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" } 23 spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" }
23 spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" } 24 spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" }
24 spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springBoot" } 25 spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springBoot" }
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt
@@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional @@ -6,6 +6,7 @@ import org.springframework.transaction.annotation.Transactional
6 import org.vibeerp.pbc.identity.domain.User 6 import org.vibeerp.pbc.identity.domain.User
7 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository 7 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
8 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository 8 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  9 +import org.vibeerp.pbc.identity.infrastructure.UserRoleJpaRepository
9 import org.vibeerp.platform.security.AuthenticationFailedException 10 import org.vibeerp.platform.security.AuthenticationFailedException
10 import org.vibeerp.platform.security.IssuedToken 11 import org.vibeerp.platform.security.IssuedToken
11 import org.vibeerp.platform.security.JwtIssuer 12 import org.vibeerp.platform.security.JwtIssuer
@@ -35,6 +36,7 @@ import org.vibeerp.platform.security.JwtVerifier @@ -35,6 +36,7 @@ import org.vibeerp.platform.security.JwtVerifier
35 class AuthService( 36 class AuthService(
36 private val users: UserJpaRepository, 37 private val users: UserJpaRepository,
37 private val credentials: UserCredentialJpaRepository, 38 private val credentials: UserCredentialJpaRepository,
  39 + private val userRoles: UserRoleJpaRepository,
38 private val passwordEncoder: PasswordEncoder, 40 private val passwordEncoder: PasswordEncoder,
39 private val jwtIssuer: JwtIssuer, 41 private val jwtIssuer: JwtIssuer,
40 private val jwtVerifier: JwtVerifier, 42 private val jwtVerifier: JwtVerifier,
@@ -65,7 +67,13 @@ class AuthService( @@ -65,7 +67,13 @@ class AuthService(
65 } 67 }
66 68
67 private fun mintTokenPair(user: User): TokenPair { 69 private fun mintTokenPair(user: User): TokenPair {
68 - val access = jwtIssuer.issueAccessToken(user.id, user.username) 70 + // Look up the user's CURRENT roles. The set is encoded as a
  71 + // JWT claim on the access token; refresh tokens never carry
  72 + // roles, so a refresh always reaches back here and picks up
  73 + // the latest set (a role revocation propagates within one
  74 + // access-token lifetime — see JwtIssuer.issueAccessToken).
  75 + val roles = userRoles.findRoleCodesByUserId(user.id).toSet()
  76 + val access = jwtIssuer.issueAccessToken(user.id, user.username, roles)
69 val refresh = jwtIssuer.issueRefreshToken(user.id, user.username) 77 val refresh = jwtIssuer.issueRefreshToken(user.id, user.username)
70 return TokenPair(access, refresh) 78 return TokenPair(access, refresh)
71 } 79 }
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt
@@ -5,10 +5,14 @@ import org.springframework.boot.CommandLineRunner @@ -5,10 +5,14 @@ import org.springframework.boot.CommandLineRunner
5 import org.springframework.security.crypto.password.PasswordEncoder 5 import org.springframework.security.crypto.password.PasswordEncoder
6 import org.springframework.stereotype.Component 6 import org.springframework.stereotype.Component
7 import org.springframework.transaction.annotation.Transactional 7 import org.springframework.transaction.annotation.Transactional
  8 +import org.vibeerp.pbc.identity.domain.Role
8 import org.vibeerp.pbc.identity.domain.User 9 import org.vibeerp.pbc.identity.domain.User
9 import org.vibeerp.pbc.identity.domain.UserCredential 10 import org.vibeerp.pbc.identity.domain.UserCredential
  11 +import org.vibeerp.pbc.identity.domain.UserRole
  12 +import org.vibeerp.pbc.identity.infrastructure.RoleJpaRepository
10 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository 13 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
11 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository 14 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  15 +import org.vibeerp.pbc.identity.infrastructure.UserRoleJpaRepository
12 import org.vibeerp.platform.persistence.security.PrincipalContext 16 import org.vibeerp.platform.persistence.security.PrincipalContext
13 import java.security.SecureRandom 17 import java.security.SecureRandom
14 import java.time.Instant 18 import java.time.Instant
@@ -42,6 +46,8 @@ import java.time.Instant @@ -42,6 +46,8 @@ import java.time.Instant
42 class BootstrapAdminInitializer( 46 class BootstrapAdminInitializer(
43 private val users: UserJpaRepository, 47 private val users: UserJpaRepository,
44 private val credentials: UserCredentialJpaRepository, 48 private val credentials: UserCredentialJpaRepository,
  49 + private val roles: RoleJpaRepository,
  50 + private val userRoles: UserRoleJpaRepository,
45 private val passwordEncoder: PasswordEncoder, 51 private val passwordEncoder: PasswordEncoder,
46 ) : CommandLineRunner { 52 ) : CommandLineRunner {
47 53
@@ -77,6 +83,23 @@ class BootstrapAdminInitializer( @@ -77,6 +83,23 @@ class BootstrapAdminInitializer(
77 ), 83 ),
78 ) 84 )
79 85
  86 + // Create the wildcard `admin` role and grant it to the
  87 + // bootstrap admin (P4.3 — authorization). The role exists
  88 + // even if no other roles are seeded; it's the only one
  89 + // the framework promises to be present from boot. The
  90 + // permission evaluator special-cases this role code and
  91 + // grants every permission key — see PermissionEvaluator
  92 + // and the rationale on Role.ADMIN_CODE.
  93 + val adminRole = roles.findByCode(Role.ADMIN_CODE) ?: roles.save(
  94 + Role(
  95 + code = Role.ADMIN_CODE,
  96 + name = "Administrator",
  97 + description = "Bootstrap administrator role with every permission. Treat as a break-glass " +
  98 + "account; create least-privilege roles for day-to-day operators.",
  99 + ),
  100 + )
  101 + userRoles.save(UserRole(userId = savedAdmin.id, roleId = adminRole.id))
  102 +
80 // Banner-style log lines so the operator can spot it in 103 // Banner-style log lines so the operator can spot it in
81 // verbose boot output. The password is intentionally on its 104 // verbose boot output. The password is intentionally on its
82 // own line for easy copy-paste. 105 // own line for easy copy-paste.
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/Role.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Table
  6 +import org.hibernate.annotations.JdbcTypeCode
  7 +import org.hibernate.type.SqlTypes
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +
  10 +/**
  11 + * A named bundle of permissions that a user can be assigned.
  12 + *
  13 + * **Why roles live in pbc-identity, not in platform-security:**
  14 + * the *capability* of "check whether the current user has permission X"
  15 + * is a framework primitive (it lives in platform-security with the
  16 + * `@RequirePermission` aspect and the `PermissionEvaluator`). The
  17 + * *data* of "what roles exist and which user has which" is identity
  18 + * domain data — same shape as the user table itself, same lifecycle,
  19 + * same audit trail. Splitting them keeps the framework primitive
  20 + * stateless and lets the SPA's role editor (P3.x customisation UI)
  21 + * use the standard CRUD machinery.
  22 + *
  23 + * **Why role → permission mapping is NOT on this entity:**
  24 + * permissions are declared by PBCs as part of their YAML metadata
  25 + * (`permissions:` block). The role → permission link is metadata
  26 + * (managed in `metadata__role_permission`), not business data.
  27 + * Customers can edit it through the SPA's role editor without
  28 + * touching this table.
  29 + *
  30 + * **Reserved role code: `admin`.** A role with code `admin` is
  31 + * special-cased by [org.vibeerp.platform.security.authz.PermissionEvaluator]
  32 + * as having every permission, including ones declared by plug-ins
  33 + * loaded after the admin user logged in. The wildcard exists so
  34 + * the bootstrap admin can do everything from the very first boot
  35 + * without the operator first having to author a complete role
  36 + * → permission mapping.
  37 + *
  38 + * The `ext` JSONB column is for Tier 1 customisation (P3.4) — a key
  39 + * user can add a `description_long` or `notify_on_assignment` field
  40 + * via the metadata layer without patching this PBC.
  41 + */
  42 +@Entity
  43 +@Table(name = "identity__role")
  44 +class Role(
  45 + code: String,
  46 + name: String,
  47 + description: String? = null,
  48 +) : AuditedJpaEntity() {
  49 +
  50 + @Column(name = "code", nullable = false, length = 64)
  51 + var code: String = code
  52 +
  53 + @Column(name = "name", nullable = false, length = 256)
  54 + var name: String = name
  55 +
  56 + @Column(name = "description", nullable = true)
  57 + var description: String? = description
  58 +
  59 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  60 + @JdbcTypeCode(SqlTypes.JSON)
  61 + var ext: String = "{}"
  62 +
  63 + override fun toString(): String = "Role(id=$id, code='$code')"
  64 +
  65 + companion object {
  66 + /**
  67 + * The reserved code for the wildcard administrator role. The
  68 + * permission evaluator short-circuits this code to "always
  69 + * grant", so the bootstrap admin can do everything without a
  70 + * complete role → permission mapping seeded ahead of time.
  71 + */
  72 + const val ADMIN_CODE: String = "admin"
  73 + }
  74 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserRole.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Table
  6 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Join entity for the many-to-many between [User] and [Role].
  11 + *
  12 + * **Why a separate JPA entity** instead of a JPA `@ManyToMany` on
  13 + * [User] / [Role]:
  14 + * - The join table has its own audit columns (created_by tells you
  15 + * who granted this role to this user, not when the user or role
  16 + * was created). A `@ManyToMany` collection would force Hibernate
  17 + * to manage the row without those columns being visible.
  18 + * - Future expansion (effective dates, scope qualifiers, conditional
  19 + * grants) drops in as new columns on this entity without having
  20 + * to migrate the model.
  21 + * - Querying "who has this role?" / "what roles does this user
  22 + * have?" is a single repository call, not a `JOIN FETCH` dance.
  23 + *
  24 + * The unique index `(user_id, role_id)` in the schema prevents the
  25 + * same role from being granted twice to the same user. Application
  26 + * code rejects re-grants up-front to give a meaningful error.
  27 + */
  28 +@Entity
  29 +@Table(name = "identity__user_role")
  30 +class UserRole(
  31 + userId: UUID,
  32 + roleId: UUID,
  33 +) : AuditedJpaEntity() {
  34 +
  35 + @Column(name = "user_id", nullable = false)
  36 + var userId: UUID = userId
  37 +
  38 + @Column(name = "role_id", nullable = false)
  39 + var roleId: UUID = roleId
  40 +
  41 + override fun toString(): String =
  42 + "UserRole(id=$id, user=$userId, role=$roleId)"
  43 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/RoleJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.identity.domain.Role
  6 +import java.util.UUID
  7 +
  8 +@Repository
  9 +interface RoleJpaRepository : JpaRepository<Role, UUID> {
  10 +
  11 + fun findByCode(code: String): Role?
  12 +
  13 + fun existsByCode(code: String): Boolean
  14 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserRoleJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.data.jpa.repository.Query
  5 +import org.springframework.data.repository.query.Param
  6 +import org.springframework.stereotype.Repository
  7 +import org.vibeerp.pbc.identity.domain.UserRole
  8 +import java.util.UUID
  9 +
  10 +@Repository
  11 +interface UserRoleJpaRepository : JpaRepository<UserRole, UUID> {
  12 +
  13 + fun findByUserId(userId: UUID): List<UserRole>
  14 +
  15 + fun existsByUserIdAndRoleId(userId: UUID, roleId: UUID): Boolean
  16 +
  17 + /**
  18 + * Return the codes of every role currently assigned to the user.
  19 + *
  20 + * Used by [org.vibeerp.pbc.identity.application.AuthService.login]
  21 + * at access-token mint time to encode the roles claim, and by
  22 + * [org.vibeerp.pbc.identity.application.UserService.findUserRoles]
  23 + * for the SPA's user-detail screen. Single SQL with a JOIN, so
  24 + * cheaper than fetching the join rows and then resolving each
  25 + * role one-by-one.
  26 + */
  27 + @Query(
  28 + """
  29 + SELECT r.code
  30 + FROM Role r
  31 + WHERE r.id IN (SELECT ur.roleId FROM UserRole ur WHERE ur.userId = :userId)
  32 + """,
  33 + )
  34 + fun findRoleCodesByUserId(@Param("userId") userId: UUID): List<String>
  35 +}
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt
@@ -13,6 +13,7 @@ import org.vibeerp.pbc.identity.domain.User @@ -13,6 +13,7 @@ import org.vibeerp.pbc.identity.domain.User
13 import org.vibeerp.pbc.identity.domain.UserCredential 13 import org.vibeerp.pbc.identity.domain.UserCredential
14 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository 14 import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
15 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository 15 import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  16 +import org.vibeerp.pbc.identity.infrastructure.UserRoleJpaRepository
16 import org.vibeerp.platform.security.AuthenticationFailedException 17 import org.vibeerp.platform.security.AuthenticationFailedException
17 import org.vibeerp.platform.security.DecodedToken 18 import org.vibeerp.platform.security.DecodedToken
18 import org.vibeerp.platform.security.IssuedToken 19 import org.vibeerp.platform.security.IssuedToken
@@ -26,6 +27,7 @@ class AuthServiceTest { @@ -26,6 +27,7 @@ class AuthServiceTest {
26 27
27 private lateinit var users: UserJpaRepository 28 private lateinit var users: UserJpaRepository
28 private lateinit var credentials: UserCredentialJpaRepository 29 private lateinit var credentials: UserCredentialJpaRepository
  30 + private lateinit var userRoles: UserRoleJpaRepository
29 private lateinit var passwordEncoder: PasswordEncoder 31 private lateinit var passwordEncoder: PasswordEncoder
30 private lateinit var jwtIssuer: JwtIssuer 32 private lateinit var jwtIssuer: JwtIssuer
31 private lateinit var jwtVerifier: JwtVerifier 33 private lateinit var jwtVerifier: JwtVerifier
@@ -35,10 +37,14 @@ class AuthServiceTest { @@ -35,10 +37,14 @@ class AuthServiceTest {
35 fun setUp() { 37 fun setUp() {
36 users = mockk() 38 users = mockk()
37 credentials = mockk() 39 credentials = mockk()
  40 + userRoles = mockk()
38 passwordEncoder = mockk() 41 passwordEncoder = mockk()
39 jwtIssuer = mockk() 42 jwtIssuer = mockk()
40 jwtVerifier = mockk() 43 jwtVerifier = mockk()
41 - service = AuthService(users, credentials, passwordEncoder, jwtIssuer, jwtVerifier) 44 + // Default: user has no roles. Tests that care about roles
  45 + // override this expectation.
  46 + every { userRoles.findRoleCodesByUserId(any()) } returns emptyList()
  47 + service = AuthService(users, credentials, userRoles, passwordEncoder, jwtIssuer, jwtVerifier)
42 } 48 }
43 49
44 private fun aliceUser(id: UUID = UUID.randomUUID(), enabled: Boolean = true): User { 50 private fun aliceUser(id: UUID = UUID.randomUUID(), enabled: Boolean = true): User {
@@ -49,7 +55,7 @@ class AuthServiceTest { @@ -49,7 +55,7 @@ class AuthServiceTest {
49 55
50 private fun stubMint(userId: UUID, username: String) { 56 private fun stubMint(userId: UUID, username: String) {
51 val now = Instant.now() 57 val now = Instant.now()
52 - every { jwtIssuer.issueAccessToken(userId, username) } returns 58 + every { jwtIssuer.issueAccessToken(userId, username, any()) } returns
53 IssuedToken("access-$userId", now.plusSeconds(900)) 59 IssuedToken("access-$userId", now.plusSeconds(900))
54 every { jwtIssuer.issueRefreshToken(userId, username) } returns 60 every { jwtIssuer.issueRefreshToken(userId, username) } returns
55 IssuedToken("refresh-$userId", now.plusSeconds(86400 * 7)) 61 IssuedToken("refresh-$userId", now.plusSeconds(86400 * 7))
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockBalanceController.kt
@@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam @@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestParam
12 import org.springframework.web.bind.annotation.RestController 12 import org.springframework.web.bind.annotation.RestController
13 import org.vibeerp.pbc.inventory.application.StockBalanceService 13 import org.vibeerp.pbc.inventory.application.StockBalanceService
14 import org.vibeerp.pbc.inventory.domain.StockBalance 14 import org.vibeerp.pbc.inventory.domain.StockBalance
  15 +import org.vibeerp.platform.security.authz.RequirePermission
15 import java.math.BigDecimal 16 import java.math.BigDecimal
16 import java.util.UUID 17 import java.util.UUID
17 18
@@ -51,6 +52,7 @@ class StockBalanceController( @@ -51,6 +52,7 @@ class StockBalanceController(
51 * Returns the resulting balance. 52 * Returns the resulting balance.
52 */ 53 */
53 @PostMapping("/adjust") 54 @PostMapping("/adjust")
  55 + @RequirePermission("inventory.stock.adjust")
54 fun adjust(@RequestBody @Valid request: AdjustStockBalanceRequest): StockBalanceResponse = 56 fun adjust(@RequestBody @Valid request: AdjustStockBalanceRequest): StockBalanceResponse =
55 stockBalanceService.adjust( 57 stockBalanceService.adjust(
56 itemCode = request.itemCode, 58 itemCode = request.itemCode,
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt
@@ -22,6 +22,7 @@ import org.vibeerp.pbc.orders.sales.application.UpdateSalesOrderCommand @@ -22,6 +22,7 @@ import org.vibeerp.pbc.orders.sales.application.UpdateSalesOrderCommand
22 import org.vibeerp.pbc.orders.sales.domain.SalesOrder 22 import org.vibeerp.pbc.orders.sales.domain.SalesOrder
23 import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine 23 import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine
24 import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus 24 import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus
  25 +import org.vibeerp.platform.security.authz.RequirePermission
25 import java.math.BigDecimal 26 import java.math.BigDecimal
26 import java.time.LocalDate 27 import java.time.LocalDate
27 import java.util.UUID 28 import java.util.UUID
@@ -97,10 +98,12 @@ class SalesOrderController( @@ -97,10 +98,12 @@ class SalesOrderController(
97 ).toResponse(salesOrderService) 98 ).toResponse(salesOrderService)
98 99
99 @PostMapping("/{id}/confirm") 100 @PostMapping("/{id}/confirm")
  101 + @RequirePermission("orders.sales.confirm")
100 fun confirm(@PathVariable id: UUID): SalesOrderResponse = 102 fun confirm(@PathVariable id: UUID): SalesOrderResponse =
101 salesOrderService.confirm(id).toResponse(salesOrderService) 103 salesOrderService.confirm(id).toResponse(salesOrderService)
102 104
103 @PostMapping("/{id}/cancel") 105 @PostMapping("/{id}/cancel")
  106 + @RequirePermission("orders.sales.cancel")
104 fun cancel(@PathVariable id: UUID): SalesOrderResponse = 107 fun cancel(@PathVariable id: UUID): SalesOrderResponse =
105 salesOrderService.cancel(id).toResponse(salesOrderService) 108 salesOrderService.cancel(id).toResponse(salesOrderService)
106 } 109 }
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt
@@ -19,6 +19,7 @@ import org.vibeerp.pbc.partners.application.PartnerService @@ -19,6 +19,7 @@ import org.vibeerp.pbc.partners.application.PartnerService
19 import org.vibeerp.pbc.partners.application.UpdatePartnerCommand 19 import org.vibeerp.pbc.partners.application.UpdatePartnerCommand
20 import org.vibeerp.pbc.partners.domain.Partner 20 import org.vibeerp.pbc.partners.domain.Partner
21 import org.vibeerp.pbc.partners.domain.PartnerType 21 import org.vibeerp.pbc.partners.domain.PartnerType
  22 +import org.vibeerp.platform.security.authz.RequirePermission
22 import java.util.UUID 23 import java.util.UUID
23 24
24 /** 25 /**
@@ -92,6 +93,7 @@ class PartnerController( @@ -92,6 +93,7 @@ class PartnerController(
92 93
93 @DeleteMapping("/{id}") 94 @DeleteMapping("/{id}")
94 @ResponseStatus(HttpStatus.NO_CONTENT) 95 @ResponseStatus(HttpStatus.NO_CONTENT)
  96 + @RequirePermission("partners.partner.deactivate")
95 fun deactivate(@PathVariable id: UUID) { 97 fun deactivate(@PathVariable id: UUID) {
96 partnerService.deactivate(id) 98 partnerService.deactivate(id)
97 } 99 }
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
@@ -6,6 +6,7 @@ import org.springframework.http.ProblemDetail @@ -6,6 +6,7 @@ import org.springframework.http.ProblemDetail
6 import org.springframework.web.bind.annotation.ExceptionHandler 6 import org.springframework.web.bind.annotation.ExceptionHandler
7 import org.springframework.web.bind.annotation.RestControllerAdvice 7 import org.springframework.web.bind.annotation.RestControllerAdvice
8 import org.vibeerp.platform.security.AuthenticationFailedException 8 import org.vibeerp.platform.security.AuthenticationFailedException
  9 +import org.vibeerp.platform.security.authz.PermissionDeniedException
9 import java.time.Instant 10 import java.time.Instant
10 11
11 /** 12 /**
@@ -67,6 +68,25 @@ class GlobalExceptionHandler { @@ -67,6 +68,25 @@ class GlobalExceptionHandler {
67 } 68 }
68 69
69 /** 70 /**
  71 + * The authenticated principal lacks the permission required by a
  72 + * `@RequirePermission`-annotated method (P4.3 — authorization).
  73 + *
  74 + * Returns 403 Forbidden with the offending permission key in the
  75 + * body. Unlike the 401 above, we DO surface the key to the caller
  76 + * because:
  77 + * - The caller is already authenticated, so this is not an
  78 + * enumeration vector.
  79 + * - The SPA needs the key to render a useful "your role doesn't
  80 + * include `partners.partner.deactivate`" message instead of a
  81 + * bare 403.
  82 + * - Operators reading server logs can grep for the key + the
  83 + * user from the aspect's WARN line.
  84 + */
  85 + @ExceptionHandler(PermissionDeniedException::class)
  86 + fun handlePermissionDenied(ex: PermissionDeniedException): ProblemDetail =
  87 + problem(HttpStatus.FORBIDDEN, "permission denied: '${ex.permissionKey}'")
  88 +
  89 + /**
70 * Last-resort fallback. Anything not handled above is logged with a 90 * Last-resort fallback. Anything not handled above is logged with a
71 * full stack trace and surfaced as a generic 500 to the caller. The 91 * full stack trace and surfaced as a generic 500 to the caller. The
72 * detail message is intentionally vague so we don't leak internals. 92 * detail message is intentionally vague so we don't leak internals.
platform/platform-plugins/build.gradle.kts
@@ -27,6 +27,7 @@ dependencies { @@ -27,6 +27,7 @@ dependencies {
27 implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext 27 implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext
28 implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) 28 implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...)
29 implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider 29 implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider
  30 + implementation(project(":platform:platform-security")) // for PermissionEvaluator refresh wiring
30 31
31 implementation(libs.spring.boot.starter) 32 implementation(libs.spring.boot.starter)
32 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher 33 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
@@ -13,6 +13,7 @@ import org.vibeerp.api.v1.plugin.PluginJdbc @@ -13,6 +13,7 @@ import org.vibeerp.api.v1.plugin.PluginJdbc
13 import org.vibeerp.platform.i18n.IcuTranslator 13 import org.vibeerp.platform.i18n.IcuTranslator
14 import org.vibeerp.platform.metadata.MetadataLoader 14 import org.vibeerp.platform.metadata.MetadataLoader
15 import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry 15 import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry
  16 +import org.vibeerp.platform.security.authz.PermissionEvaluator
16 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry 17 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
17 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar 18 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
18 import org.vibeerp.platform.plugins.lint.PluginLinter 19 import org.vibeerp.platform.plugins.lint.PluginLinter
@@ -62,6 +63,7 @@ class VibeErpPluginManager( @@ -62,6 +63,7 @@ class VibeErpPluginManager(
62 private val liquibaseRunner: PluginLiquibaseRunner, 63 private val liquibaseRunner: PluginLiquibaseRunner,
63 private val metadataLoader: MetadataLoader, 64 private val metadataLoader: MetadataLoader,
64 private val customFieldRegistry: CustomFieldRegistry, 65 private val customFieldRegistry: CustomFieldRegistry,
  66 + private val permissionEvaluator: PermissionEvaluator,
65 private val localeProvider: LocaleProvider, 67 private val localeProvider: LocaleProvider,
66 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { 68 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
67 69
@@ -99,6 +101,18 @@ class VibeErpPluginManager( @@ -99,6 +101,18 @@ class VibeErpPluginManager(
99 ) 101 )
100 } 102 }
101 103
  104 + // Refresh the permission evaluator's role → permissions cache
  105 + // for the same reason — every authenticated request consults
  106 + // it, so it has to be populated before any traffic flows.
  107 + try {
  108 + permissionEvaluator.refresh()
  109 + } catch (ex: Throwable) {
  110 + log.error(
  111 + "VibeErpPluginManager: PermissionEvaluator initial refresh failed; non-admin users will be denied",
  112 + ex,
  113 + )
  114 + }
  115 +
102 if (!properties.autoLoad) { 116 if (!properties.autoLoad) {
103 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") 117 log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan")
104 return 118 return
@@ -196,6 +210,17 @@ class VibeErpPluginManager( @@ -196,6 +210,17 @@ class VibeErpPluginManager(
196 ) 210 )
197 } 211 }
198 212
  213 + // Same for the permission evaluator: plug-ins may have
  214 + // contributed their own role-permission rows.
  215 + try {
  216 + permissionEvaluator.refresh()
  217 + } catch (ex: Throwable) {
  218 + log.warn(
  219 + "VibeErpPluginManager: PermissionEvaluator post-plug-in refresh failed; the role-permission map may be stale",
  220 + ex,
  221 + )
  222 + }
  223 +
199 startPlugins() 224 startPlugins()
200 225
201 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()` 226 // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`
platform/platform-security/build.gradle.kts
@@ -30,12 +30,16 @@ dependencies { @@ -30,12 +30,16 @@ dependencies {
30 implementation(libs.spring.boot.starter.web) 30 implementation(libs.spring.boot.starter.web)
31 implementation(libs.spring.boot.starter.security) 31 implementation(libs.spring.boot.starter.security)
32 implementation(libs.spring.boot.starter.oauth2.resource.server) 32 implementation(libs.spring.boot.starter.oauth2.resource.server)
  33 + implementation(libs.spring.boot.starter.data.jpa) // for NamedParameterJdbcTemplate (PermissionEvaluator)
  34 + implementation(libs.spring.boot.starter.aop) // for @RequirePermission aspect
33 implementation(libs.spring.security.oauth2.jose) 35 implementation(libs.spring.security.oauth2.jose)
  36 + implementation(libs.jackson.module.kotlin) // for PermissionEvaluator payload parsing
34 runtimeOnly(libs.bouncycastle) // required by Argon2PasswordEncoder 37 runtimeOnly(libs.bouncycastle) // required by Argon2PasswordEncoder
35 38
36 testImplementation(libs.spring.boot.starter.test) 39 testImplementation(libs.spring.boot.starter.test)
37 testImplementation(libs.junit.jupiter) 40 testImplementation(libs.junit.jupiter)
38 testImplementation(libs.assertk) 41 testImplementation(libs.assertk)
  42 + testImplementation(libs.mockk)
39 } 43 }
40 44
41 tasks.test { 45 tasks.test {
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt
@@ -25,17 +25,32 @@ class JwtIssuer( @@ -25,17 +25,32 @@ class JwtIssuer(
25 private val properties: JwtProperties, 25 private val properties: JwtProperties,
26 ) { 26 ) {
27 27
28 - fun issueAccessToken(userId: UUID, username: String): IssuedToken =  
29 - issue(userId, username, type = TYPE_ACCESS, ttlSeconds = properties.accessTokenTtl.seconds) 28 + /**
  29 + * Mint an access token. The [roles] set is encoded as a `roles`
  30 + * JWT claim and read back by [JwtVerifier] on subsequent
  31 + * requests so the authorization layer can decide whether the
  32 + * caller has permission to perform the requested operation.
  33 + *
  34 + * Roles live on the access token (NOT the refresh token) because:
  35 + * - Access tokens are short-lived (15 min by default), so a role
  36 + * revocation propagates within one access-token lifetime.
  37 + * - Refresh tokens go through `AuthService.refresh` which re-reads
  38 + * the user's current roles from the database before minting
  39 + * the new access token, so a refresh always picks up the
  40 + * latest role set.
  41 + */
  42 + fun issueAccessToken(userId: UUID, username: String, roles: Set<String> = emptySet()): IssuedToken =
  43 + issue(userId, username, type = TYPE_ACCESS, ttlSeconds = properties.accessTokenTtl.seconds, roles = roles)
30 44
31 fun issueRefreshToken(userId: UUID, username: String): IssuedToken = 45 fun issueRefreshToken(userId: UUID, username: String): IssuedToken =
32 - issue(userId, username, type = TYPE_REFRESH, ttlSeconds = properties.refreshTokenTtl.seconds) 46 + issue(userId, username, type = TYPE_REFRESH, ttlSeconds = properties.refreshTokenTtl.seconds, roles = emptySet())
33 47
34 private fun issue( 48 private fun issue(
35 userId: UUID, 49 userId: UUID,
36 username: String, 50 username: String,
37 type: String, 51 type: String,
38 ttlSeconds: Long, 52 ttlSeconds: Long,
  53 + roles: Set<String>,
39 ): IssuedToken { 54 ): IssuedToken {
40 // Truncate to whole seconds because the JWT `iat` and `exp` claims 55 // Truncate to whole seconds because the JWT `iat` and `exp` claims
41 // are integer-second values (RFC 7519). Without this truncation 56 // are integer-second values (RFC 7519). Without this truncation
@@ -44,14 +59,20 @@ class JwtIssuer( @@ -44,14 +59,20 @@ class JwtIssuer(
44 // tests fail and refresh-token expiry checks become inconsistent. 59 // tests fail and refresh-token expiry checks become inconsistent.
45 val now = Instant.now().truncatedTo(ChronoUnit.SECONDS) 60 val now = Instant.now().truncatedTo(ChronoUnit.SECONDS)
46 val expiresAt = now.plusSeconds(ttlSeconds) 61 val expiresAt = now.plusSeconds(ttlSeconds)
47 - val claims = JwtClaimsSet.builder() 62 + val builder = JwtClaimsSet.builder()
48 .issuer(properties.issuer) 63 .issuer(properties.issuer)
49 .subject(userId.toString()) 64 .subject(userId.toString())
50 .issuedAt(now) 65 .issuedAt(now)
51 .expiresAt(expiresAt) 66 .expiresAt(expiresAt)
52 .claim("username", username) 67 .claim("username", username)
53 .claim("type", type) 68 .claim("type", type)
54 - .build() 69 + // Encode as a sorted list (deterministic for tests). The
  70 + // claim is omitted entirely on refresh tokens so refresh
  71 + // doesn't carry stale role information.
  72 + if (roles.isNotEmpty()) {
  73 + builder.claim("roles", roles.toSortedSet().toList())
  74 + }
  75 + val claims = builder.build()
55 val headers = JwsHeader.with(MacAlgorithm.HS256).build() 76 val headers = JwsHeader.with(MacAlgorithm.HS256).build()
56 val token = encoder.encode(JwtEncoderParameters.from(headers, claims)).tokenValue 77 val token = encoder.encode(JwtEncoderParameters.from(headers, claims)).tokenValue
57 return IssuedToken(value = token, expiresAt = expiresAt) 78 return IssuedToken(value = token, expiresAt = expiresAt)
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt
@@ -58,11 +58,17 @@ class JwtVerifier( @@ -58,11 +58,17 @@ class JwtVerifier(
58 val expiresAt = decoded.expiresAt 58 val expiresAt = decoded.expiresAt
59 ?: throw AuthenticationFailedException("token has no exp claim") 59 ?: throw AuthenticationFailedException("token has no exp claim")
60 60
  61 + // Roles are optional — refresh tokens don't carry them, and
  62 + // legacy/system tokens may not either. Missing claim → empty
  63 + // set, NOT an error.
  64 + val roles: Set<String> = decoded.getClaimAsStringList("roles")?.toSet().orEmpty()
  65 +
61 return DecodedToken( 66 return DecodedToken(
62 userId = userId, 67 userId = userId,
63 username = username, 68 username = username,
64 type = type, 69 type = type,
65 expiresAt = expiresAt, 70 expiresAt = expiresAt,
  71 + roles = roles,
66 ) 72 )
67 } 73 }
68 } 74 }
@@ -76,4 +82,11 @@ data class DecodedToken( @@ -76,4 +82,11 @@ data class DecodedToken(
76 val username: String, 82 val username: String,
77 val type: String, 83 val type: String,
78 val expiresAt: Instant, 84 val expiresAt: Instant,
  85 + /**
  86 + * The role codes the user holds, read from the access token's
  87 + * `roles` claim. Empty for refresh tokens (which never carry
  88 + * roles — see [JwtIssuer.issueRefreshToken]) and for legacy
  89 + * tokens that predate P4.3.
  90 + */
  91 + val roles: Set<String> = emptySet(),
79 ) 92 )
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt
@@ -9,28 +9,40 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut @@ -9,28 +9,40 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
9 import org.springframework.stereotype.Component 9 import org.springframework.stereotype.Component
10 import org.springframework.web.filter.OncePerRequestFilter 10 import org.springframework.web.filter.OncePerRequestFilter
11 import org.vibeerp.platform.persistence.security.PrincipalContext 11 import org.vibeerp.platform.persistence.security.PrincipalContext
  12 +import org.vibeerp.platform.security.authz.AuthorizationContext
  13 +import org.vibeerp.platform.security.authz.AuthorizedPrincipal
12 14
13 /** 15 /**
14 * Bridges Spring Security's authenticated principal into vibe_erp's 16 * Bridges Spring Security's authenticated principal into vibe_erp's
15 - * [PrincipalContext] for the duration of the request. 17 + * own thread-local contexts for the duration of the request.
16 * 18 *
17 - * Spring Security puts the authenticated `Authentication` object on a  
18 - * thread-local of its own. Our JPA audit listener — which lives in  
19 - * `platform-persistence` — must NOT depend on Spring Security types  
20 - * (it would force every PBC's compile classpath to include Spring  
21 - * Security). The bridge solves this by reading the JWT subject from  
22 - * Spring Security's context and writing it as a plain string into  
23 - * [PrincipalContext], which is in the persistence module and so  
24 - * legitimately importable by the audit listener. 19 + * The filter populates TWO thread-locals:
  20 + *
  21 + * 1. **`PrincipalContext`** (in `platform-persistence`) — carries
  22 + * just the principal id as a plain string, for the JPA audit
  23 + * listener to write into `created_by` / `updated_by`. The audit
  24 + * listener must not depend on security types, so the id is
  25 + * stringly-typed at the persistence boundary.
  26 + * 2. **`AuthorizationContext`** (in this module, P4.3) — carries the
  27 + * full `AuthorizedPrincipal` (id, username, role set) for the
  28 + * `@RequirePermission` aspect and the `PermissionEvaluator` to
  29 + * consult. Roles are read from the JWT's `roles` claim populated
  30 + * by `JwtIssuer.issueAccessToken`.
25 * 31 *
26 - * The filter is registered immediately after Spring Security's  
27 - * authentication filter so the `SecurityContext` is fully populated by  
28 - * the time we read it. 32 + * Spring Security puts the authenticated `Authentication` object on a
  33 + * thread-local of its own. The bridge solves the layering problem by
  34 + * being the one and only place that knows about Spring Security's
  35 + * type AND about both vibe_erp contexts; everything downstream uses
  36 + * just the typed-but-Spring-free abstractions.
29 * 37 *
30 - * On unauthenticated requests (the public allowlist) the  
31 - * `SecurityContext` is empty and we leave the principal unset, which  
32 - * means the audit listener falls back to its `__system__` sentinel —  
33 - * the right behavior for boot-time and migration writes. 38 + * The filter is registered immediately after Spring Security's JWT
  39 + * authentication filter (`BearerTokenAuthenticationFilter`) so the
  40 + * `SecurityContext` is fully populated by the time we read it. On
  41 + * unauthenticated requests (the public allowlist) the `SecurityContext`
  42 + * is empty and we leave both contexts unset — which means the audit
  43 + * listener falls back to its `__system__` sentinel and the
  44 + * `@RequirePermission` aspect denies any protected call (correct for
  45 + * unauth callers).
34 */ 46 */
35 @Component 47 @Component
36 class PrincipalContextFilter : OncePerRequestFilter() { 48 class PrincipalContextFilter : OncePerRequestFilter() {
@@ -41,17 +53,33 @@ class PrincipalContextFilter : OncePerRequestFilter() { @@ -41,17 +53,33 @@ class PrincipalContextFilter : OncePerRequestFilter() {
41 filterChain: FilterChain, 53 filterChain: FilterChain,
42 ) { 54 ) {
43 val auth = SecurityContextHolder.getContext().authentication 55 val auth = SecurityContextHolder.getContext().authentication
44 - val subject: String? = when (auth) {  
45 - is JwtAuthenticationToken -> (auth.principal as? Jwt)?.subject 56 + val jwt: Jwt? = when (auth) {
  57 + is JwtAuthenticationToken -> auth.principal as? Jwt
46 else -> null 58 else -> null
47 } 59 }
48 60
49 - if (subject != null) {  
50 - PrincipalContext.runAs(subject) { 61 + if (jwt == null) {
  62 + filterChain.doFilter(request, response)
  63 + return
  64 + }
  65 +
  66 + val subject = jwt.subject
  67 + val username = jwt.getClaimAsString("username") ?: subject
  68 + // Roles claim is optional — pre-P4.3 tokens don't have it
  69 + // (treat as empty), refresh-token-derived sessions wouldn't
  70 + // make it here anyway, and system tokens never carry it.
  71 + val roles: Set<String> = jwt.getClaimAsStringList("roles")?.toSet().orEmpty()
  72 +
  73 + val authorized = AuthorizedPrincipal(
  74 + id = subject,
  75 + username = username,
  76 + roles = roles,
  77 + )
  78 +
  79 + PrincipalContext.runAs(subject) {
  80 + AuthorizationContext.runAs(authorized) {
51 filterChain.doFilter(request, response) 81 filterChain.doFilter(request, response)
52 } 82 }
53 - } else {  
54 - filterChain.doFilter(request, response)  
55 } 83 }
56 } 84 }
57 } 85 }
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/authz/AuthorizationContext.kt 0 → 100644
  1 +package org.vibeerp.platform.security.authz
  2 +
  3 +/**
  4 + * Per-thread holder of the current request's principal AND their
  5 + * role set, for the [PermissionEvaluator] and `@RequirePermission`
  6 + * aspect to consult.
  7 + *
  8 + * **Why this is separate from `PrincipalContext`:**
  9 + * `org.vibeerp.platform.persistence.security.PrincipalContext` lives
  10 + * in `platform-persistence` and carries only the principal id (a
  11 + * stringly-typed value) so the JPA audit listener can write
  12 + * `created_by` / `updated_by` without depending on security types.
  13 + *
  14 + * The authorization layer needs the *full* identity — principal id
  15 + * + role set — and that data has no business inside `platform-persistence`
  16 + * (the audit listener should not know what roles a user has). A second
  17 + * thread-local in this module keeps the layering clean: persistence
  18 + * imports persistence types, security imports security types, and the
  19 + * filter that bridges from the JWT to both contexts is the only code
  20 + * that knows about the pair.
  21 + *
  22 + * **Why a `ThreadLocal`:** the `@RequirePermission` aspect runs on the
  23 + * request thread alongside the controller method, so a thread-local is
  24 + * the simplest mechanism to thread the role set through without
  25 + * re-decoding the JWT in every aspect call. Background jobs and
  26 + * scheduled tasks set their own context via [runAs] when they need to
  27 + * impersonate a user (or use the empty context to opt out of
  28 + * permission checks for system code).
  29 + *
  30 + * **What happens when nothing is set:** [current] returns `null`, and
  31 + * the `@RequirePermission` aspect treats null as "no roles, no
  32 + * permissions" — i.e. denied. System code that legitimately needs to
  33 + * bypass permission checks must use [runAs] with a context whose
  34 + * roles include `admin`, or be invoked through a code path the
  35 + * aspect doesn't intercept.
  36 + */
  37 +object AuthorizationContext {
  38 +
  39 + private val current = ThreadLocal<AuthorizedPrincipal?>()
  40 +
  41 + fun set(principal: AuthorizedPrincipal) {
  42 + current.set(principal)
  43 + }
  44 +
  45 + fun clear() {
  46 + current.remove()
  47 + }
  48 +
  49 + fun current(): AuthorizedPrincipal? = current.get()
  50 +
  51 + /**
  52 + * Run [block] with [principal] bound to the current thread,
  53 + * restoring the previous value (which is usually null) afterwards.
  54 + * The framework's `PrincipalContextFilter` uses this to scope the
  55 + * principal to the request lifetime.
  56 + */
  57 + fun <T> runAs(principal: AuthorizedPrincipal, block: () -> T): T {
  58 + val previous = current.get()
  59 + current.set(principal)
  60 + try {
  61 + return block()
  62 + } finally {
  63 + if (previous == null) current.remove() else current.set(previous)
  64 + }
  65 + }
  66 +}
  67 +
  68 +/**
  69 + * Snapshot of the authenticated principal as carried in
  70 + * [AuthorizationContext]. Read-only data, decoupled from the JWT
  71 + * type so non-security code can consume it without pulling in
  72 + * Spring Security types.
  73 + *
  74 + * @property id The principal's user-id (UUID rendered as string).
  75 + * Matches what `PrincipalContext` carries for the audit listener.
  76 + * @property username The principal's username, for log lines and
  77 + * for permission-evaluator error messages.
  78 + * @property roles The role codes the principal currently holds.
  79 + * Empty for an unauthenticated request (in which case
  80 + * [AuthorizationContext.current] is null entirely; this struct is
  81 + * not used to represent the unauthenticated case).
  82 + */
  83 +data class AuthorizedPrincipal(
  84 + val id: String,
  85 + val username: String,
  86 + val roles: Set<String>,
  87 +)
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/authz/PermissionEvaluator.kt 0 → 100644
  1 +package org.vibeerp.platform.security.authz
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  5 +import org.slf4j.LoggerFactory
  6 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  7 +import org.springframework.stereotype.Component
  8 +import java.util.concurrent.ConcurrentHashMap
  9 +
  10 +/**
  11 + * The framework's authorization decision point. Answers
  12 + * "does a principal with this role set have this permission?"
  13 + *
  14 + * **Resolution chain:**
  15 + * 1. **`admin` wildcard.** If the role set contains the reserved
  16 + * code `admin`, the answer is `true` for every permission key
  17 + * unconditionally — including ones declared by plug-ins loaded
  18 + * AFTER the user logged in. The wildcard exists so the bootstrap
  19 + * admin can do everything from the very first boot without the
  20 + * operator first having to seed a complete role-to-permission
  21 + * mapping. Customer "operations admin" / "shop manager" / etc.
  22 + * roles use the explicit mapping below; only the literal `admin`
  23 + * code is the wildcard.
  24 + * 2. **Explicit `metadata__role_permission` mapping.** Each row in
  25 + * that table has a payload of shape `{"role":"manager","permission":"orders.sales.confirm"}`.
  26 + * The evaluator caches them in an in-memory `Map<role, Set<permission>>`
  27 + * for the hot path. The cache is rebuilt by [refresh], which the
  28 + * metadata loader calls after every successful core or plug-in
  29 + * load.
  30 + * 3. **Empty set if neither matches.** No implicit grants. A user
  31 + * with no matching role gets denied; an operation with no
  32 + * declared permission key is the caller's bug, not a free pass.
  33 + *
  34 + * **Cache concurrency.** [ConcurrentHashMap] under the hood; reads
  35 + * are lock-free, writes during [refresh] are atomic per-role. The
  36 + * race window — a request hitting the evaluator while a refresh is
  37 + * in progress — is intentionally tolerated, because the user-visible
  38 + * outcome is "your check used either the old or the new role-perm
  39 + * map" and both are valid framework states.
  40 + *
  41 + * **Why this lives in `platform-security` and not in `pbc-identity`:**
  42 + * the *capability* of "answer a permission question" is a framework
  43 + * primitive that PBCs and plug-ins consume. The *data* of "what
  44 + * roles a user has" is identity domain data. Splitting them keeps
  45 + * the framework primitive stateless about identity and lets every
  46 + * PBC inject this evaluator without depending on pbc-identity.
  47 + */
  48 +@Component
  49 +class PermissionEvaluator(
  50 + private val jdbc: NamedParameterJdbcTemplate,
  51 +) {
  52 +
  53 + private val log = LoggerFactory.getLogger(PermissionEvaluator::class.java)
  54 +
  55 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  56 +
  57 + private val byRole: ConcurrentHashMap<String, Set<String>> = ConcurrentHashMap()
  58 +
  59 + /**
  60 + * Returns `true` when [roles] grants [permissionKey].
  61 + *
  62 + * - The literal `admin` role is a wildcard — short-circuits to
  63 + * `true` for any permission key.
  64 + * - Otherwise consults the cached role → permissions map.
  65 + * - An empty role set is always denied.
  66 + */
  67 + fun has(roles: Set<String>, permissionKey: String): Boolean {
  68 + if (roles.isEmpty()) return false
  69 + if (ADMIN_ROLE in roles) return true
  70 + for (role in roles) {
  71 + if (byRole[role]?.contains(permissionKey) == true) return true
  72 + }
  73 + return false
  74 + }
  75 +
  76 + /**
  77 + * Convenience overload that consults the current request's
  78 + * [AuthorizationContext]. Returns `false` when no principal is
  79 + * bound (e.g. unauth requests, background jobs that didn't set
  80 + * a context).
  81 + */
  82 + fun currentHas(permissionKey: String): Boolean {
  83 + val principal = AuthorizationContext.current() ?: return false
  84 + return has(principal.roles, permissionKey)
  85 + }
  86 +
  87 + /**
  88 + * Read every `metadata__role_permission` row from the database
  89 + * and rebuild the in-memory index. Called at startup (after the
  90 + * MetadataLoader has loaded core YAML) and after every plug-in
  91 + * load via `VibeErpPluginManager`.
  92 + *
  93 + * Tolerates rows with malformed payloads — they're logged and
  94 + * skipped, the rest of the load continues.
  95 + */
  96 + fun refresh() {
  97 + val rows = jdbc.query(
  98 + "SELECT payload FROM metadata__role_permission",
  99 + emptyMap<String, Any?>(),
  100 + ) { rs, _ -> rs.getString("payload") }
  101 +
  102 + val grouped = mutableMapOf<String, MutableSet<String>>()
  103 + var malformed = 0
  104 + for (payload in rows) {
  105 + try {
  106 + @Suppress("UNCHECKED_CAST")
  107 + val map = jsonMapper.readValue(payload, Map::class.java) as Map<String, Any?>
  108 + val role = map["role"] as? String
  109 + val permission = map["permission"] as? String
  110 + if (role.isNullOrBlank() || permission.isNullOrBlank()) {
  111 + malformed++
  112 + continue
  113 + }
  114 + grouped.getOrPut(role) { mutableSetOf() }.add(permission)
  115 + } catch (ex: Throwable) {
  116 + malformed++
  117 + log.warn("PermissionEvaluator: skipping malformed metadata__role_permission row: {}", ex.message)
  118 + }
  119 + }
  120 +
  121 + byRole.clear()
  122 + grouped.forEach { (role, perms) -> byRole[role] = perms.toSet() }
  123 +
  124 + log.info(
  125 + "PermissionEvaluator: refreshed {} role(s) covering {} permission grant(s) ({} malformed rows skipped)",
  126 + grouped.size, grouped.values.sumOf { it.size }, malformed,
  127 + )
  128 + }
  129 +
  130 + companion object {
  131 + /**
  132 + * The reserved role code that grants every permission. Kept
  133 + * in sync with `org.vibeerp.pbc.identity.domain.Role.ADMIN_CODE`
  134 + * — but duplicated here as a string constant so this module
  135 + * has no dependency on pbc-identity.
  136 + */
  137 + const val ADMIN_ROLE: String = "admin"
  138 + }
  139 +}
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/authz/RequirePermission.kt 0 → 100644
  1 +package org.vibeerp.platform.security.authz
  2 +
  3 +/**
  4 + * Method-level annotation that requires the authenticated principal
  5 + * to hold a specific permission key before the annotated method runs.
  6 + *
  7 + * Applied to controller methods (most common) or to service methods
  8 + * (when the same operation is reachable via multiple controllers).
  9 + * The framework's [RequirePermissionAspect] intercepts every annotated
  10 + * method, reads the current request's [AuthorizationContext], and
  11 + * either lets the call through or throws [PermissionDeniedException]
  12 + * (which `GlobalExceptionHandler` maps to HTTP 403 Forbidden).
  13 + *
  14 + * **Permission key convention:** `<pbc-or-plugin>.<resource>.<action>`
  15 + * (lowercase, dot-separated). Examples: `partners.partner.deactivate`,
  16 + * `inventory.stock.adjust`, `orders.sales.confirm`. The key MUST also
  17 + * be declared in the PBC's metadata YAML so the SPA's role editor and
  18 + * the OpenAPI generator know it exists; the framework does NOT
  19 + * cross-validate this at boot in v1, but the plug-in linter is the
  20 + * natural place for that check to land later.
  21 + *
  22 + * **Why permissions live on the controller, not the service:**
  23 + * - Permission keys are an HTTP-surface concern: the same business
  24 + * operation may legitimately be reachable from multiple controllers
  25 + * (REST + a future GraphQL endpoint + the AI agent's MCP tool) with
  26 + * DIFFERENT permission keys. Putting `@RequirePermission` on the
  27 + * service forces all entry points to share one key.
  28 + * - Service methods are also called from internal cross-PBC code
  29 + * paths (e.g. SalesOrderService.create reads from CatalogApi at
  30 + * runtime). The cross-PBC call is system code and should not be
  31 + * permission-gated as if it were a user action.
  32 + *
  33 + * **What this annotation does NOT do** (deliberately):
  34 + * - It does not check resource ownership ("the user owns THIS
  35 + * partner"). That requires the resource-aware permission model
  36 + * landing post-v1.
  37 + * - It does not implement role hierarchy. Roles are flat in v1; a
  38 + * role with permission X cannot inherit from a role with
  39 + * permission Y. The metadata layer makes this trivial to add by
  40 + * introducing a `parent_role` field on the role row.
  41 + * - It does not implement OR / AND combinations of permissions. A
  42 + * single key per call site keeps the contract simple. Composite
  43 + * requirements live in service code that calls
  44 + * `PermissionEvaluator.currentHas` directly.
  45 + */
  46 +@Target(AnnotationTarget.FUNCTION)
  47 +@Retention(AnnotationRetention.RUNTIME)
  48 +@MustBeDocumented
  49 +annotation class RequirePermission(val value: String)
  50 +
  51 +/**
  52 + * Thrown when the authenticated principal lacks the permission
  53 + * required by a `@RequirePermission`-annotated method (or by an
  54 + * explicit call to [PermissionEvaluator.currentHas]).
  55 + *
  56 + * The framework's `GlobalExceptionHandler` maps this exception to
  57 + * HTTP 403 Forbidden with the offending key in the body. We
  58 + * deliberately give the caller the key (rather than a generic
  59 + * "forbidden") because the caller is already authenticated and
  60 + * the SPA needs the key to render a meaningful "your role doesn't
  61 + * include X" message; for unauthenticated callers the auth filter
  62 + * has already returned 401 before this code runs.
  63 + */
  64 +class PermissionDeniedException(val permissionKey: String) :
  65 + RuntimeException("permission denied: '$permissionKey'")
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/authz/RequirePermissionAspect.kt 0 → 100644
  1 +package org.vibeerp.platform.security.authz
  2 +
  3 +import org.aspectj.lang.ProceedingJoinPoint
  4 +import org.aspectj.lang.annotation.Around
  5 +import org.aspectj.lang.annotation.Aspect
  6 +import org.aspectj.lang.reflect.MethodSignature
  7 +import org.slf4j.LoggerFactory
  8 +import org.springframework.stereotype.Component
  9 +
  10 +/**
  11 + * Spring AOP aspect that enforces [RequirePermission] on every
  12 + * annotated method.
  13 + *
  14 + * **How the interception works:** the aspect's `@Around` advice
  15 + * intercepts every method that carries the [RequirePermission]
  16 + * annotation, reads the annotation's `value` (the required permission
  17 + * key), consults [PermissionEvaluator.currentHas] (which in turn reads
  18 + * the current request's [AuthorizationContext]), and either allows
  19 + * the call to proceed via `joinPoint.proceed()` or throws
  20 + * [PermissionDeniedException] which `GlobalExceptionHandler` maps to
  21 + * HTTP 403.
  22 + *
  23 + * **Why @Around and not @Before:** @Around is the only AOP advice
  24 + * type that can SHORT-CIRCUIT the call. @Before runs before the
  25 + * method but cannot prevent it from running; throwing from @Before
  26 + * works on Spring proxies but is fragile across the proxy boundary.
  27 + * @Around is the documented "permission gate" pattern.
  28 + *
  29 + * **Why Spring AOP and not AspectJ load-time-weaving:** Spring AOP
  30 + * is proxy-based, which means it intercepts calls THROUGH a Spring
  31 + * bean (controller → controller, service → service via injection)
  32 + * but NOT calls within the same instance. For permission checks on
  33 + * controller methods this is exactly what we want: every HTTP
  34 + * request goes through the proxy, so every annotated method call
  35 + * is intercepted. AspectJ LTW would intercept every call regardless
  36 + * of source — including the cross-PBC service calls that should
  37 + * NOT be gated, because they're system code (see the rationale on
  38 + * [RequirePermission]). Proxy-based AOP gives us the right scope by
  39 + * default.
  40 + */
  41 +@Aspect
  42 +@Component
  43 +class RequirePermissionAspect(
  44 + private val evaluator: PermissionEvaluator,
  45 +) {
  46 +
  47 + private val log = LoggerFactory.getLogger(RequirePermissionAspect::class.java)
  48 +
  49 + @Around("@annotation(org.vibeerp.platform.security.authz.RequirePermission)")
  50 + fun enforce(joinPoint: ProceedingJoinPoint): Any? {
  51 + val signature = joinPoint.signature as MethodSignature
  52 + val annotation = signature.method.getAnnotation(RequirePermission::class.java)
  53 + val key = annotation.value
  54 +
  55 + val principal = AuthorizationContext.current()
  56 + if (principal == null) {
  57 + // No auth context bound (e.g. unauthenticated request that
  58 + // somehow reached this controller method, or background
  59 + // job code). Deny — `GlobalExceptionHandler` returns 403
  60 + // and the operator can grep the log for who.
  61 + log.warn("permission denied (no principal): {} on {}", key, signature.toShortString())
  62 + throw PermissionDeniedException(key)
  63 + }
  64 +
  65 + if (!evaluator.has(principal.roles, key)) {
  66 + log.warn(
  67 + "permission denied: user='{}' roles={} required='{}' on {}",
  68 + principal.username, principal.roles, key, signature.toShortString(),
  69 + )
  70 + throw PermissionDeniedException(key)
  71 + }
  72 +
  73 + return joinPoint.proceed()
  74 + }
  75 +}
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
@@ -45,6 +45,30 @@ class JwtRoundTripTest { @@ -45,6 +45,30 @@ class JwtRoundTripTest {
45 assertThat(decoded.username).isEqualTo("alice") 45 assertThat(decoded.username).isEqualTo("alice")
46 assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_ACCESS) 46 assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_ACCESS)
47 assertThat(decoded.expiresAt).isEqualTo(issued.expiresAt) 47 assertThat(decoded.expiresAt).isEqualTo(issued.expiresAt)
  48 + // No roles passed → no roles in the decoded token (P4.3).
  49 + assertThat(decoded.roles).isEqualTo(emptySet<String>())
  50 + }
  51 +
  52 + @Test
  53 + fun `roles claim round-trips through the access token`() {
  54 + val userId = UUID.randomUUID()
  55 + val issued = issuer.issueAccessToken(userId, "alice", roles = setOf("admin", "shop_manager"))
  56 +
  57 + val decoded = verifier.verify(issued.value)
  58 +
  59 + assertThat(decoded.roles).isEqualTo(setOf("admin", "shop_manager"))
  60 + }
  61 +
  62 + @Test
  63 + fun `refresh token never carries roles even if AuthService asks for them`() {
  64 + val userId = UUID.randomUUID()
  65 + val issued = issuer.issueRefreshToken(userId, "bob")
  66 +
  67 + val decoded = verifier.verify(issued.value)
  68 +
  69 + // Refresh tokens are deliberately role-free — see the rationale
  70 + // on JwtIssuer.issueAccessToken.
  71 + assertThat(decoded.roles).isEqualTo(emptySet<String>())
48 } 72 }
49 73
50 @Test 74 @Test
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/authz/PermissionEvaluatorTest.kt 0 → 100644
  1 +package org.vibeerp.platform.security.authz
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isFalse
  5 +import assertk.assertions.isTrue
  6 +import io.mockk.every
  7 +import io.mockk.mockk
  8 +import org.junit.jupiter.api.BeforeEach
  9 +import org.junit.jupiter.api.Test
  10 +import org.springframework.jdbc.core.RowMapper
  11 +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
  12 +
  13 +class PermissionEvaluatorTest {
  14 +
  15 + private lateinit var jdbc: NamedParameterJdbcTemplate
  16 + private lateinit var evaluator: PermissionEvaluator
  17 +
  18 + @BeforeEach
  19 + fun setUp() {
  20 + jdbc = mockk()
  21 + evaluator = PermissionEvaluator(jdbc)
  22 + }
  23 +
  24 + /**
  25 + * Stub `jdbc.query(...)` to return the payload strings directly,
  26 + * skipping the RowMapper invocation. The evaluator's payload
  27 + * parsing + grouping logic is what we want to exercise here, not
  28 + * the JDBC plumbing — that's verified by the smoke test against
  29 + * a real Postgres.
  30 + */
  31 + private fun stubRows(vararg payloads: String) {
  32 + every {
  33 + jdbc.query(any<String>(), any<Map<String, Any?>>(), any<RowMapper<String>>())
  34 + } returns payloads.toList()
  35 + }
  36 +
  37 + @Test
  38 + fun `empty role set is always denied`() {
  39 + assertThat(evaluator.has(emptySet(), "anything")).isFalse()
  40 + }
  41 +
  42 + @Test
  43 + fun `admin role wildcards every permission key`() {
  44 + stubRows() // empty cache
  45 + evaluator.refresh()
  46 +
  47 + assertThat(evaluator.has(setOf("admin"), "partners.partner.deactivate")).isTrue()
  48 + assertThat(evaluator.has(setOf("admin"), "anything.totally.unknown")).isTrue()
  49 + assertThat(evaluator.has(setOf("admin", "shop_manager"), "x.y.z")).isTrue()
  50 + }
  51 +
  52 + @Test
  53 + fun `non-admin role with explicit grant returns true for that key`() {
  54 + stubRows(
  55 + """{"role":"shop_manager","permission":"orders.sales.confirm"}""",
  56 + """{"role":"shop_manager","permission":"orders.sales.cancel"}""",
  57 + )
  58 + evaluator.refresh()
  59 +
  60 + assertThat(evaluator.has(setOf("shop_manager"), "orders.sales.confirm")).isTrue()
  61 + assertThat(evaluator.has(setOf("shop_manager"), "orders.sales.cancel")).isTrue()
  62 + assertThat(evaluator.has(setOf("shop_manager"), "inventory.stock.adjust")).isFalse()
  63 + }
  64 +
  65 + @Test
  66 + fun `union of multiple roles is honored`() {
  67 + stubRows(
  68 + """{"role":"sales","permission":"orders.sales.confirm"}""",
  69 + """{"role":"warehouse","permission":"inventory.stock.adjust"}""",
  70 + )
  71 + evaluator.refresh()
  72 +
  73 + // A user with BOTH roles can do BOTH things.
  74 + val both = setOf("sales", "warehouse")
  75 + assertThat(evaluator.has(both, "orders.sales.confirm")).isTrue()
  76 + assertThat(evaluator.has(both, "inventory.stock.adjust")).isTrue()
  77 + assertThat(evaluator.has(both, "partners.partner.deactivate")).isFalse()
  78 + }
  79 +
  80 + @Test
  81 + fun `unknown role yields no permissions`() {
  82 + stubRows(
  83 + """{"role":"sales","permission":"orders.sales.confirm"}""",
  84 + )
  85 + evaluator.refresh()
  86 +
  87 + assertThat(evaluator.has(setOf("nonexistent"), "orders.sales.confirm")).isFalse()
  88 + }
  89 +
  90 + @Test
  91 + fun `malformed payload rows are skipped, valid rows still load`() {
  92 + stubRows(
  93 + "this is not json",
  94 + """{"role":"sales"}""", // missing permission
  95 + """{"permission":"orders.sales.confirm"}""", // missing role
  96 + """{"role":"sales","permission":"orders.sales.confirm"}""",
  97 + )
  98 + evaluator.refresh()
  99 +
  100 + // Only the last row was valid.
  101 + assertThat(evaluator.has(setOf("sales"), "orders.sales.confirm")).isTrue()
  102 + }
  103 +
  104 + @Test
  105 + fun `currentHas returns false when no AuthorizationContext bound`() {
  106 + AuthorizationContext.clear()
  107 + assertThat(evaluator.currentHas("orders.sales.confirm")).isFalse()
  108 + }
  109 +
  110 + @Test
  111 + fun `currentHas consults the bound AuthorizationContext`() {
  112 + stubRows(
  113 + """{"role":"sales","permission":"orders.sales.confirm"}""",
  114 + )
  115 + evaluator.refresh()
  116 +
  117 + AuthorizationContext.runAs(AuthorizedPrincipal("u1", "alice", setOf("sales"))) {
  118 + assertThat(evaluator.currentHas("orders.sales.confirm")).isTrue()
  119 + assertThat(evaluator.currentHas("inventory.stock.adjust")).isFalse()
  120 + }
  121 +
  122 + // Bound principal cleared after the runAs block.
  123 + assertThat(evaluator.currentHas("orders.sales.confirm")).isFalse()
  124 + }
  125 +}