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