Commit 540d916f6d62856f1712f362655e326b97e8c25a

Authored by vibe_erp
1 parent 4879fd7d

feat(auth): P4.1 — built-in JWT auth, Argon2id passwords, bootstrap admin

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.
Showing 29 changed files with 1336 additions and 76 deletions
distribution/build.gradle.kts
... ... @@ -22,6 +22,7 @@ dependencies {
22 22 implementation(project(":platform:platform-bootstrap"))
23 23 implementation(project(":platform:platform-persistence"))
24 24 implementation(project(":platform:platform-plugins"))
  25 + implementation(project(":platform:platform-security"))
25 26 implementation(project(":pbc:pbc-identity"))
26 27  
27 28 implementation(libs.spring.boot.starter)
... ...
distribution/src/main/resources/application-dev.yaml
... ... @@ -11,6 +11,12 @@ spring:
11 11 password: vibeerp
12 12  
13 13 vibeerp:
  14 + security:
  15 + jwt:
  16 + # Dev-only secret — DO NOT use this in production. The application.yaml
  17 + # template reads VIBEERP_JWT_SECRET from the environment and refuses
  18 + # to start without a real value.
  19 + secret: dev-only-secret-must-be-at-least-32-chars-please
14 20 plugins:
15 21 directory: ./plugins-dev
16 22 files:
... ...
distribution/src/main/resources/application.yaml
... ... @@ -46,6 +46,15 @@ vibeerp:
46 46 # running instance — provisioning a second customer means deploying a
47 47 # second instance with its own database.
48 48 company-name: ${VIBEERP_COMPANY_NAME:vibe_erp instance}
  49 + security:
  50 + jwt:
  51 + # HMAC-SHA256 secret. Must be at least 32 characters in production.
  52 + # Set the VIBEERP_JWT_SECRET environment variable; the framework
  53 + # refuses to start if a shorter value is used.
  54 + secret: ${VIBEERP_JWT_SECRET:}
  55 + issuer: vibe-erp
  56 + access-token-ttl: PT15M
  57 + refresh-token-ttl: P7D
49 58 plugins:
50 59 directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins}
51 60 auto-load: true
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -12,4 +12,5 @@
12 12 https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
13 13 <include file="classpath:db/changelog/platform/000-platform-init.xml"/>
14 14 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/>
  15 + <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/>
15 16 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + identity__user_credential
  9 +
  10 + Stores password hashes (and, in future, MFA secrets) for users.
  11 + Kept in a SEPARATE table from identity__user so that:
  12 +
  13 + 1. The User entity stays free of secrets and can be safely
  14 + returned from cross-PBC facades and audit dumps.
  15 + 2. A future PBC that needs to read users (e.g. pbc-orders for
  16 + "owner of this order") never accidentally pulls password
  17 + hashes into its query plans.
  18 + 3. The credential row can be rotated, revoked, or deleted
  19 + independently of the user record (e.g. on password reset).
  20 +
  21 + password_algo is recorded explicitly so the platform can support
  22 + a graceful migration to a stronger hash function later (today:
  23 + argon2id; tomorrow: maybe argon2id-with-newer-params).
  24 + -->
  25 + <changeSet id="identity-credential-001" author="vibe_erp">
  26 + <comment>Create identity__user_credential table</comment>
  27 + <sql>
  28 + CREATE TABLE identity__user_credential (
  29 + id uuid PRIMARY KEY,
  30 + user_id uuid NOT NULL UNIQUE
  31 + REFERENCES identity__user(id) ON DELETE CASCADE,
  32 + password_hash text NOT NULL,
  33 + password_algo varchar(32) NOT NULL DEFAULT 'argon2id',
  34 + must_change boolean NOT NULL DEFAULT false,
  35 + last_changed_at timestamptz NOT NULL,
  36 + created_at timestamptz NOT NULL,
  37 + created_by varchar(128) NOT NULL,
  38 + updated_at timestamptz NOT NULL,
  39 + updated_by varchar(128) NOT NULL,
  40 + version bigint NOT NULL DEFAULT 0
  41 + );
  42 + CREATE INDEX identity__user_credential_user_id_idx
  43 + ON identity__user_credential (user_id);
  44 + </sql>
  45 + <rollback>
  46 + DROP TABLE identity__user_credential;
  47 + </rollback>
  48 + </changeSet>
  49 +
  50 +</databaseChangeLog>
... ...
docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
1 1 # vibe_erp — Implementation Plan (post-v0.1)
2 2  
3   -**Status:** Approved roadmap. v0.1 skeleton is built. This document is the source of truth for the work that turns v0.1 into v1.0.
  3 +**Status:** Approved roadmap. v0.1 skeleton is built, tested end-to-end against a real Postgres, and pushed. This document is the source of truth for the work that turns v0.1 into v1.0.
4 4 **Date:** 2026-04-07
5 5 **Companion document:** [Architecture spec](2026-04-07-vibe-erp-architecture-design.md)
6 6  
7   ----
  7 +> **2026-04-07 update — single-tenant refactor.** vibe_erp is now deliberately
  8 +> single-tenant per instance: one running process serves exactly one company
  9 +> against an isolated Postgres database. All multi-tenancy work has been
  10 +> removed from the framework — no `tenant_id` columns, no Hibernate tenant
  11 +> filter, no Postgres Row-Level Security policies, no `TenantContext`. The
  12 +> previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant
  13 +> routing) units are gone. See CLAUDE.md guardrail #5 for the rationale.
8 14  
9   -## What v0.1 (this commit) actually delivers
  15 +---
10 16  
11   -A buildable, runnable, tested skeleton of the vibe_erp framework. Concretely:
  17 +## What v0.1 actually delivers (verified on a running app)
12 18  
13 19 | Item | Status |
14 20 |---|---|
15   -| Gradle multi-project with 7 subprojects | ✅ Builds |
16   -| `api/api-v1` — public plug-in contract (~30 files, semver-governed) | ✅ Compiles, unit-tested |
17   -| `platform/platform-bootstrap` — Spring Boot main, config, tenant filter | ✅ Compiles |
18   -| `platform/platform-persistence` — multi-tenant JPA scaffolding, audit base | ✅ Compiles |
19   -| `platform/platform-plugins` — PF4J host, lifecycle | ✅ Compiles |
20   -| `pbc/pbc-identity` — User entity end-to-end (entity → repo → service → REST) | ✅ Compiles, unit-tested |
21   -| `reference-customer/plugin-printing-shop` — hello-world PF4J plug-in | ✅ Compiles, packs into a real plug-in JAR with PF4J manifest |
22   -| `distribution` — Spring Boot fat jar (`vibe-erp.jar`, ~59 MB) | ✅ `bootJar` succeeds |
23   -| Liquibase changelogs (platform init + identity init) | ✅ Schema-valid, runs against Postgres on first boot |
24   -| Dockerfile (multi-stage), docker-compose.yaml (app + Postgres), Makefile | ✅ Present, image build untested in this session |
25   -| GitHub Actions workflows (build + architecture-rule check) | ✅ Present, untested in CI |
26   -| Architecture-rule enforcement in `build.gradle.kts` | ✅ Active — fails the build if a PBC depends on another PBC, or if a plug-in depends on `platform/*` |
27   -| README, CONTRIBUTING, LICENSE, 8 docs guides | ✅ Present |
28   -| Architecture spec (this folder) | ✅ Present |
29   -| Implementation plan (this file) | ✅ Present |
  21 +| Gradle multi-project, 7 subprojects | ✅ Builds end-to-end |
  22 +| `api/api-v1` — public plug-in contract | ✅ Compiles, 12 unit tests |
  23 +| `platform/platform-bootstrap` — Spring Boot main, properties, `@EnableJpaRepositories`, `GlobalExceptionHandler` | ✅ |
  24 +| `platform/platform-persistence` — JPA, audit listener (no tenant scaffolding) | ✅ |
  25 +| `platform/platform-plugins` — PF4J host, lifecycle | ✅ |
  26 +| `pbc/pbc-identity` — User entity end-to-end | ✅ Compiles, 6 unit tests, smoke-tested via REST against real Postgres |
  27 +| `reference-customer/plugin-printing-shop` — hello-world PF4J plug-in | ✅ Compiles, packs into a real PF4J JAR |
  28 +| `distribution` — Spring Boot fat jar (`vibe-erp.jar`) | ✅ `bootJar` succeeds, `bootRun` boots cleanly in dev profile |
  29 +| Liquibase changelogs (platform init + identity init) | ✅ Run cleanly against fresh Postgres |
  30 +| Dockerfile, docker-compose.yaml, Makefile | ✅ Present; `docker compose up -d db` verified |
  31 +| GitHub Actions workflows | ✅ Present, running in CI on every push to https://github.com/reporkey/vibe-erp |
  32 +| Architecture-rule enforcement in `build.gradle.kts` | ✅ Active — fails on cross-PBC deps, on plug-ins importing internals, and on PBCs depending on `platform-bootstrap` |
  33 +| README, CONTRIBUTING, LICENSE, 8 docs guides | ✅ |
  34 +| Architecture spec + this implementation plan | ✅ |
  35 +| **End-to-end smoke test against real Postgres** | ✅ POST/GET/PATCH/DELETE on `/api/v1/identity/users`; duplicate-username → 400; bogus id → 404; disable flow works |
30 36  
31 37 **What v0.1 explicitly does NOT deliver:**
32 38  
... ... @@ -34,9 +40,8 @@ A buildable, runnable, tested skeleton of the vibe_erp framework. Concretely:
34 40 - The metadata store machinery (custom-field application, form designer, list views, rules engine)
35 41 - Embedded Flowable workflow engine
36 42 - ICU4J `Translator` implementation
37   -- Postgres Row-Level Security `SET LOCAL` hook (the policies exist; the hook that sets `vibeerp.current_tenant` per transaction is deferred)
38 43 - JasperReports
39   -- OIDC integration
  44 +- Real authentication (anonymous access; the bootstrap admin lands in P4.1)
40 45 - React web SPA
41 46 - Plug-in linter (rejecting reflection into platform internals at install time)
42 47 - Plug-in Liquibase application
... ... @@ -66,12 +71,6 @@ The 11 architecture guardrails in `CLAUDE.md` and the 14-section design in `2026
66 71  
67 72 These units finish the platform layer so PBCs can be implemented without inventing scaffolding.
68 73  
69   -### P1.1 — Postgres RLS transaction hook
70   -**Module:** `platform-persistence`
71   -**Why:** v0.1 enables RLS policies but never sets `current_setting('vibeerp.current_tenant')`. Without the hook, RLS-enabled tables return zero rows. **Block on this for any read/write integration test.**
72   -**What:** A Hibernate `StatementInspector` (or a Spring `TransactionSynchronization`) that runs `SET LOCAL vibeerp.current_tenant = '<tenant_id>'` at the start of every transaction, reading from `TenantContext`.
73   -**Acceptance:** integration test with Testcontainers Postgres, two tenants, one user per tenant, asserts that tenant A's queries return zero rows from tenant B's data even when the Hibernate filter is bypassed.
74   -
75 74 ### P1.2 — Plug-in linter
76 75 **Module:** `platform-plugins`
77 76 **Depends on:** —
... ... @@ -99,8 +98,8 @@ These units finish the platform layer so PBCs can be implemented without inventi
99 98 ### P1.6 — `Translator` implementation backed by ICU4J
100 99 **Module:** new `platform-i18n` module
101 100 **Depends on:** P1.5 (so plug-in message bundles can be loaded as part of metadata)
102   -**What:** Implement `org.vibeerp.api.v1.i18n.Translator` using ICU4J `MessageFormat` with locale fallback. Resolve message bundles in this order: `metadata__translation` (tenant overrides) → plug-in `i18n/messages_<locale>.properties` → core `i18n/messages_<locale>.properties` → fallback locale.
103   -**Acceptance:** unit tests covering plurals, gender, number formatting in en-US, zh-CN, de-DE, ja-JP, es-ES; a test that a tenant override beats a plug-in default beats a core default.
  101 +**What:** Implement `org.vibeerp.api.v1.i18n.Translator` using ICU4J `MessageFormat` with locale fallback. Resolve message bundles in this order: `metadata__translation` (operator overrides) → plug-in `i18n/messages_<locale>.properties` → core `i18n/messages_<locale>.properties` → fallback locale.
  102 +**Acceptance:** unit tests covering plurals, gender, number formatting in en-US, zh-CN, de-DE, ja-JP, es-ES; a test that an operator override beats a plug-in default beats a core default.
104 103  
105 104 ### P1.7 — Event bus + outbox
106 105 **Module:** new `platform-events` module
... ... @@ -117,14 +116,14 @@ These units finish the platform layer so PBCs can be implemented without inventi
117 116 ### P1.9 — File store (local + S3)
118 117 **Module:** new `platform-files` module
119 118 **Depends on:** —
120   -**What:** A `FileStore` interface with `LocalFileStore` and `S3FileStore` implementations, configured via `vibeerp.files.backend`. Each stored file has a `tenant_id` namespace baked into its key, so a misconfigured S3 bucket can't accidentally serve cross-tenant files.
  119 +**What:** A `FileStore` interface with `LocalFileStore` and `S3FileStore` implementations, configured via `vibeerp.files.backend`. Files are stored under a flat key namespace — there is exactly one company per instance, so no per-tenant prefixing is needed.
121 120 **Acceptance:** unit tests for both backends; integration test with MinIO Testcontainer for S3.
122 121  
123 122 ### P1.10 — Job scheduler
124 123 **Module:** new `platform-jobs` module
125 124 **Depends on:** —
126   -**What:** Quartz integration with the `JDBCJobStore` against the same Postgres. PBCs and plug-ins register jobs through a typed `JobScheduler` API in api.v1 (already has the seam, may need a tiny addition). Each job runs inside a `TenantContext.runAs(tenantId) { ... }` block — never inheriting a stale tenant from the trigger thread.
127   -**Acceptance:** a recurring core job that prunes the audit log; integration test confirms the job runs in the right tenant context.
  125 +**What:** Quartz integration with the `JDBCJobStore` against the same Postgres. PBCs and plug-ins register jobs through a typed `JobScheduler` API in api.v1 (already has the seam, may need a tiny addition). Each job runs inside a `PrincipalContext.runAs(systemPrincipal)` block so audit columns get a sensible value instead of inheriting whatever the trigger thread had.
  126 +**Acceptance:** a recurring core job that prunes the audit log; integration test confirms the job runs with the expected system principal in audit columns.
128 127  
129 128 ---
130 129  
... ... @@ -144,7 +143,7 @@ These units finish the platform layer so PBCs can be implemented without inventi
144 143  
145 144 ### P2.3 — User task / form rendering
146 145 **Module:** `platform-workflow` + future React SPA
147   -**Depends on:** P2.1, F1 (form designer), R1
  146 +**Depends on:** P2.1, P3.3 (form designer), R1
148 147 **What:** When a Flowable user task fires, look up the form id from the task definition, render it via the form renderer (P3.x), capture the user's input, and complete the task.
149 148 **Acceptance:** end-to-end test of an approval workflow where a human clicks a button.
150 149  
... ... @@ -179,29 +178,38 @@ These units finish the platform layer so PBCs can be implemented without inventi
179 178 ### P3.5 — Rules engine (simple "if X then Y")
180 179 **Module:** new `platform-rules` module
181 180 **Depends on:** P1.7 (events)
182   -**What:** Listen to `DomainEvent`s, evaluate `metadata__rule` rows scoped to that event type, run the configured action (send email, set field, call workflow, publish another event). Use a simple expression language (something like `JEXL` or Spring SpEL — bias toward Spring SpEL since we already have it).
  181 +**What:** Listen to `DomainEvent`s, evaluate `metadata__rule` rows scoped to that event type, run the configured action (send email, set field, call workflow, publish another event). Use a simple expression language (Spring SpEL — already on the classpath).
183 182 **Acceptance:** integration test where a rule "on UserCreated, set ext.greeting = 'Welcome'" actually fires.
184 183  
185 184 ### P3.6 — List view designer (web)
186 185 **Module:** future React SPA
187 186 **Depends on:** R1
188   -**What:** A grid component backed by `metadata__list_view` rows. Tier 1 users pick columns, filters, sort, save as a named list view, share with their tenant.
  187 +**What:** A grid component backed by `metadata__list_view` rows. Operators pick columns, filters, sort, save as a named list view.
189 188 **Acceptance:** e2e test where a user creates a "Active customers in Germany" list view and re-opens it later.
190 189  
191 190 ---
192 191  
193   -## Phase 4 — Authentication and authorization (real)
  192 +## Phase 4 — Authentication and authorization (PROMOTED to next priority)
  193 +
  194 +> **Promoted from "after the platform layer" to "do this next."** With the
  195 +> single-tenant refactor, there is no second wall protecting data. Auth is
  196 +> the only security. Until P4.1 lands, vibe_erp is only safe to run on
  197 +> `localhost`.
194 198  
195 199 ### P4.1 — Built-in JWT auth
196   -**Module:** new `pbc-auth` (under `pbc/`) + `platform-security` (might already exist)
  200 +**Module:** `pbc-identity` (extended) + `platform-bootstrap` (Spring Security config)
197 201 **Depends on:** —
198   -**What:** Username/password login backed by `identity__user_credential` (separate table from `identity__user` so the User entity stays small). Argon2id for hashing. Issue a signed JWT with `sub`, `tenant`, `roles`, `iat`, `exp`. Validate on every request via a Spring Security filter.
199   -**Acceptance:** integration test of full login/logout/refresh flow; assertion that the JWT carries `tenant` and that downstream PBC code sees it via `Principal`.
  202 +**What:** Username/password login backed by `identity__user_credential` (separate table from `identity__user` so the User entity stays free of secrets). Argon2id for hashing via Spring Security's `Argon2PasswordEncoder`. Issue an HMAC-SHA256-signed JWT with `sub` (user id), `username`, `iss = vibe-erp`, `iat`, `exp`. Validate on every request via a Spring Security filter, bind the resolved `Principal` to a per-request `PrincipalContext`, and have the audit listener read from it (replacing today's `__system__` placeholder). On first boot of an empty `identity__user` table, create a bootstrap admin with a random one-time password printed to the application logs.
  203 +**Acceptance:** end-to-end smoke test against the running app:
  204 +- unauthenticated `GET /api/v1/identity/users` → 401
  205 +- `POST /api/v1/auth/login` with admin → 200 + access + refresh tokens
  206 +- `GET /api/v1/identity/users` with `Authorization: Bearer …` → 200
  207 +- new user created via REST has `created_by = <admin user id>`, not `__system__`
200 208  
201 209 ### P4.2 — OIDC integration
202   -**Module:** `pbc-auth`
  210 +**Module:** `pbc-identity`
203 211 **Depends on:** P4.1
204   -**What:** Plug Spring Security's OIDC client into the same `Principal` resolution. Configurable per tenant: which provider, which client id, which claim maps to `tenant`. Keycloak is the smoke-test target.
  212 +**What:** Plug Spring Security's OIDC client into the same `Principal` resolution. Configurable per *instance* (not per tenant — vibe_erp is single-tenant): which provider, which client id, which claim is the username. Keycloak is the smoke-test target.
205 213 **Acceptance:** Keycloak Testcontainer + integration test of a full OIDC code flow; the same `UserController` endpoints work without changes.
206 214  
207 215 ### P4.3 — Permission checking (real)
... ... @@ -221,9 +229,13 @@ Each PBC follows the same 7-step recipe:
221 229 3. Spring Data JPA repositories
222 230 4. Application services
223 231 5. REST controllers under `/api/v1/<pbc>/<resource>`
224   -6. Liquibase changelog (`db/changelog/<pbc>/`) with rollback blocks AND RLS policies
  232 +6. Liquibase changelog (`db/changelog/<pbc>/`) with rollback blocks
225 233 7. Cross-PBC facade in `api.v1.ext.<pbc>` + adapter in the PBC
226 234  
  235 +> No `tenant_id` columns. No RLS policies. vibe_erp is single-tenant per
  236 +> instance — everything in the database belongs to the one company that
  237 +> owns the instance.
  238 +
227 239 ### P5.1 — `pbc-catalog` — items, units of measure, attributes
228 240 Foundation for everything that's bought, sold, made, or stored. Hold off on price lists and configurable products until P5.10.
229 241  
... ... @@ -290,28 +302,26 @@ Defer until P5.5 (orders-sales) is real, since pricing only matters when orders
290 302 **Module:** same plug-in
291 303 **Depends on:** P1.4 (plug-in Liquibase application)
292 304 **What:** The plug-in defines its own entities (`plugin_printingshop__plate`, `plugin_printingshop__ink_recipe`, `plugin_printingshop__press`) via its own Liquibase changelog. It registers REST endpoints for them via `@PluginEndpoint`. It registers permissions like `printingshop.plate.approve`.
293   -**Acceptance:** integration test that a printing-shop user can CRUD plates against `/api/v1/plugins/printing-shop/plates`, scoped to their tenant.
  305 +**Acceptance:** integration test that a printing-shop user can CRUD plates against `/api/v1/plugins/printing-shop/plates`.
294 306  
295 307 ### REF.3 — Real reference forms and metadata
296 308 **Module:** same plug-in
297 309 **Depends on:** P3.3, P3.4
298 310 **What:** The plug-in ships YAML metadata for: a custom field on Sales Order ("printing job type"), a custom form for plate approval, an automation rule "on plate approved, dispatch to press". All loaded at plug-in start, all tagged `source = 'plugin:printing-shop'`.
299   -**Acceptance:** end-to-end smoke test that loads the plug-in into a fresh tenant and exercises the full quote-to-job-card-to-press flow without any code changes outside the plug-in.
  311 +**Acceptance:** end-to-end smoke test that loads the plug-in into a fresh instance and exercises the full quote-to-job-card-to-press flow without any code changes outside the plug-in.
300 312  
301 313 ---
302 314  
303   -## Phase 8 — Hosted, AI agents, mobile (post-v1.0)
304   -
305   -### H1 — Per-region tenant routing (hosted)
306   -Make `platform-persistence` look up the tenant's region from `identity__tenant` and route the connection accordingly. v1.0 doesn't ship hosted, but the routing seam should land in v1.0 so v2.0 can flip a switch.
  315 +## Phase 8 — Hosted operations, AI agents, mobile (post-v1.0)
307 316  
308   -### H2 — Tenant provisioning UI
309   -A per-instance "operator console" that creates tenants, manages quotas, watches health. Separate React app, separate URL.
  317 +### H1 — Instance provisioning console (hosted)
  318 +**What:** A separate operator console that, given a "create customer" request, provisions a fresh vibe_erp instance: spin up a dedicated Postgres database, deploy a vibe_erp container pointed at it, register DNS, hand the customer their bootstrap admin credentials. The vibe_erp framework itself is not changed for this — provisioning is a layer *on top of* the framework. Each customer gets their own process, their own database, their own backups, their own upgrade cadence.
  319 +**Acceptance:** can provision and tear down a customer instance via a single CLI command.
310 320  
311 321 ### A1 — MCP server
312 322 **Module:** new `platform-mcp`
313 323 **Depends on:** every PBC, every endpoint having a typed OpenAPI definition
314   -**What:** Expose every REST endpoint as an MCP function. Permission-checked, locale-aware, tenant-aware. A Tier 2 plug-in can register additional MCP functions via a new `@McpFunction` annotation in api.v1 (an additive change, minor version bump).
  324 +**What:** Expose every REST endpoint as an MCP function. Permission-checked, locale-aware. A Tier 2 plug-in can register additional MCP functions via a new `@McpFunction` annotation in api.v1 (an additive change, minor version bump).
315 325 **Acceptance:** an LLM-driven agent can call `vibe_erp.identity.users.create` and the result is identical to a REST call.
316 326  
317 327 ### M1 — React Native skeleton
... ... @@ -321,34 +331,33 @@ Shared TypeScript types with the web SPA; first screens cover shop-floor scannin
321 331  
322 332 ## Tracking and ordering
323 333  
324   -A coarse dependency view:
  334 +A coarse dependency view (after the single-tenant refactor):
325 335  
326 336 ```
327   -P1.1 — RLS hook ─────┐
328   -P1.2 — linter ──┐ │
329   -P1.3 — child ctx ┘ │
330   -P1.4 — plug-in liquibase ─ depends on P1.3
331   -P1.5 — metadata seed ──┐ ──┐
  337 +P4.1 — auth (PROMOTED) ─────────┐
  338 +P1.5 — metadata seed ──┐ ──┐ │
332 339 P1.6 — translator ──── (depends on P1.5)
333 340 P1.7 — events ─────────┘ │
334   -P1.8 — reports ── (depends on P1.5)
  341 +P1.2 — linter ──┐
  342 +P1.3 — child ctx ┘
  343 +P1.4 — plug-in liquibase ── depends on P1.3
  344 +P1.8 — reports ── depends on P1.5
335 345 P1.9 — files
336 346 P1.10 — jobs
337   -P2.1 — Flowable ── (depends on P1.5, P1.6, P1.7)
338   -P3.x — metadata Tier 1 ── (depends on P1.5)
339   -P4.x — auth
340   -P5.x — core PBCs ── (depend on P1.x complete + P4.x for permission checks)
341   -R1 — web bootstrap ── (depends on P4.1)
342   -REF.x — reference plug-in ── (depends on P1.4 + P2.1 + P3.3)
  347 +P2.1 — Flowable ── depends on P1.5, P1.6, P1.7
  348 +P3.x — metadata Tier 1 ── depends on P1.5
  349 +P5.x — core PBCs ── depend on P1.x complete + P4.x for permission checks
  350 +R1 — web bootstrap ── depends on P4.1
  351 +REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3
343 352 ```
344 353  
345   -Sensible ordering for one developer:
346   -P1.1 → P1.5 → P1.7 → P1.6 → P1.10 → P1.4 → P1.3 → P1.2 → P2.1 → P3.4 → P3.1 → P4.1 → P5.1 → P5.2 → ...
  354 +Sensible ordering for one developer (single-tenant world):
  355 +**P4.1 → P1.5 → P1.7 → P1.6 → P1.2 → P1.3 → P1.4 → P1.10 → P2.1 → P3.4 → P3.1 → P5.1 → P5.2 → R1 → ...**
347 356  
348 357 Sensible parallel ordering for a team of three:
349   -- Dev A: P1.1, P1.5, P1.7, P1.6, P2.1
  358 +- Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1
350 359 - Dev B: P1.4, P1.3, P1.2, P3.4, P3.1
351   -- Dev C: P4.1, P4.3, P5.1 (catalog), P5.2 (partners)
  360 +- Dev C: P5.1 (catalog), P5.2 (partners), P4.3
352 361  
353 362 Then converge on R1 (web) and the rest of Phase 5 in parallel.
354 363  
... ... @@ -358,9 +367,9 @@ Then converge on R1 (web) and the rest of Phase 5 in parallel.
358 367  
359 368 v1.0 ships when, on a fresh Postgres, an operator can:
360 369  
361   -1. `docker run` the image
362   -2. Log in to the web SPA
363   -3. Create a tenant (or use the default tenant in self-host mode)
  370 +1. `docker run` the image with a connection string and a company name
  371 +2. Read the bootstrap admin password from the logs
  372 +3. Log in to the web SPA
364 373 4. Drop the printing-shop plug-in JAR into `./plugins/`
365 374 5. Restart the container
366 375 6. See the printing-shop's screens, custom fields, workflows, and permissions in the SPA
... ... @@ -368,6 +377,6 @@ v1.0 ships when, on a fresh Postgres, an operator can:
368 377 8. Generate a PDF report for that quote in zh-CN
369 378 9. Export a DSAR for one of their customers
370 379  
371   -…and the same image, against a fresh empty Postgres, also serves a customer who has loaded a *completely different* plug-in expressing a *completely different* business — without any change to the core image.
  380 +…and the same image, against a fresh empty Postgres pointed at a *different* company, also serves a customer who has loaded a *completely different* plug-in expressing a *completely different* business — without any change to the core image. (Hosting both customers in one process is explicitly NOT a goal — they are two independent instances.)
372 381  
373 382 That's the bar. Until that bar is met, the framework has not delivered on its premise.
... ...
gradle/libs.versions.toml
... ... @@ -21,7 +21,12 @@ spring-boot-starter-web = { module = &quot;org.springframework.boot:spring-boot-start
21 21 spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "springBoot" }
22 22 spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" }
23 23 spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" }
  24 +spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springBoot" }
  25 +spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "springBoot" }
  26 +spring-security-oauth2-jose = { module = "org.springframework.security:spring-security-oauth2-jose", version = "6.3.3" }
24 27 spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springBoot" }
  28 +spring-security-test = { module = "org.springframework.security:spring-security-test", version = "6.3.3" }
  29 +bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.78.1" }
25 30  
26 31 # Kotlin
27 32 kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
... ...
pbc/pbc-identity/build.gradle.kts
... ... @@ -39,6 +39,7 @@ allOpen {
39 39 dependencies {
40 40 api(project(":api:api-v1"))
41 41 implementation(project(":platform:platform-persistence"))
  42 + implementation(project(":platform:platform-security"))
42 43  
43 44 implementation(libs.kotlin.stdlib)
44 45 implementation(libs.kotlin.reflect)
... ... @@ -47,6 +48,7 @@ dependencies {
47 48 implementation(libs.spring.boot.starter.web)
48 49 implementation(libs.spring.boot.starter.data.jpa)
49 50 implementation(libs.spring.boot.starter.validation)
  51 + implementation(libs.spring.boot.starter.security) // Argon2PasswordEncoder
50 52 implementation(libs.jackson.module.kotlin)
51 53  
52 54 testImplementation(libs.spring.boot.starter.test)
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.application
  2 +
  3 +import org.springframework.security.crypto.password.PasswordEncoder
  4 +import org.springframework.stereotype.Service
  5 +import org.springframework.transaction.annotation.Transactional
  6 +import org.vibeerp.pbc.identity.domain.User
  7 +import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
  8 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  9 +import org.vibeerp.platform.security.AuthenticationFailedException
  10 +import org.vibeerp.platform.security.IssuedToken
  11 +import org.vibeerp.platform.security.JwtIssuer
  12 +import org.vibeerp.platform.security.JwtVerifier
  13 +
  14 +/**
  15 + * Login and refresh-token logic.
  16 + *
  17 + * Why this lives in `pbc-identity` even though the JWT machinery lives in
  18 + * `platform-security`: the **policy** of "verify a username and password
  19 + * against our credential store" is identity domain logic. The
  20 + * **mechanism** of "encode and decode a JWT" is a framework primitive.
  21 + * Identity calls the framework primitive; the framework does not call
  22 + * into identity.
  23 + *
  24 + * The service is intentionally narrow: only `login` and `refresh`. Logout
  25 + * is a client-side action (drop the token); revocation requires a token
  26 + * blocklist that lands later (post-v0.3) once we have somewhere to put it.
  27 + *
  28 + * On both methods, a verification failure throws a generic
  29 + * [AuthenticationFailedException] regardless of *why* (no such user, wrong
  30 + * password, expired refresh, wrong token type) so an attacker cannot
  31 + * distinguish "user does not exist" from "wrong password".
  32 + */
  33 +@Service
  34 +@Transactional(readOnly = true)
  35 +class AuthService(
  36 + private val users: UserJpaRepository,
  37 + private val credentials: UserCredentialJpaRepository,
  38 + private val passwordEncoder: PasswordEncoder,
  39 + private val jwtIssuer: JwtIssuer,
  40 + private val jwtVerifier: JwtVerifier,
  41 +) {
  42 +
  43 + fun login(username: String, password: String): TokenPair {
  44 + val user = users.findByUsername(username) ?: throw AuthenticationFailedException()
  45 + if (!user.enabled) throw AuthenticationFailedException()
  46 +
  47 + val credential = credentials.findByUserId(user.id) ?: throw AuthenticationFailedException()
  48 + if (!passwordEncoder.matches(password, credential.passwordHash)) {
  49 + throw AuthenticationFailedException()
  50 + }
  51 +
  52 + return mintTokenPair(user)
  53 + }
  54 +
  55 + fun refresh(refreshToken: String): TokenPair {
  56 + val decoded = jwtVerifier.verify(refreshToken)
  57 + if (decoded.type != JwtIssuer.TYPE_REFRESH) {
  58 + throw AuthenticationFailedException("expected refresh token, got ${decoded.type}")
  59 + }
  60 +
  61 + val user = users.findById(decoded.userId).orElseThrow { AuthenticationFailedException() }
  62 + if (!user.enabled) throw AuthenticationFailedException()
  63 +
  64 + return mintTokenPair(user)
  65 + }
  66 +
  67 + private fun mintTokenPair(user: User): TokenPair {
  68 + val access = jwtIssuer.issueAccessToken(user.id, user.username)
  69 + val refresh = jwtIssuer.issueRefreshToken(user.id, user.username)
  70 + return TokenPair(access, refresh)
  71 + }
  72 +}
  73 +
  74 +data class TokenPair(
  75 + val accessToken: IssuedToken,
  76 + val refreshToken: IssuedToken,
  77 +)
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.bootstrap
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.boot.CommandLineRunner
  5 +import org.springframework.security.crypto.password.PasswordEncoder
  6 +import org.springframework.stereotype.Component
  7 +import org.springframework.transaction.annotation.Transactional
  8 +import org.vibeerp.pbc.identity.domain.User
  9 +import org.vibeerp.pbc.identity.domain.UserCredential
  10 +import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
  11 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  12 +import org.vibeerp.platform.persistence.security.PrincipalContext
  13 +import java.security.SecureRandom
  14 +import java.time.Instant
  15 +
  16 +/**
  17 + * On the very first boot of an empty `identity__user` table, create a
  18 + * single `admin` user with a randomly-generated 16-character password
  19 + * and print it to the application logs.
  20 + *
  21 + * The operator copies the password from the logs, logs in once, changes
  22 + * the password (or hands it to the real human admin), and the bootstrap
  23 + * is done. Subsequent boots see the admin row already exists and skip
  24 + * silently.
  25 + *
  26 + * **Why a CommandLineRunner instead of a Liquibase changeset:** the
  27 + * password hash needs to be produced by the live `Argon2PasswordEncoder`
  28 + * bean (so the parameters match what the rest of the application uses
  29 + * to verify), and Liquibase has no clean hook for "run a Spring bean to
  30 + * produce a value." A CommandLineRunner runs after the application
  31 + * context is fully initialized, with all beans wired, in a transaction
  32 + * we control.
  33 + *
  34 + * **Why log the password instead of failing the boot:** alternatives
  35 + * (env var, config file, prompt) all create a chicken-and-egg problem
  36 + * for first-install. Logging the password is the standard pattern (Wiki
  37 + * named it "Jenkins-style bootstrap") and it works for both interactive
  38 + * `docker run -d` and automated provisioning, as long as the operator
  39 + * can read the logs once.
  40 + */
  41 +@Component
  42 +class BootstrapAdminInitializer(
  43 + private val users: UserJpaRepository,
  44 + private val credentials: UserCredentialJpaRepository,
  45 + private val passwordEncoder: PasswordEncoder,
  46 +) : CommandLineRunner {
  47 +
  48 + private val log = LoggerFactory.getLogger(BootstrapAdminInitializer::class.java)
  49 +
  50 + @Transactional
  51 + override fun run(vararg args: String?) {
  52 + if (users.count() > 0L) {
  53 + log.debug("identity__user is non-empty; skipping bootstrap admin creation")
  54 + return
  55 + }
  56 +
  57 + // Audit columns expect *some* principal id; bootstrap writes are
  58 + // attributed to the system sentinel, which is what runs every
  59 + // migration / first-install operation.
  60 + PrincipalContext.runAs(BOOTSTRAP_PRINCIPAL) {
  61 + val password = generatePassword()
  62 + val admin = User(
  63 + username = "admin",
  64 + displayName = "Bootstrap administrator",
  65 + email = null,
  66 + enabled = true,
  67 + )
  68 + val savedAdmin = users.save(admin)
  69 +
  70 + credentials.save(
  71 + UserCredential(
  72 + userId = savedAdmin.id,
  73 + passwordHash = passwordEncoder.encode(password),
  74 + passwordAlgo = "argon2id",
  75 + mustChange = true,
  76 + lastChangedAt = Instant.now(),
  77 + ),
  78 + )
  79 +
  80 + // Banner-style log lines so the operator can spot it in
  81 + // verbose boot output. The password is intentionally on its
  82 + // own line for easy copy-paste.
  83 + log.warn("================================================================")
  84 + log.warn("vibe_erp bootstrap admin created")
  85 + log.warn(" username: admin")
  86 + log.warn(" password: {}", password)
  87 + log.warn("Change this password on first login. This is the last time")
  88 + log.warn("the password will be printed.")
  89 + log.warn("================================================================")
  90 + }
  91 + }
  92 +
  93 + private fun generatePassword(): String {
  94 + val bytes = ByteArray(PASSWORD_BYTES)
  95 + random.nextBytes(bytes)
  96 + return PASSWORD_ALPHABET.let { alphabet ->
  97 + buildString(PASSWORD_BYTES) {
  98 + bytes.forEach { append(alphabet[(it.toInt() and 0xff) % alphabet.length]) }
  99 + }
  100 + }
  101 + }
  102 +
  103 + private companion object {
  104 + const val BOOTSTRAP_PRINCIPAL = "__bootstrap__"
  105 + const val PASSWORD_BYTES = 16
  106 + const val PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
  107 + val random = SecureRandom()
  108 + }
  109 +}
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Table
  6 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  7 +import java.time.Instant
  8 +import java.util.UUID
  9 +
  10 +/**
  11 + * Password (and future MFA) material for a [User].
  12 + *
  13 + * Lives in a SEPARATE table from `identity__user` for three reasons:
  14 + * 1. Keep secrets out of every accidental query plan that pulls users.
  15 + * 2. Let other PBCs read user references (`UserRef`) without ever seeing
  16 + * the password hash, even by mistake.
  17 + * 3. Independent rotation, revocation, and deletion (e.g. on password
  18 + * reset) without touching the User row.
  19 + *
  20 + * The hash itself is stored as an opaque string. The `passwordAlgo` column
  21 + * records which encoder produced it so the platform can support hash
  22 + * migration in the future without losing existing credentials.
  23 + */
  24 +@Entity
  25 +@Table(name = "identity__user_credential")
  26 +class UserCredential(
  27 + userId: UUID,
  28 + passwordHash: String,
  29 + passwordAlgo: String = "argon2id",
  30 + mustChange: Boolean = false,
  31 + lastChangedAt: Instant = Instant.now(),
  32 +) : AuditedJpaEntity() {
  33 +
  34 + @Column(name = "user_id", nullable = false, updatable = false, columnDefinition = "uuid")
  35 + var userId: UUID = userId
  36 +
  37 + @Column(name = "password_hash", nullable = false)
  38 + var passwordHash: String = passwordHash
  39 +
  40 + @Column(name = "password_algo", nullable = false, length = 32)
  41 + var passwordAlgo: String = passwordAlgo
  42 +
  43 + @Column(name = "must_change", nullable = false)
  44 + var mustChange: Boolean = mustChange
  45 +
  46 + @Column(name = "last_changed_at", nullable = false)
  47 + var lastChangedAt: Instant = lastChangedAt
  48 +
  49 + override fun toString(): String =
  50 + "UserCredential(id=$id, userId=$userId, algo='$passwordAlgo')"
  51 +}
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.http
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonCreator
  4 +import com.fasterxml.jackson.annotation.JsonProperty
  5 +import jakarta.validation.Valid
  6 +import jakarta.validation.constraints.NotBlank
  7 +import org.springframework.web.bind.annotation.PostMapping
  8 +import org.springframework.web.bind.annotation.RequestBody
  9 +import org.springframework.web.bind.annotation.RequestMapping
  10 +import org.springframework.web.bind.annotation.RestController
  11 +import org.vibeerp.pbc.identity.application.AuthService
  12 +import org.vibeerp.pbc.identity.application.TokenPair
  13 +import java.time.Instant
  14 +
  15 +/**
  16 + * Login and refresh endpoints.
  17 + *
  18 + * Mounted under `/api/v1/auth/` (not under `/api/v1/identity/auth/`)
  19 + * because authentication is a framework-level concern: a future OIDC
  20 + * integration (P4.2), an API-key flow, or an SSO bridge will all share
  21 + * the same root path. Putting auth under `identity` would suggest only
  22 + * `pbc-identity` can issue tokens, which is not true.
  23 + *
  24 + * Both endpoints are in the public allowlist of `SecurityConfiguration`.
  25 + * The framework's `GlobalExceptionHandler` maps `AuthenticationFailedException`
  26 + * (defined in `platform-security`) to HTTP 401, so this controller body
  27 + * stays focused on the happy path.
  28 + */
  29 +@RestController
  30 +@RequestMapping("/api/v1/auth")
  31 +class AuthController(
  32 + private val authService: AuthService,
  33 +) {
  34 +
  35 + @PostMapping("/login")
  36 + fun login(@RequestBody @Valid request: LoginRequest): TokenPairResponse =
  37 + authService.login(request.username, request.password).toResponse()
  38 +
  39 + @PostMapping("/refresh")
  40 + fun refresh(@RequestBody @Valid request: RefreshRequest): TokenPairResponse =
  41 + authService.refresh(request.refreshToken).toResponse()
  42 +}
  43 +
  44 +// ─── DTOs ────────────────────────────────────────────────────────────
  45 +
  46 +data class LoginRequest(
  47 + @field:NotBlank val username: String,
  48 + @field:NotBlank val password: String,
  49 +)
  50 +
  51 +// Single-field data classes trip up jackson-module-kotlin: a single
  52 +// String parameter is interpreted as a *delegate-based* creator, so
  53 +// Jackson tries to deserialize the entire JSON value as a String and
  54 +// fails when it sees an object. The explicit @JsonCreator + @JsonProperty
  55 +// forces property-based mode and lets Jackson read the JSON object normally.
  56 +data class RefreshRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
  57 + @field:NotBlank @param:JsonProperty("refreshToken") val refreshToken: String,
  58 +)
  59 +
  60 +data class TokenPairResponse(
  61 + val accessToken: String,
  62 + val accessExpiresAt: Instant,
  63 + val refreshToken: String,
  64 + val refreshExpiresAt: Instant,
  65 + val tokenType: String = "Bearer",
  66 +)
  67 +
  68 +private fun TokenPair.toResponse() = TokenPairResponse(
  69 + accessToken = accessToken.value,
  70 + accessExpiresAt = accessToken.expiresAt,
  71 + refreshToken = refreshToken.value,
  72 + refreshExpiresAt = refreshToken.expiresAt,
  73 +)
... ...
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.identity.domain.UserCredential
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data repository for [UserCredential].
  10 + *
  11 + * One credential row per user (enforced by the unique constraint on
  12 + * `user_id` in `identity__user_credential`). The lookup is by `userId`
  13 + * because that is the primary access pattern: "I know who you claim to
  14 + * be — what is your password hash?".
  15 + */
  16 +@Repository
  17 +interface UserCredentialJpaRepository : JpaRepository<UserCredential, UUID> {
  18 +
  19 + fun findByUserId(userId: UUID): UserCredential?
  20 +
  21 + fun existsByUserId(userId: UUID): Boolean
  22 +}
... ...
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isInstanceOf
  7 +import io.mockk.every
  8 +import io.mockk.mockk
  9 +import org.junit.jupiter.api.BeforeEach
  10 +import org.junit.jupiter.api.Test
  11 +import org.springframework.security.crypto.password.PasswordEncoder
  12 +import org.vibeerp.pbc.identity.domain.User
  13 +import org.vibeerp.pbc.identity.domain.UserCredential
  14 +import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
  15 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  16 +import org.vibeerp.platform.security.AuthenticationFailedException
  17 +import org.vibeerp.platform.security.DecodedToken
  18 +import org.vibeerp.platform.security.IssuedToken
  19 +import org.vibeerp.platform.security.JwtIssuer
  20 +import org.vibeerp.platform.security.JwtVerifier
  21 +import java.time.Instant
  22 +import java.util.Optional
  23 +import java.util.UUID
  24 +
  25 +class AuthServiceTest {
  26 +
  27 + private lateinit var users: UserJpaRepository
  28 + private lateinit var credentials: UserCredentialJpaRepository
  29 + private lateinit var passwordEncoder: PasswordEncoder
  30 + private lateinit var jwtIssuer: JwtIssuer
  31 + private lateinit var jwtVerifier: JwtVerifier
  32 + private lateinit var service: AuthService
  33 +
  34 + @BeforeEach
  35 + fun setUp() {
  36 + users = mockk()
  37 + credentials = mockk()
  38 + passwordEncoder = mockk()
  39 + jwtIssuer = mockk()
  40 + jwtVerifier = mockk()
  41 + service = AuthService(users, credentials, passwordEncoder, jwtIssuer, jwtVerifier)
  42 + }
  43 +
  44 + private fun aliceUser(id: UUID = UUID.randomUUID(), enabled: Boolean = true): User {
  45 + return User(username = "alice", displayName = "Alice", email = null, enabled = enabled).also {
  46 + it.id = id
  47 + }
  48 + }
  49 +
  50 + private fun stubMint(userId: UUID, username: String) {
  51 + val now = Instant.now()
  52 + every { jwtIssuer.issueAccessToken(userId, username) } returns
  53 + IssuedToken("access-$userId", now.plusSeconds(900))
  54 + every { jwtIssuer.issueRefreshToken(userId, username) } returns
  55 + IssuedToken("refresh-$userId", now.plusSeconds(86400 * 7))
  56 + }
  57 +
  58 + @Test
  59 + fun `login returns a token pair on the happy path`() {
  60 + val alice = aliceUser()
  61 + val cred = UserCredential(userId = alice.id, passwordHash = "hash")
  62 + every { users.findByUsername("alice") } returns alice
  63 + every { credentials.findByUserId(alice.id) } returns cred
  64 + every { passwordEncoder.matches("correct", "hash") } returns true
  65 + stubMint(alice.id, "alice")
  66 +
  67 + val pair = service.login("alice", "correct")
  68 +
  69 + assertThat(pair.accessToken.value).isEqualTo("access-${alice.id}")
  70 + assertThat(pair.refreshToken.value).isEqualTo("refresh-${alice.id}")
  71 + }
  72 +
  73 + @Test
  74 + fun `login fails when user does not exist`() {
  75 + every { users.findByUsername("ghost") } returns null
  76 +
  77 + assertFailure { service.login("ghost", "anything") }
  78 + .isInstanceOf(AuthenticationFailedException::class)
  79 + }
  80 +
  81 + @Test
  82 + fun `login fails when user is disabled`() {
  83 + val disabled = aliceUser(enabled = false)
  84 + every { users.findByUsername("alice") } returns disabled
  85 +
  86 + assertFailure { service.login("alice", "correct") }
  87 + .isInstanceOf(AuthenticationFailedException::class)
  88 + }
  89 +
  90 + @Test
  91 + fun `login fails when no credential row exists`() {
  92 + val alice = aliceUser()
  93 + every { users.findByUsername("alice") } returns alice
  94 + every { credentials.findByUserId(alice.id) } returns null
  95 +
  96 + assertFailure { service.login("alice", "correct") }
  97 + .isInstanceOf(AuthenticationFailedException::class)
  98 + }
  99 +
  100 + @Test
  101 + fun `login fails on wrong password`() {
  102 + val alice = aliceUser()
  103 + val cred = UserCredential(userId = alice.id, passwordHash = "hash")
  104 + every { users.findByUsername("alice") } returns alice
  105 + every { credentials.findByUserId(alice.id) } returns cred
  106 + every { passwordEncoder.matches("wrong", "hash") } returns false
  107 +
  108 + assertFailure { service.login("alice", "wrong") }
  109 + .isInstanceOf(AuthenticationFailedException::class)
  110 + }
  111 +
  112 + @Test
  113 + fun `refresh fails when verifier rejects the token`() {
  114 + every { jwtVerifier.verify("garbage") } throws AuthenticationFailedException()
  115 +
  116 + assertFailure { service.refresh("garbage") }
  117 + .isInstanceOf(AuthenticationFailedException::class)
  118 + }
  119 +
  120 + @Test
  121 + fun `refresh rejects access tokens presented as refresh`() {
  122 + every { jwtVerifier.verify("access-token") } returns DecodedToken(
  123 + userId = UUID.randomUUID(),
  124 + username = "alice",
  125 + type = JwtIssuer.TYPE_ACCESS,
  126 + expiresAt = Instant.now().plusSeconds(900),
  127 + )
  128 +
  129 + assertFailure { service.refresh("access-token") }
  130 + .isInstanceOf(AuthenticationFailedException::class)
  131 + }
  132 +
  133 + @Test
  134 + fun `refresh fails when user no longer exists`() {
  135 + val ghostId = UUID.randomUUID()
  136 + every { jwtVerifier.verify("rt") } returns DecodedToken(
  137 + userId = ghostId,
  138 + username = "ghost",
  139 + type = JwtIssuer.TYPE_REFRESH,
  140 + expiresAt = Instant.now().plusSeconds(86400),
  141 + )
  142 + every { users.findById(ghostId) } returns Optional.empty()
  143 +
  144 + assertFailure { service.refresh("rt") }
  145 + .isInstanceOf(AuthenticationFailedException::class)
  146 + }
  147 +
  148 + @Test
  149 + fun `refresh mints a fresh pair on the happy path`() {
  150 + val alice = aliceUser()
  151 + every { jwtVerifier.verify("rt") } returns DecodedToken(
  152 + userId = alice.id,
  153 + username = "alice",
  154 + type = JwtIssuer.TYPE_REFRESH,
  155 + expiresAt = Instant.now().plusSeconds(86400),
  156 + )
  157 + every { users.findById(alice.id) } returns Optional.of(alice)
  158 + stubMint(alice.id, "alice")
  159 +
  160 + val pair = service.refresh("rt")
  161 +
  162 + assertThat(pair.accessToken.value).isEqualTo("access-${alice.id}")
  163 + assertThat(pair.refreshToken.value).isEqualTo("refresh-${alice.id}")
  164 + }
  165 +}
... ...
platform/platform-bootstrap/build.gradle.kts
... ... @@ -21,6 +21,8 @@ kotlin {
21 21  
22 22 dependencies {
23 23 api(project(":api:api-v1"))
  24 + api(project(":platform:platform-persistence")) // for the audit listener wiring
  25 + api(project(":platform:platform-security")) // for AuthenticationFailedException, JwtIssuer wiring
24 26 implementation(libs.kotlin.stdlib)
25 27 implementation(libs.kotlin.reflect)
26 28 implementation(libs.jackson.module.kotlin)
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
... ... @@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus
5 5 import org.springframework.http.ProblemDetail
6 6 import org.springframework.web.bind.annotation.ExceptionHandler
7 7 import org.springframework.web.bind.annotation.RestControllerAdvice
  8 +import org.vibeerp.platform.security.AuthenticationFailedException
8 9 import java.time.Instant
9 10  
10 11 /**
... ... @@ -52,6 +53,20 @@ class GlobalExceptionHandler {
52 53 problem(HttpStatus.NOT_FOUND, ex.message ?: "not found")
53 54  
54 55 /**
  56 + * All authentication failures collapse to a single 401 with a generic
  57 + * "invalid credentials" body. The framework deliberately does not
  58 + * distinguish "no such user" from "wrong password" / "expired token" /
  59 + * "wrong token type" — that distinction would help an attacker
  60 + * enumerate valid usernames. The cause is logged at DEBUG so operators
  61 + * can still triage from the server logs.
  62 + */
  63 + @ExceptionHandler(AuthenticationFailedException::class)
  64 + fun handleAuthFailed(ex: AuthenticationFailedException): ProblemDetail {
  65 + log.debug("Authentication failed: {}", ex.message)
  66 + return problem(HttpStatus.UNAUTHORIZED, "invalid credentials")
  67 + }
  68 +
  69 + /**
55 70 * Last-resort fallback. Anything not handled above is logged with a
56 71 * full stack trace and surfaced as a generic 500 to the caller. The
57 72 * detail message is intentionally vague so we don't leak internals.
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
... ... @@ -7,6 +7,7 @@ import jakarta.persistence.MappedSuperclass
7 7 import jakarta.persistence.PrePersist
8 8 import jakarta.persistence.PreUpdate
9 9 import jakarta.persistence.Version
  10 +import org.vibeerp.platform.persistence.security.PrincipalContext
10 11 import java.time.Instant
11 12 import java.util.UUID
12 13  
... ... @@ -89,8 +90,5 @@ class AuditedJpaEntityListener {
89 90 }
90 91 }
91 92  
92   - private fun currentPrincipal(): String {
93   - // TODO(v0.2): pull from PrincipalContext once pbc-identity wires up auth.
94   - return "__system__"
95   - }
  93 + private fun currentPrincipal(): String = PrincipalContext.currentOrSystem()
96 94 }
... ...
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt 0 → 100644
  1 +package org.vibeerp.platform.persistence.security
  2 +
  3 +/**
  4 + * Per-thread holder of the current request's principal id.
  5 + *
  6 + * **Why a string id and not the full `Principal` object:** the audit
  7 + * listener (and any other consumer in `platform-persistence`) only needs
  8 + * a stable identifier to write into `created_by` / `updated_by`. Holding
  9 + * the whole api.v1 `Principal` here would force `platform-persistence`
  10 + * to depend on `api/api-v1`'s security types and would couple persistence
  11 + * to the principal type hierarchy. Stringly-typed at the boundary keeps
  12 + * the layering clean: the JWT filter (in `platform-bootstrap`) is the
  13 + * one place that knows about both `Principal` and `PrincipalContext`,
  14 + * and it bridges them at request entry.
  15 + *
  16 + * **Why a `ThreadLocal`:** the audit listener is invoked from inside
  17 + * Hibernate's flush, which may happen on a thread that is not currently
  18 + * in a Spring request scope (e.g. background event handlers, scheduled
  19 + * jobs). A `ThreadLocal` is the lowest common denominator that works
  20 + * across every code path that might trigger a JPA save.
  21 + *
  22 + * **For background jobs:** the job scheduler explicitly calls [runAs]
  23 + * with a system principal id for the duration of the job, then clears it.
  24 + * Never let a job thread inherit a stale principal from a previous run.
  25 + *
  26 + * **What happens when nothing is set:** [currentOrSystem] returns the
  27 + * sentinel `__system__`, matching the v0.1 placeholder. Once the JWT
  28 + * filter is in place, every HTTP request sets the real id and the
  29 + * sentinel only appears for genuinely-system writes (boot, migrations).
  30 + */
  31 +object PrincipalContext {
  32 +
  33 + private const val SYSTEM_SENTINEL = "__system__"
  34 +
  35 + private val current = ThreadLocal<String?>()
  36 +
  37 + fun set(principalId: String) {
  38 + require(principalId.isNotBlank()) { "principalId must not be blank" }
  39 + current.set(principalId)
  40 + }
  41 +
  42 + fun clear() {
  43 + current.remove()
  44 + }
  45 +
  46 + fun currentOrNull(): String? = current.get()
  47 +
  48 + /**
  49 + * Returns the current principal id, or the system sentinel if no
  50 + * principal is bound. Safe to call from JPA listeners during boot
  51 + * or migration when no request is in flight.
  52 + */
  53 + fun currentOrSystem(): String = current.get() ?: SYSTEM_SENTINEL
  54 +
  55 + /**
  56 + * Run [block] with [principalId] bound to the current thread, restoring
  57 + * the previous value (which is usually null) afterwards. Used by
  58 + * background jobs and tests.
  59 + */
  60 + fun <T> runAs(principalId: String, block: () -> T): T {
  61 + val previous = current.get()
  62 + set(principalId)
  63 + try {
  64 + return block()
  65 + } finally {
  66 + if (previous == null) current.remove() else current.set(previous)
  67 + }
  68 + }
  69 +}
... ...
platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt 0 → 100644
  1 +package org.vibeerp.platform.persistence.security
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isInstanceOf
  7 +import assertk.assertions.isNull
  8 +import org.junit.jupiter.api.AfterEach
  9 +import org.junit.jupiter.api.Test
  10 +
  11 +class PrincipalContextTest {
  12 +
  13 + @AfterEach
  14 + fun cleanup() {
  15 + // Always clear so a leaked binding doesn't poison sibling tests.
  16 + PrincipalContext.clear()
  17 + }
  18 +
  19 + @Test
  20 + fun `currentOrNull is null by default`() {
  21 + assertThat(PrincipalContext.currentOrNull()).isNull()
  22 + }
  23 +
  24 + @Test
  25 + fun `currentOrSystem returns sentinel when nothing is bound`() {
  26 + assertThat(PrincipalContext.currentOrSystem()).isEqualTo("__system__")
  27 + }
  28 +
  29 + @Test
  30 + fun `set and currentOrNull round-trip`() {
  31 + PrincipalContext.set("user-42")
  32 + assertThat(PrincipalContext.currentOrNull()).isEqualTo("user-42")
  33 + assertThat(PrincipalContext.currentOrSystem()).isEqualTo("user-42")
  34 + }
  35 +
  36 + @Test
  37 + fun `runAs binds for the duration of the block then restores previous`() {
  38 + PrincipalContext.set("outer")
  39 + val inside = PrincipalContext.runAs("inner") { PrincipalContext.currentOrNull() }
  40 + assertThat(inside).isEqualTo("inner")
  41 + assertThat(PrincipalContext.currentOrNull()).isEqualTo("outer")
  42 + }
  43 +
  44 + @Test
  45 + fun `runAs from empty context restores to empty after the block`() {
  46 + PrincipalContext.runAs("temp") { /* no-op */ }
  47 + assertThat(PrincipalContext.currentOrNull()).isNull()
  48 + }
  49 +
  50 + @Test
  51 + fun `set rejects blank principal id`() {
  52 + assertFailure { PrincipalContext.set("") }
  53 + .isInstanceOf(IllegalArgumentException::class)
  54 + assertFailure { PrincipalContext.set(" ") }
  55 + .isInstanceOf(IllegalArgumentException::class)
  56 + }
  57 +}
... ...
platform/platform-security/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.spring.dependency.management)
  5 +}
  6 +
  7 +description = "vibe_erp platform security — JWT issuer/decoder, password encoder, Spring Security config. INTERNAL."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 +}
  14 +
  15 +kotlin {
  16 + jvmToolchain(21)
  17 + compilerOptions {
  18 + freeCompilerArgs.add("-Xjsr305=strict")
  19 + }
  20 +}
  21 +
  22 +dependencies {
  23 + api(project(":api:api-v1"))
  24 + api(project(":platform:platform-persistence")) // for PrincipalContext
  25 +
  26 + implementation(libs.kotlin.stdlib)
  27 + implementation(libs.kotlin.reflect)
  28 +
  29 + implementation(libs.spring.boot.starter)
  30 + implementation(libs.spring.boot.starter.web)
  31 + implementation(libs.spring.boot.starter.security)
  32 + implementation(libs.spring.boot.starter.oauth2.resource.server)
  33 + implementation(libs.spring.security.oauth2.jose)
  34 + runtimeOnly(libs.bouncycastle) // required by Argon2PasswordEncoder
  35 +
  36 + testImplementation(libs.spring.boot.starter.test)
  37 + testImplementation(libs.junit.jupiter)
  38 + testImplementation(libs.assertk)
  39 +}
  40 +
  41 +tasks.test {
  42 + useJUnitPlatform()
  43 +}
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +/**
  4 + * Single exception type for **all** authentication failures across the framework.
  5 + *
  6 + * Why one type instead of distinct ones for "no such user", "wrong password",
  7 + * "expired token", etc.: distinguishing the cases helps an attacker enumerate
  8 + * valid usernames and probe the auth flow. The framework is sold worldwide
  9 + * (CLAUDE.md guardrail #6) and the OWASP advice is unambiguous — fail
  10 + * uniformly.
  11 + *
  12 + * The platform's [org.vibeerp.platform.bootstrap.web.GlobalExceptionHandler]
  13 + * maps this to HTTP 401 with a generic body. The original cause is logged
  14 + * server-side at DEBUG so operators can still triage.
  15 + *
  16 + * Lives in `platform-security` (not `pbc-identity`) because it is a
  17 + * framework-level concept: every auth mechanism the framework grows in the
  18 + * future (built-in JWT, OIDC, API keys, …) raises the same exception type.
  19 + */
  20 +class AuthenticationFailedException(
  21 + message: String = "invalid credentials",
  22 + cause: Throwable? = null,
  23 +) : RuntimeException(message, cause)
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import com.nimbusds.jose.jwk.source.ImmutableSecret
  4 +import com.nimbusds.jose.proc.SecurityContext
  5 +import com.nimbusds.jose.jwk.source.JWKSource
  6 +import org.springframework.context.annotation.Bean
  7 +import org.springframework.context.annotation.Configuration
  8 +import org.springframework.security.oauth2.jose.jws.MacAlgorithm
  9 +import org.springframework.security.oauth2.jwt.JwtDecoder
  10 +import org.springframework.security.oauth2.jwt.JwtEncoder
  11 +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
  12 +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
  13 +import javax.crypto.spec.SecretKeySpec
  14 +
  15 +/**
  16 + * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans against an
  17 + * HMAC-SHA256 secret read from [JwtProperties].
  18 + *
  19 + * Why HS256 and not RS256:
  20 + * - vibe_erp is single-tenant per instance and the same process both
  21 + * issues and verifies the token, so there is no need for asymmetric
  22 + * keys to support a separate verifier.
  23 + * - HS256 keeps the deployment story to "set one env var" instead of
  24 + * "manage and distribute a keypair", which matches the self-hosted-
  25 + * first goal in CLAUDE.md guardrail #5.
  26 + * - RS256 is straightforward to add later if a federated trust story
  27 + * ever appears (it would land in OIDC, P4.2).
  28 + */
  29 +@Configuration
  30 +class JwtConfiguration(
  31 + private val properties: JwtProperties,
  32 +) {
  33 +
  34 + init {
  35 + require(properties.secret.length >= MIN_SECRET_BYTES) {
  36 + "vibeerp.security.jwt.secret is too short (${properties.secret.length} bytes); " +
  37 + "HS256 requires at least $MIN_SECRET_BYTES bytes. " +
  38 + "Set the VIBEERP_JWT_SECRET environment variable in production."
  39 + }
  40 + }
  41 +
  42 + private val secretKey: SecretKeySpec by lazy {
  43 + SecretKeySpec(properties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
  44 + }
  45 +
  46 + @Bean
  47 + fun jwtEncoder(): JwtEncoder {
  48 + val jwkSource: JWKSource<SecurityContext> = ImmutableSecret(secretKey)
  49 + return NimbusJwtEncoder(jwkSource)
  50 + }
  51 +
  52 + @Bean
  53 + fun jwtDecoder(): JwtDecoder =
  54 + NimbusJwtDecoder.withSecretKey(secretKey)
  55 + .macAlgorithm(MacAlgorithm.HS256)
  56 + .build()
  57 +
  58 + private companion object {
  59 + const val MIN_SECRET_BYTES = 32
  60 + }
  61 +}
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import org.springframework.security.oauth2.jose.jws.MacAlgorithm
  4 +import org.springframework.security.oauth2.jwt.JwsHeader
  5 +import org.springframework.security.oauth2.jwt.JwtClaimsSet
  6 +import org.springframework.security.oauth2.jwt.JwtEncoder
  7 +import org.springframework.security.oauth2.jwt.JwtEncoderParameters
  8 +import org.springframework.stereotype.Component
  9 +import java.time.Instant
  10 +import java.time.temporal.ChronoUnit
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * Mints access and refresh tokens for authenticated users.
  15 + *
  16 + * Both token kinds carry the same payload shape (`sub`, `username`,
  17 + * `iss`, `iat`, `exp`, `type`) and differ only in the `type` claim
  18 + * (`access` vs `refresh`) and in lifetime. The `type` claim is what
  19 + * stops a refresh token from being accepted as an access token by the
  20 + * resource server, and vice versa.
  21 + */
  22 +@Component
  23 +class JwtIssuer(
  24 + private val encoder: JwtEncoder,
  25 + private val properties: JwtProperties,
  26 +) {
  27 +
  28 + fun issueAccessToken(userId: UUID, username: String): IssuedToken =
  29 + issue(userId, username, type = TYPE_ACCESS, ttlSeconds = properties.accessTokenTtl.seconds)
  30 +
  31 + fun issueRefreshToken(userId: UUID, username: String): IssuedToken =
  32 + issue(userId, username, type = TYPE_REFRESH, ttlSeconds = properties.refreshTokenTtl.seconds)
  33 +
  34 + private fun issue(
  35 + userId: UUID,
  36 + username: String,
  37 + type: String,
  38 + ttlSeconds: Long,
  39 + ): IssuedToken {
  40 + // Truncate to whole seconds because the JWT `iat` and `exp` claims
  41 + // are integer-second values (RFC 7519). Without this truncation
  42 + // the [IssuedToken] returned to the caller carries nanosecond
  43 + // precision that the decoder will then strip — round-trip equality
  44 + // tests fail and refresh-token expiry checks become inconsistent.
  45 + val now = Instant.now().truncatedTo(ChronoUnit.SECONDS)
  46 + val expiresAt = now.plusSeconds(ttlSeconds)
  47 + val claims = JwtClaimsSet.builder()
  48 + .issuer(properties.issuer)
  49 + .subject(userId.toString())
  50 + .issuedAt(now)
  51 + .expiresAt(expiresAt)
  52 + .claim("username", username)
  53 + .claim("type", type)
  54 + .build()
  55 + val headers = JwsHeader.with(MacAlgorithm.HS256).build()
  56 + val token = encoder.encode(JwtEncoderParameters.from(headers, claims)).tokenValue
  57 + return IssuedToken(value = token, expiresAt = expiresAt)
  58 + }
  59 +
  60 + companion object {
  61 + const val TYPE_ACCESS = "access"
  62 + const val TYPE_REFRESH = "refresh"
  63 + }
  64 +}
  65 +
  66 +data class IssuedToken(
  67 + val value: String,
  68 + val expiresAt: Instant,
  69 +)
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties
  4 +import java.time.Duration
  5 +
  6 +/**
  7 + * JWT configuration bound from `vibeerp.security.jwt.*`.
  8 + *
  9 + * Why this lives in `platform-bootstrap` rather than in `pbc-identity`:
  10 + * the JWT machinery (issuer, decoder, filter chain) is part of the
  11 + * **framework's** security wall, not part of the identity domain. Other
  12 + * PBCs and other auth mechanisms (OIDC, future API keys) all hang off
  13 + * the same `Principal`, the same filter chain, and the same set of
  14 + * properties. Identity owns the *user store*; bootstrap owns the
  15 + * *machinery* that turns a user into a request principal.
  16 + *
  17 + * The signing secret is **mandatory in production** and is read from an
  18 + * environment variable. A development default is provided in
  19 + * `application-dev.yaml` so `gradle bootRun` works out of the box.
  20 + */
  21 +@ConfigurationProperties(prefix = "vibeerp.security.jwt")
  22 +data class JwtProperties(
  23 + /**
  24 + * HMAC-SHA256 signing secret. Must be at least 32 bytes (256 bits)
  25 + * for HS256. The framework refuses to start if a shorter value is
  26 + * configured.
  27 + */
  28 + val secret: String = "",
  29 + /**
  30 + * Issuer claim (`iss`) embedded in every token. Identifies which
  31 + * vibe_erp instance minted the token; used by the verifier to reject
  32 + * tokens from unrelated installations.
  33 + */
  34 + val issuer: String = "vibe-erp",
  35 + /** Lifetime of access tokens. Short by default; refresh tokens carry the long-lived state. */
  36 + val accessTokenTtl: Duration = Duration.ofMinutes(15),
  37 + /** Lifetime of refresh tokens. */
  38 + val refreshTokenTtl: Duration = Duration.ofDays(7),
  39 +)
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import org.springframework.security.oauth2.jwt.JwtDecoder
  4 +import org.springframework.security.oauth2.jwt.JwtException
  5 +import org.springframework.stereotype.Component
  6 +import java.time.Instant
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Validate-and-decode wrapper for inbound JWTs.
  11 + *
  12 + * Why this exists separately from Spring Security's [JwtDecoder]:
  13 + * - PBCs (and plug-ins, eventually) need to verify tokens without
  14 + * pulling in `spring-security-oauth2-jose` themselves. Adding that
  15 + * dependency to every PBC's compile classpath would mean every test
  16 + * suite has to construct a fake `JwtDecoder` and every plug-in author
  17 + * has to learn the OAuth2 type hierarchy.
  18 + * - Returning a small typed [DecodedToken] instead of Spring's `Jwt`
  19 + * keeps the surface in our control: when we add a `roles` claim
  20 + * later, we add a field here, not learn how to navigate Spring's
  21 + * `getClaimAsStringList`.
  22 + * - Translating `JwtException` and parse failures into a single
  23 + * [AuthenticationFailedException] aligns with the OWASP "fail
  24 + * uniformly" principle (see [AuthenticationFailedException] KDoc).
  25 + */
  26 +@Component
  27 +class JwtVerifier(
  28 + private val decoder: JwtDecoder,
  29 +) {
  30 +
  31 + /**
  32 + * Decode and validate [token]. Throws [AuthenticationFailedException]
  33 + * on any failure mode (bad signature, expired, malformed subject,
  34 + * unknown type) — the caller cannot distinguish, by design.
  35 + */
  36 + fun verify(token: String): DecodedToken {
  37 + val decoded = try {
  38 + decoder.decode(token)
  39 + } catch (ex: JwtException) {
  40 + throw AuthenticationFailedException("token decode failed", ex)
  41 + }
  42 +
  43 + val type = decoded.getClaimAsString("type")
  44 + ?: throw AuthenticationFailedException("token has no type claim")
  45 +
  46 + val subject = decoded.subject
  47 + ?: throw AuthenticationFailedException("token has no subject")
  48 +
  49 + val userId = try {
  50 + UUID.fromString(subject)
  51 + } catch (ex: IllegalArgumentException) {
  52 + throw AuthenticationFailedException("token subject is not a UUID", ex)
  53 + }
  54 +
  55 + val username = decoded.getClaimAsString("username")
  56 + ?: throw AuthenticationFailedException("token has no username claim")
  57 +
  58 + val expiresAt = decoded.expiresAt
  59 + ?: throw AuthenticationFailedException("token has no exp claim")
  60 +
  61 + return DecodedToken(
  62 + userId = userId,
  63 + username = username,
  64 + type = type,
  65 + expiresAt = expiresAt,
  66 + )
  67 + }
  68 +}
  69 +
  70 +/**
  71 + * Plain data result of [JwtVerifier.verify]. Decoupled from Spring's
  72 + * `Jwt` so consumers do not need OAuth2 types on their classpath.
  73 + */
  74 +data class DecodedToken(
  75 + val userId: UUID,
  76 + val username: String,
  77 + val type: String,
  78 + val expiresAt: Instant,
  79 +)
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import jakarta.servlet.FilterChain
  4 +import jakarta.servlet.http.HttpServletRequest
  5 +import jakarta.servlet.http.HttpServletResponse
  6 +import org.springframework.security.core.context.SecurityContextHolder
  7 +import org.springframework.security.oauth2.jwt.Jwt
  8 +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
  9 +import org.springframework.stereotype.Component
  10 +import org.springframework.web.filter.OncePerRequestFilter
  11 +import org.vibeerp.platform.persistence.security.PrincipalContext
  12 +
  13 +/**
  14 + * Bridges Spring Security's authenticated principal into vibe_erp's
  15 + * [PrincipalContext] for the duration of the request.
  16 + *
  17 + * Spring Security puts the authenticated `Authentication` object on a
  18 + * thread-local of its own. Our JPA audit listener — which lives in
  19 + * `platform-persistence` — must NOT depend on Spring Security types
  20 + * (it would force every PBC's compile classpath to include Spring
  21 + * Security). The bridge solves this by reading the JWT subject from
  22 + * Spring Security's context and writing it as a plain string into
  23 + * [PrincipalContext], which is in the persistence module and so
  24 + * legitimately importable by the audit listener.
  25 + *
  26 + * The filter is registered immediately after Spring Security's
  27 + * authentication filter so the `SecurityContext` is fully populated by
  28 + * the time we read it.
  29 + *
  30 + * On unauthenticated requests (the public allowlist) the
  31 + * `SecurityContext` is empty and we leave the principal unset, which
  32 + * means the audit listener falls back to its `__system__` sentinel —
  33 + * the right behavior for boot-time and migration writes.
  34 + */
  35 +@Component
  36 +class PrincipalContextFilter : OncePerRequestFilter() {
  37 +
  38 + override fun doFilterInternal(
  39 + request: HttpServletRequest,
  40 + response: HttpServletResponse,
  41 + filterChain: FilterChain,
  42 + ) {
  43 + val auth = SecurityContextHolder.getContext().authentication
  44 + val subject: String? = when (auth) {
  45 + is JwtAuthenticationToken -> (auth.principal as? Jwt)?.subject
  46 + else -> null
  47 + }
  48 +
  49 + if (subject != null) {
  50 + PrincipalContext.runAs(subject) {
  51 + filterChain.doFilter(request, response)
  52 + }
  53 + } else {
  54 + filterChain.doFilter(request, response)
  55 + }
  56 + }
  57 +}
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import org.springframework.context.annotation.Bean
  4 +import org.springframework.context.annotation.Configuration
  5 +import org.springframework.security.config.annotation.web.builders.HttpSecurity
  6 +import org.springframework.security.config.http.SessionCreationPolicy
  7 +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
  8 +import org.springframework.security.crypto.password.PasswordEncoder
  9 +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
  10 +import org.springframework.security.web.SecurityFilterChain
  11 +
  12 +/**
  13 + * Spring Security configuration for the vibe_erp HTTP surface.
  14 + *
  15 + * The setup:
  16 + * • CSRF disabled (this is a stateless REST API; cookies are not used).
  17 + * • Sessions are stateless — every request must carry its own JWT.
  18 + * • A small public-paths allowlist for actuator probes, the meta info
  19 + * endpoint, and the auth endpoints themselves (chicken-and-egg).
  20 + * • Everything else requires a valid Bearer JWT validated by Spring
  21 + * Security's resource server.
  22 + * • A custom filter ([PrincipalContextFilter]) runs immediately after
  23 + * authentication to bridge the resolved `Principal` into the
  24 + * [org.vibeerp.platform.persistence.security.PrincipalContext] so the
  25 + * audit listener picks it up.
  26 + */
  27 +@Configuration
  28 +class SecurityConfiguration {
  29 +
  30 + @Bean
  31 + fun securityFilterChain(
  32 + http: HttpSecurity,
  33 + principalContextFilter: PrincipalContextFilter,
  34 + ): SecurityFilterChain {
  35 + http
  36 + .csrf { it.disable() }
  37 + .cors { } // default; tighten in v0.4 once the React SPA exists
  38 + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
  39 + .authorizeHttpRequests { auth ->
  40 + auth.requestMatchers(
  41 + // Public actuator probes
  42 + "/actuator/health",
  43 + "/actuator/info",
  44 + // Meta info — no secrets, used by load balancers and CI
  45 + "/api/v1/_meta/**",
  46 + // Auth endpoints themselves cannot require auth
  47 + "/api/v1/auth/login",
  48 + "/api/v1/auth/refresh",
  49 + ).permitAll()
  50 + auth.anyRequest().authenticated()
  51 + }
  52 + .oauth2ResourceServer { rs ->
  53 + rs.jwt { } // uses the JwtDecoder bean from JwtConfiguration
  54 + }
  55 + // Bridge Spring Security's authenticated principal into
  56 + // platform-persistence's PrincipalContext so JPA listeners
  57 + // see the right user. Must run AFTER `BearerTokenAuthenticationFilter`
  58 + // (the OAuth2 resource server filter that resolves a Bearer JWT
  59 + // into a SecurityContext) — otherwise SecurityContextHolder is
  60 + // empty when we read it and every request looks anonymous to
  61 + // the audit listener.
  62 + .addFilterAfter(principalContextFilter, BearerTokenAuthenticationFilter::class.java)
  63 +
  64 + return http.build()
  65 + }
  66 +
  67 + /**
  68 + * Argon2id password encoder used by [org.vibeerp.pbc.identity.application.AuthService]
  69 + * to hash and verify credentials. Defined here (not in pbc-identity)
  70 + * because the encoder is a framework primitive — every PBC that ever
  71 + * stores a secret will reuse the same instance.
  72 + *
  73 + * Parameters are Spring Security 6's "modern" defaults
  74 + * (`Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8`):
  75 + * - 16 byte salt
  76 + * - 32 byte hash
  77 + * - parallelism 1
  78 + * - 4096 iterations
  79 + * - 19 MiB memory cost
  80 + */
  81 + @Bean
  82 + fun passwordEncoder(): PasswordEncoder =
  83 + Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
  84 +}
... ...
platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt 0 → 100644
  1 +package org.vibeerp.platform.security
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isInstanceOf
  7 +import org.junit.jupiter.api.BeforeEach
  8 +import org.junit.jupiter.api.Test
  9 +import java.util.UUID
  10 +
  11 +/**
  12 + * End-to-end JWT issuer/verifier round-trip test using the *real*
  13 + * Spring Security `NimbusJwtEncoder` / `NimbusJwtDecoder` against an
  14 + * HMAC-SHA256 secret.
  15 + *
  16 + * No Spring context is started — we instantiate `JwtConfiguration`
  17 + * manually and pull the encoder and decoder out of it. That keeps the
  18 + * test under 100ms and proves the wiring without dragging Spring Boot
  19 + * into a unit test.
  20 + */
  21 +class JwtRoundTripTest {
  22 +
  23 + private val properties = JwtProperties(
  24 + secret = "test-secret-with-at-least-32-chars-of-entropy",
  25 + issuer = "vibe-erp-test",
  26 + )
  27 + private lateinit var issuer: JwtIssuer
  28 + private lateinit var verifier: JwtVerifier
  29 +
  30 + @BeforeEach
  31 + fun setUp() {
  32 + val cfg = JwtConfiguration(properties)
  33 + issuer = JwtIssuer(cfg.jwtEncoder(), properties)
  34 + verifier = JwtVerifier(cfg.jwtDecoder())
  35 + }
  36 +
  37 + @Test
  38 + fun `issued access token decodes back to the same user`() {
  39 + val userId = UUID.randomUUID()
  40 + val issued = issuer.issueAccessToken(userId, "alice")
  41 +
  42 + val decoded = verifier.verify(issued.value)
  43 +
  44 + assertThat(decoded.userId).isEqualTo(userId)
  45 + assertThat(decoded.username).isEqualTo("alice")
  46 + assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_ACCESS)
  47 + assertThat(decoded.expiresAt).isEqualTo(issued.expiresAt)
  48 + }
  49 +
  50 + @Test
  51 + fun `issued refresh token has refresh type`() {
  52 + val userId = UUID.randomUUID()
  53 + val issued = issuer.issueRefreshToken(userId, "bob")
  54 +
  55 + val decoded = verifier.verify(issued.value)
  56 +
  57 + assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_REFRESH)
  58 + }
  59 +
  60 + @Test
  61 + fun `tampered token is rejected`() {
  62 + val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol")
  63 + // Flip the last character of the signature segment.
  64 + val tampered = issued.value.dropLast(1) + if (issued.value.last() == 'a') 'b' else 'a'
  65 +
  66 + assertFailure { verifier.verify(tampered) }
  67 + .isInstanceOf(AuthenticationFailedException::class)
  68 + }
  69 +
  70 + @Test
  71 + fun `garbage string is rejected`() {
  72 + assertFailure { verifier.verify("not-a-jwt") }
  73 + .isInstanceOf(AuthenticationFailedException::class)
  74 + }
  75 +
  76 + @Test
  77 + fun `JwtConfiguration refuses too-short secret`() {
  78 + assertFailure { JwtConfiguration(JwtProperties(secret = "too-short")) }
  79 + .isInstanceOf(IllegalArgumentException::class)
  80 + }
  81 +}
... ...
settings.gradle.kts
... ... @@ -30,6 +30,9 @@ project(&quot;:platform:platform-persistence&quot;).projectDir = file(&quot;platform/platform-p
30 30 include(":platform:platform-plugins")
31 31 project(":platform:platform-plugins").projectDir = file("platform/platform-plugins")
32 32  
  33 +include(":platform:platform-security")
  34 +project(":platform:platform-security").projectDir = file("platform/platform-security")
  35 +
33 36 // ─── Packaged Business Capabilities (core PBCs) ─────────────────────
34 37 include(":pbc:pbc-identity")
35 38 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")
... ...