-
The framework now supports federated authentication: operators can configure an external OIDC provider (Keycloak, Auth0, or any OIDC-compliant issuer) and the API accepts JWTs from both the built-in auth (/api/v1/auth/login, HS256) and the OIDC provider (RS256, JWKS auto-discovered). Opt-in via vibeerp.security.oidc.issuer-uri. When blank (default), only built-in auth works — exactly the pre-P4.2 behavior. When set, the JwtDecoder becomes a composite: tries the built-in HS256 decoder first (cheap, local HMAC), falls back to the OIDC decoder (RS256, cached JWKS fetch from the provider's .well-known endpoint). Claim mapping: PrincipalContextFilter now handles both formats: - Built-in: sub=UUID, username=<claim>, roles=<flat array> - OIDC/Keycloak: sub=OIDC subject, preferred_username=<claim>, realm_access.roles=<nested array> Claim names are configurable via vibeerp.security.oidc.username-claim and roles-claim for non-Keycloak providers. New files: - OidcProperties.kt: config properties class for the OIDC block Modified files: - JwtConfiguration.kt: composite decoder, now takes OidcProperties - PrincipalContextFilter.kt: dual claim resolution (built-in first, OIDC fallback), now takes OidcProperties - JwtRoundTripTest.kt: updated to pass OidcProperties (defaults) - application.yaml: OIDC config block with env-var interpolation No new dependencies — uses Spring Security's existing JwtDecoders.fromIssuerLocation() which is already on the classpath via spring-boot-starter-oauth2-resource-server. -
Closes the R2 gap: an admin can now manage users and roles entirely from the SPA without touching curl or Swagger UI. Backend (pbc-identity): - New RoleService with createRole, assignRole, revokeRole, findUserRoleCodes, listRoles. Each method validates existence + idempotency (duplicate assignment rejected, missing role rejected). - New RoleController at /api/v1/identity/roles (CRUD) + /api/v1/identity/users/{userId}/roles/{roleCode} (POST assign, DELETE revoke). All permission-gated: identity.role.read, identity.role.create, identity.role.assign. - identity.yml updated: added identity.role.create permission. SPA (web/): - UsersPage — list with username link to detail, "+ New User" - CreateUserPage — username, display name, email form - UserDetailPage — shows user info + role toggle list. Each role has an Assign/Revoke button that takes effect on the user's next login (JWT carries roles from login time). - RolesPage — list with inline create form (code + name) - Sidebar gains "System" section with Users + Roles links - API client + types: identity.listUsers, getUser, createUser, listRoles, createRole, getUserRoles, assignRole, revokeRole Infrastructure: - SpaController: added /users/** and /roles/** forwarding - SecurityConfiguration: added /users/** and /roles/** to the SPA permitAll block
-
First runnable end-to-end demo: open the browser, log in, click through every PBC, and walk a sales order DRAFT → CONFIRMED → SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the ledger, and the AR journal entry settles — all visible in the SPA without touching curl. Bumps version to 0.29.0-SNAPSHOT. What landed ----------- * New `:web` Gradle subproject — Vite + React 18 + TypeScript + Tailwind 3.4. The Gradle wrapper is two `Exec` tasks (`npmInstall`, `npmBuild`) with proper inputs/outputs declared for incremental builds. Deliberately no node-gradle plugin — one less moving piece. * SPA architecture: hand-written typed REST client over `fetch` (auth header injection + 401 handler), AuthContext that decodes the JWT for display, ProtectedRoute, AppLayout with sidebar grouped by PBC, 16 page components covering the full v1 surface (Items, UoMs, Partners, Locations, Stock Balances + Movements, Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders + detail w/ confirm/receive/cancel, Work Orders + detail w/ start/complete, Shop-Floor dashboard with 5s polling, Journal Entries). 211 KB JS / 21 KB CSS gzipped. * Sales-order detail page: confirm/ship/cancel verbs each refresh the order, the (SO-filtered) movements list, and the (SO-filtered) journal entries — so an operator watches the ledger row appear and the AR row settle in real time after a single click. Same pattern on the purchase-order detail page for the AP/RECEIPT side. * Shop-floor dashboard polls /api/v1/production/work-orders/shop- floor every 5s and renders one card per IN_PROGRESS WO with current operation, planned vs actual minutes (progress bar), and operations-completed. * `:distribution` consumes the SPA dist via a normal Gradle outgoing/incoming configuration: `:web` exposes `webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync task copies it into `${buildDir}/web-static/static/`, and that parent directory is added to the main resources source set so Spring Boot serves the SPA from `classpath:/static/` out of the same fat-jar. Single artifact, no nginx, no CORS. * New `SpaController` in platform-bootstrap forwards every known SPA route prefix to `/index.html` so React Router's HTML5 history mode works on hard refresh / deep-link entry. Explicit list (12 prefixes) rather than catch-all so typoed API URLs still get an honest 404 instead of the SPA shell. * SecurityConfiguration restructured: keeps the public allowlist for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui, then `/api/**` is `.authenticated()`, then SPA static assets + every SPA route prefix are `.permitAll()`. The order is load-bearing — putting `.authenticated()` for /api/** BEFORE the SPA permitAll preserves the framework's "API is always authenticated" invariant even with the SPA bundled in the same fat-jar. The SPA bundle itself is just HTML+CSS+JS so permitting it is correct; secrets are gated by /api/**. * New `DemoSeedRunner` in `:distribution` (gated behind `vibeerp.demo.seed=true`, set in application-dev.yaml only). Idempotent — the runner short-circuits if its sentinel item (DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses, 4 partners, opening stock for every item, one open DEMO-SO-0001 (50× business cards + 20× brochures, $720), one open DEMO-PO-0001 (10000× paper, $400). Every row carries the DEMO- prefix so it's trivially distinguishable from hand-created data; a future "delete demo data" command has an obvious filter. Production deploys never set the property, so the @ConditionalOnProperty bean stays absent from the context. How to run ---------- docker compose up -d db ./gradlew :distribution:bootRun open http://localhost:8080 # Read the bootstrap admin password from the boot log, # log in as admin, and walk DEMO-SO-0001 through the # confirm + ship flow to see the buy-sell loop in the UI. What was caught by the smoke test --------------------------------- * TypeScript strict mode + `error: unknown` in React state → `{error && <X/>}` evaluates to `unknown` and JSX rejects it. Fixed by typing the state as `Error | null` and converting in catches with `e instanceof Error ? e : new Error(String(e))`. Affected 16 page files; the conversion is now uniform. * DataTable's `T extends Record<string, unknown>` constraint was too restrictive for typed row interfaces; relaxed to unconstrained `T` with `(row as unknown as Record<…>)[key]` for the unkeyed cell read fallback. * `vite.config.ts` needs `@types/node` for `node:path` + `__dirname`; added to devDependencies and tsconfig.node.json declares `"types": ["node"]`. * KDoc nested-comment trap (4th time): SpaController's KDoc had `/api/v1/...` in backticks; the `/*` inside backticks starts a nested block comment and breaks Kotlin compilation with "Unclosed comment". Rephrased to "the api-v1 prefix". * SecurityConfiguration order: a draft version that put the SPA permit-all rules BEFORE `/api/**` authenticated() let unauthenticated requests reach API endpoints. Caught by an explicit smoke test (curl /api/v1/some-bogus-endpoint should return 401, not 404 from a missing static file). Reordered so /api/** authentication runs first. End-to-end smoke (real Postgres, fresh DB) ------------------------------------------ - bootRun starts in 7.6s - DemoSeedRunner reports "populating starter dataset… done" - GET / returns the SPA HTML - GET /sales-orders, /sales-orders/<uuid>, /journal-entries all return 200 (SPA shell — React Router takes over) - GET /assets/index-*.js / /assets/index-*.css both 200 - GET /api/v1/some-bogus-endpoint → 401 (Spring Security rejects before any controller mapping) - admin login via /api/v1/auth/login → 200 + JWT - GET /catalog/items → 5 DEMO-* rows - GET /partners/partners → 4 DEMO-* rows - GET /inventory/locations → 2 DEMO-* warehouses - GET /inventory/balances → 5 starting balances - POST /orders/sales-orders/<id>/confirm → CONFIRMED; GET /finance/journal-entries shows AR POSTED 720 USD - POST /orders/sales-orders/<id>/ship {"shippingLocationCode": "DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80; journal entry flips to SETTLED - POST /orders/purchase-orders/<id>/confirm → CONFIRMED; AP POSTED 400 USD appears - POST /orders/purchase-orders/<id>/receive → RECEIVED; PAPER balance grows from 5000 to 15000; AP row SETTLED - 8 stock_movement rows in the ledger total -
Adds self-introspection of the framework's REST surface via springdoc-openapi. Every @RestController method in the host application is now documented in a machine-readable OpenAPI 3 spec at /v3/api-docs and rendered for humans at /swagger-ui/index.html. This is the first step toward: - R1 (web SPA): OpenAPI codegen feeds a typed TypeScript client - A1 (MCP server): discoverable tool catalog - Operator debugging: browsable "what can this instance do" page **Dependency.** New `springdoc-openapi-starter-webmvc-ui` 2.6.0 added to platform-bootstrap (not distribution) because it ships @Configuration classes that need to run inside a full Spring Boot application context AND brings a Swagger UI WebJar. platform-bootstrap is the only module with a @SpringBootApplication anyway; pbc modules never depend on it, so plug-in classloaders stay clean and the OpenAPI scanner only sees host controllers. **Configuration.** New `OpenApiConfiguration` @Configuration in platform-bootstrap provides a single @Bean OpenAPI: - Title "vibe_erp", version v0.28.0 (hardcoded; moves to a build property when a real version header ships) - Description with a framework-level intro explaining the bearer-JWT auth model, the permission whitelist, and the fact that plug-in endpoints under /api/v1/plugins/{id}/** are NOT scanned (they are dynamically registered via PluginContext.endpoints on a single dispatcher controller; a future chunk may extend the spec at runtime). - One relative server entry ("/") so the spec works behind a reverse proxy without baking localhost into it. - bearerAuth security scheme (HTTP/bearer/JWT) applied globally via addSecurityItem, so every operation in the rendered UI shows a lock icon and the "Authorize" button accepts a raw JWT (Swagger adds the "Bearer " prefix itself). **Security whitelist.** SecurityConfiguration now permits three additional path patterns without authentication: - /v3/api-docs/** — the generated JSON spec - /swagger-ui/** — the Swagger UI static assets + index - /swagger-ui.html — the legacy path (redirects to the above) The data still requires a valid JWT: an unauthenticated "Try it out" call from the Swagger UI against a pbc endpoint returns 401 exactly like a curl would. **Why not wire this into every PBC controller with @Operation / @Parameter annotations in this chunk:** springdoc already auto-generates the full path + request body + response schema from reflection. Adding hand-written annotations is scope creep — a future chunk can tag per-operation @Operation(security = ...) to surface the @RequirePermission keys once a consumer actually needs them. **Smoke-tested end-to-end against real Postgres:** - GET /v3/api-docs returns 200 with 64680 bytes of OpenAPI JSON - 76 total paths listed across every PBC controller - All v3 production paths present: /work-orders/shop-floor, /work-orders/{id}/operations/{operationId}/start + /complete, /work-orders/{id}/{start,complete,cancel,scrap} - components.securitySchemes includes bearerAuth (type=http, format=JWT) - GET /swagger-ui/index.html returns 200 with the Swagger HTML bundle (5 swagger markers found in the HTML) - GET /swagger-ui.html (legacy path) returns 200 after redirect 25 modules (unchanged count — new config lives inside platform-bootstrap), 355 unit tests, all green.
-
The original test flipped the LAST character of the JWT signature segment from 'a' to 'b' (or vice versa). This was flaky in CI: with a random UUID subject, the issued token's signature segment can end on a base64url character whose flipped value, when decoded, lenient- parses to bits that the JWT decoder accepts as valid — leaving the signature still verifiable. The flake was reproducible locally with `./gradlew test --rerun-tasks` and was the cause of CI failures on the P5.5 and P4.3 docs-pin commits (and would have caught the underlying chunks too if the docs commit hadn't pushed first). Fix: tamper with the PAYLOAD (middle JWT segment) instead. Any change to the payload changes the bytes the signature is computed over, which ALWAYS fails HMAC verification — no edge cases. The test now flips the first character of the payload to a definitely- different valid base64url character, leaving the format intact so the failure mode is "signature does not verify" rather than "malformed token". Verified 10 consecutive `--rerun-tasks` runs all green locally.
-
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.
-
Implements the auth unit from the implementation plan. Until now, the framework let any caller hit any endpoint; with the single-tenant refactor there is no second wall, so auth was the most pressing gap. What landed: * New `platform-security` module owns the framework's security primitives (JWT issuer/verifier, password encoder, Spring Security filter chain config, AuthenticationFailedException). Lives between platform-persistence and platform-bootstrap. * `JwtIssuer` mints HS256-signed access (15min) and refresh (7d) tokens via NimbusJwtEncoder. `JwtVerifier` decodes them back to a typed `DecodedToken` so PBCs never need to import OAuth2 types. JWT secret is read from VIBEERP_JWT_SECRET; the framework refuses to start if the secret is shorter than 32 bytes. * `SecurityConfiguration` wires Spring Security with JWT resource server, stateless sessions, CSRF disabled, and a public allowlist for /actuator/health, /actuator/info, /api/v1/_meta/**, /api/v1/auth/login, /api/v1/auth/refresh. * `PrincipalContext` (in platform-persistence/security) is the bridge between Spring Security's SecurityContextHolder and the audit listener. Bound by `PrincipalContextFilter` which runs AFTER BearerTokenAuthenticationFilter so SecurityContextHolder is fully populated. The audit listener (AuditedJpaEntityListener) now reads from PrincipalContext, so created_by/updated_by are real user ids instead of __system__. * `pbc-identity` gains `UserCredential` (separate table from User — password hashes never share a query plan with user records), `AuthService` (login + refresh, generic AuthenticationFailedException on every failure to thwart account enumeration), and `AuthController` exposing /api/v1/auth/login and /api/v1/auth/refresh. * `BootstrapAdminInitializer` runs on first boot of an empty identity__user table, creates an `admin` user with a random 16-char password printed to the application logs. Subsequent boots see the user exists and skip silently. * GlobalExceptionHandler maps AuthenticationFailedException → 401 with a generic "invalid credentials" body (RFC 7807 ProblemDetail). * New module also brings BouncyCastle as a runtime-only dep (Argon2PasswordEncoder needs it). Tests: 38 unit tests pass, including JwtRoundTripTest (issue/decode round trip + tamper detection + secret-length validation), PrincipalContextTest (ThreadLocal lifecycle), AuthServiceTest (9 cases covering login + refresh happy paths and every failure mode). End-to-end smoke test against a fresh Postgres via docker-compose: GET /api/v1/identity/users (no auth) → 401 POST /api/v1/auth/login (admin + bootstrap) → 200 + access/refresh POST /api/v1/auth/login (wrong password) → 401 GET /api/v1/identity/users (Bearer) → 200, lists admin POST /api/v1/identity/users (Bearer) → 201, creates alice alice.created_by → admin's user UUID POST /api/v1/auth/refresh (refresh token) → 200 + new pair POST /api/v1/auth/refresh (access token) → 401 (type mismatch) GET /api/v1/identity/users (garbage token) → 401 GET /api/v1/_meta/info (no auth, public) → 200 Plan: docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md refreshed to drop the now-dead P1.1 (RLS hook) and H1 (per-region tenant routing), reorder priorities so P4.1 is first, and reflect the single-tenant change throughout. Bug fixes encountered along the way (caught by the smoke test, not by unit tests — the value of running real workflows): • JwtIssuer was producing IssuedToken.expiresAt with nanosecond precision but JWT exp is integer seconds; the round-trip test failed equality. Fixed by truncating to ChronoUnit.SECONDS at issue time. • PrincipalContextFilter was registered with addFilterAfter UsernamePasswordAuthenticationFilter, which runs BEFORE the OAuth2 BearerTokenAuthenticationFilter, so SecurityContextHolder was empty when the bridge filter read it. Result: every authenticated request still wrote __system__ in audit columns. Fixed by addFilterAfter BearerTokenAuthenticationFilter::class. • RefreshRequest is a single-String data class. jackson-module-kotlin interprets single-arg data classes as delegate-based creators, so Jackson tried to deserialize the entire JSON object as a String and threw HttpMessageNotReadableException. Fixed by adding @JsonCreator(mode = PROPERTIES) + @param:JsonProperty.