Commit 75bf870568f514d0145e5bb4214e070f031fae88
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 = "0.28.1" | @@ -19,6 +19,7 @@ assertk = "0.28.1" | ||
| 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 | +} |