diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts
index d59d0bf..133aba5 100644
--- a/distribution/build.gradle.kts
+++ b/distribution/build.gradle.kts
@@ -22,6 +22,7 @@ dependencies {
implementation(project(":platform:platform-bootstrap"))
implementation(project(":platform:platform-persistence"))
implementation(project(":platform:platform-plugins"))
+ implementation(project(":platform:platform-security"))
implementation(project(":pbc:pbc-identity"))
implementation(libs.spring.boot.starter)
diff --git a/distribution/src/main/resources/application-dev.yaml b/distribution/src/main/resources/application-dev.yaml
index 0d66f6f..752cea8 100644
--- a/distribution/src/main/resources/application-dev.yaml
+++ b/distribution/src/main/resources/application-dev.yaml
@@ -11,6 +11,12 @@ spring:
password: vibeerp
vibeerp:
+ security:
+ jwt:
+ # Dev-only secret — DO NOT use this in production. The application.yaml
+ # template reads VIBEERP_JWT_SECRET from the environment and refuses
+ # to start without a real value.
+ secret: dev-only-secret-must-be-at-least-32-chars-please
plugins:
directory: ./plugins-dev
files:
diff --git a/distribution/src/main/resources/application.yaml b/distribution/src/main/resources/application.yaml
index 2f1b579..d904d07 100644
--- a/distribution/src/main/resources/application.yaml
+++ b/distribution/src/main/resources/application.yaml
@@ -46,6 +46,15 @@ vibeerp:
# running instance — provisioning a second customer means deploying a
# second instance with its own database.
company-name: ${VIBEERP_COMPANY_NAME:vibe_erp instance}
+ security:
+ jwt:
+ # HMAC-SHA256 secret. Must be at least 32 characters in production.
+ # Set the VIBEERP_JWT_SECRET environment variable; the framework
+ # refuses to start if a shorter value is used.
+ secret: ${VIBEERP_JWT_SECRET:}
+ issuer: vibe-erp
+ access-token-ttl: PT15M
+ refresh-token-ttl: P7D
plugins:
directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins}
auto-load: true
diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml
index 32931c9..8d8261d 100644
--- a/distribution/src/main/resources/db/changelog/master.xml
+++ b/distribution/src/main/resources/db/changelog/master.xml
@@ -12,4 +12,5 @@
https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
+
diff --git a/distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml b/distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml
new file mode 100644
index 0000000..2dd8af3
--- /dev/null
+++ b/distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Create identity__user_credential table
+
+ CREATE TABLE identity__user_credential (
+ id uuid PRIMARY KEY,
+ user_id uuid NOT NULL UNIQUE
+ REFERENCES identity__user(id) ON DELETE CASCADE,
+ password_hash text NOT NULL,
+ password_algo varchar(32) NOT NULL DEFAULT 'argon2id',
+ must_change boolean NOT NULL DEFAULT false,
+ last_changed_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL,
+ created_by varchar(128) NOT NULL,
+ updated_at timestamptz NOT NULL,
+ updated_by varchar(128) NOT NULL,
+ version bigint NOT NULL DEFAULT 0
+ );
+ CREATE INDEX identity__user_credential_user_id_idx
+ ON identity__user_credential (user_id);
+
+
+ DROP TABLE identity__user_credential;
+
+
+
+
diff --git a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
index 7de6d4c..2907cb6 100644
--- a/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
+++ b/docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md
@@ -1,32 +1,38 @@
# vibe_erp — Implementation Plan (post-v0.1)
-**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.
+**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.
**Date:** 2026-04-07
**Companion document:** [Architecture spec](2026-04-07-vibe-erp-architecture-design.md)
----
+> **2026-04-07 update — single-tenant refactor.** vibe_erp is now deliberately
+> single-tenant per instance: one running process serves exactly one company
+> against an isolated Postgres database. All multi-tenancy work has been
+> removed from the framework — no `tenant_id` columns, no Hibernate tenant
+> filter, no Postgres Row-Level Security policies, no `TenantContext`. The
+> previously-deferred P1.1 (RLS transaction hook) and H1 (per-region tenant
+> routing) units are gone. See CLAUDE.md guardrail #5 for the rationale.
-## What v0.1 (this commit) actually delivers
+---
-A buildable, runnable, tested skeleton of the vibe_erp framework. Concretely:
+## What v0.1 actually delivers (verified on a running app)
| Item | Status |
|---|---|
-| Gradle multi-project with 7 subprojects | ✅ Builds |
-| `api/api-v1` — public plug-in contract (~30 files, semver-governed) | ✅ Compiles, unit-tested |
-| `platform/platform-bootstrap` — Spring Boot main, config, tenant filter | ✅ Compiles |
-| `platform/platform-persistence` — multi-tenant JPA scaffolding, audit base | ✅ Compiles |
-| `platform/platform-plugins` — PF4J host, lifecycle | ✅ Compiles |
-| `pbc/pbc-identity` — User entity end-to-end (entity → repo → service → REST) | ✅ Compiles, unit-tested |
-| `reference-customer/plugin-printing-shop` — hello-world PF4J plug-in | ✅ Compiles, packs into a real plug-in JAR with PF4J manifest |
-| `distribution` — Spring Boot fat jar (`vibe-erp.jar`, ~59 MB) | ✅ `bootJar` succeeds |
-| Liquibase changelogs (platform init + identity init) | ✅ Schema-valid, runs against Postgres on first boot |
-| Dockerfile (multi-stage), docker-compose.yaml (app + Postgres), Makefile | ✅ Present, image build untested in this session |
-| GitHub Actions workflows (build + architecture-rule check) | ✅ Present, untested in CI |
-| 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/*` |
-| README, CONTRIBUTING, LICENSE, 8 docs guides | ✅ Present |
-| Architecture spec (this folder) | ✅ Present |
-| Implementation plan (this file) | ✅ Present |
+| Gradle multi-project, 7 subprojects | ✅ Builds end-to-end |
+| `api/api-v1` — public plug-in contract | ✅ Compiles, 12 unit tests |
+| `platform/platform-bootstrap` — Spring Boot main, properties, `@EnableJpaRepositories`, `GlobalExceptionHandler` | ✅ |
+| `platform/platform-persistence` — JPA, audit listener (no tenant scaffolding) | ✅ |
+| `platform/platform-plugins` — PF4J host, lifecycle | ✅ |
+| `pbc/pbc-identity` — User entity end-to-end | ✅ Compiles, 6 unit tests, smoke-tested via REST against real Postgres |
+| `reference-customer/plugin-printing-shop` — hello-world PF4J plug-in | ✅ Compiles, packs into a real PF4J JAR |
+| `distribution` — Spring Boot fat jar (`vibe-erp.jar`) | ✅ `bootJar` succeeds, `bootRun` boots cleanly in dev profile |
+| Liquibase changelogs (platform init + identity init) | ✅ Run cleanly against fresh Postgres |
+| Dockerfile, docker-compose.yaml, Makefile | ✅ Present; `docker compose up -d db` verified |
+| GitHub Actions workflows | ✅ Present, running in CI on every push to https://github.com/reporkey/vibe-erp |
+| 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` |
+| README, CONTRIBUTING, LICENSE, 8 docs guides | ✅ |
+| Architecture spec + this implementation plan | ✅ |
+| **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 |
**What v0.1 explicitly does NOT deliver:**
@@ -34,9 +40,8 @@ A buildable, runnable, tested skeleton of the vibe_erp framework. Concretely:
- The metadata store machinery (custom-field application, form designer, list views, rules engine)
- Embedded Flowable workflow engine
- ICU4J `Translator` implementation
-- Postgres Row-Level Security `SET LOCAL` hook (the policies exist; the hook that sets `vibeerp.current_tenant` per transaction is deferred)
- JasperReports
-- OIDC integration
+- Real authentication (anonymous access; the bootstrap admin lands in P4.1)
- React web SPA
- Plug-in linter (rejecting reflection into platform internals at install time)
- Plug-in Liquibase application
@@ -66,12 +71,6 @@ The 11 architecture guardrails in `CLAUDE.md` and the 14-section design in `2026
These units finish the platform layer so PBCs can be implemented without inventing scaffolding.
-### P1.1 — Postgres RLS transaction hook
-**Module:** `platform-persistence`
-**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.**
-**What:** A Hibernate `StatementInspector` (or a Spring `TransactionSynchronization`) that runs `SET LOCAL vibeerp.current_tenant = ''` at the start of every transaction, reading from `TenantContext`.
-**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.
-
### P1.2 — Plug-in linter
**Module:** `platform-plugins`
**Depends on:** —
@@ -99,8 +98,8 @@ These units finish the platform layer so PBCs can be implemented without inventi
### P1.6 — `Translator` implementation backed by ICU4J
**Module:** new `platform-i18n` module
**Depends on:** P1.5 (so plug-in message bundles can be loaded as part of metadata)
-**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_.properties` → core `i18n/messages_.properties` → fallback locale.
-**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.
+**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_.properties` → core `i18n/messages_.properties` → fallback locale.
+**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.
### P1.7 — Event bus + outbox
**Module:** new `platform-events` module
@@ -117,14 +116,14 @@ These units finish the platform layer so PBCs can be implemented without inventi
### P1.9 — File store (local + S3)
**Module:** new `platform-files` module
**Depends on:** —
-**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.
+**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.
**Acceptance:** unit tests for both backends; integration test with MinIO Testcontainer for S3.
### P1.10 — Job scheduler
**Module:** new `platform-jobs` module
**Depends on:** —
-**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.
-**Acceptance:** a recurring core job that prunes the audit log; integration test confirms the job runs in the right tenant context.
+**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.
+**Acceptance:** a recurring core job that prunes the audit log; integration test confirms the job runs with the expected system principal in audit columns.
---
@@ -144,7 +143,7 @@ These units finish the platform layer so PBCs can be implemented without inventi
### P2.3 — User task / form rendering
**Module:** `platform-workflow` + future React SPA
-**Depends on:** P2.1, F1 (form designer), R1
+**Depends on:** P2.1, P3.3 (form designer), R1
**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.
**Acceptance:** end-to-end test of an approval workflow where a human clicks a button.
@@ -179,29 +178,38 @@ These units finish the platform layer so PBCs can be implemented without inventi
### P3.5 — Rules engine (simple "if X then Y")
**Module:** new `platform-rules` module
**Depends on:** P1.7 (events)
-**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).
+**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).
**Acceptance:** integration test where a rule "on UserCreated, set ext.greeting = 'Welcome'" actually fires.
### P3.6 — List view designer (web)
**Module:** future React SPA
**Depends on:** R1
-**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.
+**What:** A grid component backed by `metadata__list_view` rows. Operators pick columns, filters, sort, save as a named list view.
**Acceptance:** e2e test where a user creates a "Active customers in Germany" list view and re-opens it later.
---
-## Phase 4 — Authentication and authorization (real)
+## Phase 4 — Authentication and authorization (PROMOTED to next priority)
+
+> **Promoted from "after the platform layer" to "do this next."** With the
+> single-tenant refactor, there is no second wall protecting data. Auth is
+> the only security. Until P4.1 lands, vibe_erp is only safe to run on
+> `localhost`.
### P4.1 — Built-in JWT auth
-**Module:** new `pbc-auth` (under `pbc/`) + `platform-security` (might already exist)
+**Module:** `pbc-identity` (extended) + `platform-bootstrap` (Spring Security config)
**Depends on:** —
-**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.
-**Acceptance:** integration test of full login/logout/refresh flow; assertion that the JWT carries `tenant` and that downstream PBC code sees it via `Principal`.
+**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.
+**Acceptance:** end-to-end smoke test against the running app:
+- unauthenticated `GET /api/v1/identity/users` → 401
+- `POST /api/v1/auth/login` with admin → 200 + access + refresh tokens
+- `GET /api/v1/identity/users` with `Authorization: Bearer …` → 200
+- new user created via REST has `created_by = `, not `__system__`
### P4.2 — OIDC integration
-**Module:** `pbc-auth`
+**Module:** `pbc-identity`
**Depends on:** P4.1
-**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.
+**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.
**Acceptance:** Keycloak Testcontainer + integration test of a full OIDC code flow; the same `UserController` endpoints work without changes.
### P4.3 — Permission checking (real)
@@ -221,9 +229,13 @@ Each PBC follows the same 7-step recipe:
3. Spring Data JPA repositories
4. Application services
5. REST controllers under `/api/v1//`
-6. Liquibase changelog (`db/changelog//`) with rollback blocks AND RLS policies
+6. Liquibase changelog (`db/changelog//`) with rollback blocks
7. Cross-PBC facade in `api.v1.ext.` + adapter in the PBC
+> No `tenant_id` columns. No RLS policies. vibe_erp is single-tenant per
+> instance — everything in the database belongs to the one company that
+> owns the instance.
+
### P5.1 — `pbc-catalog` — items, units of measure, attributes
Foundation for everything that's bought, sold, made, or stored. Hold off on price lists and configurable products until P5.10.
@@ -290,28 +302,26 @@ Defer until P5.5 (orders-sales) is real, since pricing only matters when orders
**Module:** same plug-in
**Depends on:** P1.4 (plug-in Liquibase application)
**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`.
-**Acceptance:** integration test that a printing-shop user can CRUD plates against `/api/v1/plugins/printing-shop/plates`, scoped to their tenant.
+**Acceptance:** integration test that a printing-shop user can CRUD plates against `/api/v1/plugins/printing-shop/plates`.
### REF.3 — Real reference forms and metadata
**Module:** same plug-in
**Depends on:** P3.3, P3.4
**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'`.
-**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.
+**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.
---
-## Phase 8 — Hosted, AI agents, mobile (post-v1.0)
-
-### H1 — Per-region tenant routing (hosted)
-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.
+## Phase 8 — Hosted operations, AI agents, mobile (post-v1.0)
-### H2 — Tenant provisioning UI
-A per-instance "operator console" that creates tenants, manages quotas, watches health. Separate React app, separate URL.
+### H1 — Instance provisioning console (hosted)
+**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.
+**Acceptance:** can provision and tear down a customer instance via a single CLI command.
### A1 — MCP server
**Module:** new `platform-mcp`
**Depends on:** every PBC, every endpoint having a typed OpenAPI definition
-**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).
+**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).
**Acceptance:** an LLM-driven agent can call `vibe_erp.identity.users.create` and the result is identical to a REST call.
### M1 — React Native skeleton
@@ -321,34 +331,33 @@ Shared TypeScript types with the web SPA; first screens cover shop-floor scannin
## Tracking and ordering
-A coarse dependency view:
+A coarse dependency view (after the single-tenant refactor):
```
-P1.1 — RLS hook ─────┐
-P1.2 — linter ──┐ │
-P1.3 — child ctx ┘ │
-P1.4 — plug-in liquibase ─ depends on P1.3
-P1.5 — metadata seed ──┐ ──┐
+P4.1 — auth (PROMOTED) ─────────┐
+P1.5 — metadata seed ──┐ ──┐ │
P1.6 — translator ──── (depends on P1.5)
P1.7 — events ─────────┘ │
-P1.8 — reports ── (depends on P1.5)
+P1.2 — linter ──┐
+P1.3 — child ctx ┘
+P1.4 — plug-in liquibase ── depends on P1.3
+P1.8 — reports ── depends on P1.5
P1.9 — files
P1.10 — jobs
-P2.1 — Flowable ── (depends on P1.5, P1.6, P1.7)
-P3.x — metadata Tier 1 ── (depends on P1.5)
-P4.x — auth
-P5.x — core PBCs ── (depend on P1.x complete + P4.x for permission checks)
-R1 — web bootstrap ── (depends on P4.1)
-REF.x — reference plug-in ── (depends on P1.4 + P2.1 + P3.3)
+P2.1 — Flowable ── depends on P1.5, P1.6, P1.7
+P3.x — metadata Tier 1 ── depends on P1.5
+P5.x — core PBCs ── depend on P1.x complete + P4.x for permission checks
+R1 — web bootstrap ── depends on P4.1
+REF.x — reference plug-in ── depends on P1.4 + P2.1 + P3.3
```
-Sensible ordering for one developer:
-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 → ...
+Sensible ordering for one developer (single-tenant world):
+**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 → ...**
Sensible parallel ordering for a team of three:
-- Dev A: P1.1, P1.5, P1.7, P1.6, P2.1
+- Dev A: **P4.1**, P1.5, P1.7, P1.6, P2.1
- Dev B: P1.4, P1.3, P1.2, P3.4, P3.1
-- Dev C: P4.1, P4.3, P5.1 (catalog), P5.2 (partners)
+- Dev C: P5.1 (catalog), P5.2 (partners), P4.3
Then converge on R1 (web) and the rest of Phase 5 in parallel.
@@ -358,9 +367,9 @@ Then converge on R1 (web) and the rest of Phase 5 in parallel.
v1.0 ships when, on a fresh Postgres, an operator can:
-1. `docker run` the image
-2. Log in to the web SPA
-3. Create a tenant (or use the default tenant in self-host mode)
+1. `docker run` the image with a connection string and a company name
+2. Read the bootstrap admin password from the logs
+3. Log in to the web SPA
4. Drop the printing-shop plug-in JAR into `./plugins/`
5. Restart the container
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:
8. Generate a PDF report for that quote in zh-CN
9. Export a DSAR for one of their customers
-…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.
+…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.)
That's the bar. Until that bar is met, the framework has not delivered on its premise.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 04d3f49..1a87993 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,12 @@ spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-start
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "springBoot" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" }
+spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springBoot" }
+spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "springBoot" }
+spring-security-oauth2-jose = { module = "org.springframework.security:spring-security-oauth2-jose", version = "6.3.3" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springBoot" }
+spring-security-test = { module = "org.springframework.security:spring-security-test", version = "6.3.3" }
+bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.78.1" }
# Kotlin
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
diff --git a/pbc/pbc-identity/build.gradle.kts b/pbc/pbc-identity/build.gradle.kts
index 121d7d3..cd5a62b 100644
--- a/pbc/pbc-identity/build.gradle.kts
+++ b/pbc/pbc-identity/build.gradle.kts
@@ -39,6 +39,7 @@ allOpen {
dependencies {
api(project(":api:api-v1"))
implementation(project(":platform:platform-persistence"))
+ implementation(project(":platform:platform-security"))
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.reflect)
@@ -47,6 +48,7 @@ dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.data.jpa)
implementation(libs.spring.boot.starter.validation)
+ implementation(libs.spring.boot.starter.security) // Argon2PasswordEncoder
implementation(libs.jackson.module.kotlin)
testImplementation(libs.spring.boot.starter.test)
diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt
new file mode 100644
index 0000000..57d51e7
--- /dev/null
+++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt
@@ -0,0 +1,77 @@
+package org.vibeerp.pbc.identity.application
+
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import org.vibeerp.pbc.identity.domain.User
+import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
+import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
+import org.vibeerp.platform.security.AuthenticationFailedException
+import org.vibeerp.platform.security.IssuedToken
+import org.vibeerp.platform.security.JwtIssuer
+import org.vibeerp.platform.security.JwtVerifier
+
+/**
+ * Login and refresh-token logic.
+ *
+ * Why this lives in `pbc-identity` even though the JWT machinery lives in
+ * `platform-security`: the **policy** of "verify a username and password
+ * against our credential store" is identity domain logic. The
+ * **mechanism** of "encode and decode a JWT" is a framework primitive.
+ * Identity calls the framework primitive; the framework does not call
+ * into identity.
+ *
+ * The service is intentionally narrow: only `login` and `refresh`. Logout
+ * is a client-side action (drop the token); revocation requires a token
+ * blocklist that lands later (post-v0.3) once we have somewhere to put it.
+ *
+ * On both methods, a verification failure throws a generic
+ * [AuthenticationFailedException] regardless of *why* (no such user, wrong
+ * password, expired refresh, wrong token type) so an attacker cannot
+ * distinguish "user does not exist" from "wrong password".
+ */
+@Service
+@Transactional(readOnly = true)
+class AuthService(
+ private val users: UserJpaRepository,
+ private val credentials: UserCredentialJpaRepository,
+ private val passwordEncoder: PasswordEncoder,
+ private val jwtIssuer: JwtIssuer,
+ private val jwtVerifier: JwtVerifier,
+) {
+
+ fun login(username: String, password: String): TokenPair {
+ val user = users.findByUsername(username) ?: throw AuthenticationFailedException()
+ if (!user.enabled) throw AuthenticationFailedException()
+
+ val credential = credentials.findByUserId(user.id) ?: throw AuthenticationFailedException()
+ if (!passwordEncoder.matches(password, credential.passwordHash)) {
+ throw AuthenticationFailedException()
+ }
+
+ return mintTokenPair(user)
+ }
+
+ fun refresh(refreshToken: String): TokenPair {
+ val decoded = jwtVerifier.verify(refreshToken)
+ if (decoded.type != JwtIssuer.TYPE_REFRESH) {
+ throw AuthenticationFailedException("expected refresh token, got ${decoded.type}")
+ }
+
+ val user = users.findById(decoded.userId).orElseThrow { AuthenticationFailedException() }
+ if (!user.enabled) throw AuthenticationFailedException()
+
+ return mintTokenPair(user)
+ }
+
+ private fun mintTokenPair(user: User): TokenPair {
+ val access = jwtIssuer.issueAccessToken(user.id, user.username)
+ val refresh = jwtIssuer.issueRefreshToken(user.id, user.username)
+ return TokenPair(access, refresh)
+ }
+}
+
+data class TokenPair(
+ val accessToken: IssuedToken,
+ val refreshToken: IssuedToken,
+)
diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt
new file mode 100644
index 0000000..7aeb162
--- /dev/null
+++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt
@@ -0,0 +1,109 @@
+package org.vibeerp.pbc.identity.bootstrap
+
+import org.slf4j.LoggerFactory
+import org.springframework.boot.CommandLineRunner
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.stereotype.Component
+import org.springframework.transaction.annotation.Transactional
+import org.vibeerp.pbc.identity.domain.User
+import org.vibeerp.pbc.identity.domain.UserCredential
+import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
+import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
+import org.vibeerp.platform.persistence.security.PrincipalContext
+import java.security.SecureRandom
+import java.time.Instant
+
+/**
+ * On the very first boot of an empty `identity__user` table, create a
+ * single `admin` user with a randomly-generated 16-character password
+ * and print it to the application logs.
+ *
+ * The operator copies the password from the logs, logs in once, changes
+ * the password (or hands it to the real human admin), and the bootstrap
+ * is done. Subsequent boots see the admin row already exists and skip
+ * silently.
+ *
+ * **Why a CommandLineRunner instead of a Liquibase changeset:** the
+ * password hash needs to be produced by the live `Argon2PasswordEncoder`
+ * bean (so the parameters match what the rest of the application uses
+ * to verify), and Liquibase has no clean hook for "run a Spring bean to
+ * produce a value." A CommandLineRunner runs after the application
+ * context is fully initialized, with all beans wired, in a transaction
+ * we control.
+ *
+ * **Why log the password instead of failing the boot:** alternatives
+ * (env var, config file, prompt) all create a chicken-and-egg problem
+ * for first-install. Logging the password is the standard pattern (Wiki
+ * named it "Jenkins-style bootstrap") and it works for both interactive
+ * `docker run -d` and automated provisioning, as long as the operator
+ * can read the logs once.
+ */
+@Component
+class BootstrapAdminInitializer(
+ private val users: UserJpaRepository,
+ private val credentials: UserCredentialJpaRepository,
+ private val passwordEncoder: PasswordEncoder,
+) : CommandLineRunner {
+
+ private val log = LoggerFactory.getLogger(BootstrapAdminInitializer::class.java)
+
+ @Transactional
+ override fun run(vararg args: String?) {
+ if (users.count() > 0L) {
+ log.debug("identity__user is non-empty; skipping bootstrap admin creation")
+ return
+ }
+
+ // Audit columns expect *some* principal id; bootstrap writes are
+ // attributed to the system sentinel, which is what runs every
+ // migration / first-install operation.
+ PrincipalContext.runAs(BOOTSTRAP_PRINCIPAL) {
+ val password = generatePassword()
+ val admin = User(
+ username = "admin",
+ displayName = "Bootstrap administrator",
+ email = null,
+ enabled = true,
+ )
+ val savedAdmin = users.save(admin)
+
+ credentials.save(
+ UserCredential(
+ userId = savedAdmin.id,
+ passwordHash = passwordEncoder.encode(password),
+ passwordAlgo = "argon2id",
+ mustChange = true,
+ lastChangedAt = Instant.now(),
+ ),
+ )
+
+ // Banner-style log lines so the operator can spot it in
+ // verbose boot output. The password is intentionally on its
+ // own line for easy copy-paste.
+ log.warn("================================================================")
+ log.warn("vibe_erp bootstrap admin created")
+ log.warn(" username: admin")
+ log.warn(" password: {}", password)
+ log.warn("Change this password on first login. This is the last time")
+ log.warn("the password will be printed.")
+ log.warn("================================================================")
+ }
+ }
+
+ private fun generatePassword(): String {
+ val bytes = ByteArray(PASSWORD_BYTES)
+ random.nextBytes(bytes)
+ return PASSWORD_ALPHABET.let { alphabet ->
+ buildString(PASSWORD_BYTES) {
+ bytes.forEach { append(alphabet[(it.toInt() and 0xff) % alphabet.length]) }
+ }
+ }
+ }
+
+ private companion object {
+ const val BOOTSTRAP_PRINCIPAL = "__bootstrap__"
+ const val PASSWORD_BYTES = 16
+ const val PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
+ val random = SecureRandom()
+ }
+}
diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt
new file mode 100644
index 0000000..0380c97
--- /dev/null
+++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt
@@ -0,0 +1,51 @@
+package org.vibeerp.pbc.identity.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.Table
+import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Password (and future MFA) material for a [User].
+ *
+ * Lives in a SEPARATE table from `identity__user` for three reasons:
+ * 1. Keep secrets out of every accidental query plan that pulls users.
+ * 2. Let other PBCs read user references (`UserRef`) without ever seeing
+ * the password hash, even by mistake.
+ * 3. Independent rotation, revocation, and deletion (e.g. on password
+ * reset) without touching the User row.
+ *
+ * The hash itself is stored as an opaque string. The `passwordAlgo` column
+ * records which encoder produced it so the platform can support hash
+ * migration in the future without losing existing credentials.
+ */
+@Entity
+@Table(name = "identity__user_credential")
+class UserCredential(
+ userId: UUID,
+ passwordHash: String,
+ passwordAlgo: String = "argon2id",
+ mustChange: Boolean = false,
+ lastChangedAt: Instant = Instant.now(),
+) : AuditedJpaEntity() {
+
+ @Column(name = "user_id", nullable = false, updatable = false, columnDefinition = "uuid")
+ var userId: UUID = userId
+
+ @Column(name = "password_hash", nullable = false)
+ var passwordHash: String = passwordHash
+
+ @Column(name = "password_algo", nullable = false, length = 32)
+ var passwordAlgo: String = passwordAlgo
+
+ @Column(name = "must_change", nullable = false)
+ var mustChange: Boolean = mustChange
+
+ @Column(name = "last_changed_at", nullable = false)
+ var lastChangedAt: Instant = lastChangedAt
+
+ override fun toString(): String =
+ "UserCredential(id=$id, userId=$userId, algo='$passwordAlgo')"
+}
diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt
new file mode 100644
index 0000000..de3ab77
--- /dev/null
+++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt
@@ -0,0 +1,73 @@
+package org.vibeerp.pbc.identity.http
+
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonProperty
+import jakarta.validation.Valid
+import jakarta.validation.constraints.NotBlank
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+import org.vibeerp.pbc.identity.application.AuthService
+import org.vibeerp.pbc.identity.application.TokenPair
+import java.time.Instant
+
+/**
+ * Login and refresh endpoints.
+ *
+ * Mounted under `/api/v1/auth/` (not under `/api/v1/identity/auth/`)
+ * because authentication is a framework-level concern: a future OIDC
+ * integration (P4.2), an API-key flow, or an SSO bridge will all share
+ * the same root path. Putting auth under `identity` would suggest only
+ * `pbc-identity` can issue tokens, which is not true.
+ *
+ * Both endpoints are in the public allowlist of `SecurityConfiguration`.
+ * The framework's `GlobalExceptionHandler` maps `AuthenticationFailedException`
+ * (defined in `platform-security`) to HTTP 401, so this controller body
+ * stays focused on the happy path.
+ */
+@RestController
+@RequestMapping("/api/v1/auth")
+class AuthController(
+ private val authService: AuthService,
+) {
+
+ @PostMapping("/login")
+ fun login(@RequestBody @Valid request: LoginRequest): TokenPairResponse =
+ authService.login(request.username, request.password).toResponse()
+
+ @PostMapping("/refresh")
+ fun refresh(@RequestBody @Valid request: RefreshRequest): TokenPairResponse =
+ authService.refresh(request.refreshToken).toResponse()
+}
+
+// ─── DTOs ────────────────────────────────────────────────────────────
+
+data class LoginRequest(
+ @field:NotBlank val username: String,
+ @field:NotBlank val password: String,
+)
+
+// Single-field data classes trip up jackson-module-kotlin: a single
+// String parameter is interpreted as a *delegate-based* creator, so
+// Jackson tries to deserialize the entire JSON value as a String and
+// fails when it sees an object. The explicit @JsonCreator + @JsonProperty
+// forces property-based mode and lets Jackson read the JSON object normally.
+data class RefreshRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
+ @field:NotBlank @param:JsonProperty("refreshToken") val refreshToken: String,
+)
+
+data class TokenPairResponse(
+ val accessToken: String,
+ val accessExpiresAt: Instant,
+ val refreshToken: String,
+ val refreshExpiresAt: Instant,
+ val tokenType: String = "Bearer",
+)
+
+private fun TokenPair.toResponse() = TokenPairResponse(
+ accessToken = accessToken.value,
+ accessExpiresAt = accessToken.expiresAt,
+ refreshToken = refreshToken.value,
+ refreshExpiresAt = refreshToken.expiresAt,
+)
diff --git a/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt
new file mode 100644
index 0000000..b6ae97a
--- /dev/null
+++ b/pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt
@@ -0,0 +1,22 @@
+package org.vibeerp.pbc.identity.infrastructure
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+import org.vibeerp.pbc.identity.domain.UserCredential
+import java.util.UUID
+
+/**
+ * Spring Data repository for [UserCredential].
+ *
+ * One credential row per user (enforced by the unique constraint on
+ * `user_id` in `identity__user_credential`). The lookup is by `userId`
+ * because that is the primary access pattern: "I know who you claim to
+ * be — what is your password hash?".
+ */
+@Repository
+interface UserCredentialJpaRepository : JpaRepository {
+
+ fun findByUserId(userId: UUID): UserCredential?
+
+ fun existsByUserId(userId: UUID): Boolean
+}
diff --git a/pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt b/pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt
new file mode 100644
index 0000000..0f00901
--- /dev/null
+++ b/pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt
@@ -0,0 +1,165 @@
+package org.vibeerp.pbc.identity.application
+
+import assertk.assertFailure
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.vibeerp.pbc.identity.domain.User
+import org.vibeerp.pbc.identity.domain.UserCredential
+import org.vibeerp.pbc.identity.infrastructure.UserCredentialJpaRepository
+import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
+import org.vibeerp.platform.security.AuthenticationFailedException
+import org.vibeerp.platform.security.DecodedToken
+import org.vibeerp.platform.security.IssuedToken
+import org.vibeerp.platform.security.JwtIssuer
+import org.vibeerp.platform.security.JwtVerifier
+import java.time.Instant
+import java.util.Optional
+import java.util.UUID
+
+class AuthServiceTest {
+
+ private lateinit var users: UserJpaRepository
+ private lateinit var credentials: UserCredentialJpaRepository
+ private lateinit var passwordEncoder: PasswordEncoder
+ private lateinit var jwtIssuer: JwtIssuer
+ private lateinit var jwtVerifier: JwtVerifier
+ private lateinit var service: AuthService
+
+ @BeforeEach
+ fun setUp() {
+ users = mockk()
+ credentials = mockk()
+ passwordEncoder = mockk()
+ jwtIssuer = mockk()
+ jwtVerifier = mockk()
+ service = AuthService(users, credentials, passwordEncoder, jwtIssuer, jwtVerifier)
+ }
+
+ private fun aliceUser(id: UUID = UUID.randomUUID(), enabled: Boolean = true): User {
+ return User(username = "alice", displayName = "Alice", email = null, enabled = enabled).also {
+ it.id = id
+ }
+ }
+
+ private fun stubMint(userId: UUID, username: String) {
+ val now = Instant.now()
+ every { jwtIssuer.issueAccessToken(userId, username) } returns
+ IssuedToken("access-$userId", now.plusSeconds(900))
+ every { jwtIssuer.issueRefreshToken(userId, username) } returns
+ IssuedToken("refresh-$userId", now.plusSeconds(86400 * 7))
+ }
+
+ @Test
+ fun `login returns a token pair on the happy path`() {
+ val alice = aliceUser()
+ val cred = UserCredential(userId = alice.id, passwordHash = "hash")
+ every { users.findByUsername("alice") } returns alice
+ every { credentials.findByUserId(alice.id) } returns cred
+ every { passwordEncoder.matches("correct", "hash") } returns true
+ stubMint(alice.id, "alice")
+
+ val pair = service.login("alice", "correct")
+
+ assertThat(pair.accessToken.value).isEqualTo("access-${alice.id}")
+ assertThat(pair.refreshToken.value).isEqualTo("refresh-${alice.id}")
+ }
+
+ @Test
+ fun `login fails when user does not exist`() {
+ every { users.findByUsername("ghost") } returns null
+
+ assertFailure { service.login("ghost", "anything") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `login fails when user is disabled`() {
+ val disabled = aliceUser(enabled = false)
+ every { users.findByUsername("alice") } returns disabled
+
+ assertFailure { service.login("alice", "correct") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `login fails when no credential row exists`() {
+ val alice = aliceUser()
+ every { users.findByUsername("alice") } returns alice
+ every { credentials.findByUserId(alice.id) } returns null
+
+ assertFailure { service.login("alice", "correct") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `login fails on wrong password`() {
+ val alice = aliceUser()
+ val cred = UserCredential(userId = alice.id, passwordHash = "hash")
+ every { users.findByUsername("alice") } returns alice
+ every { credentials.findByUserId(alice.id) } returns cred
+ every { passwordEncoder.matches("wrong", "hash") } returns false
+
+ assertFailure { service.login("alice", "wrong") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `refresh fails when verifier rejects the token`() {
+ every { jwtVerifier.verify("garbage") } throws AuthenticationFailedException()
+
+ assertFailure { service.refresh("garbage") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `refresh rejects access tokens presented as refresh`() {
+ every { jwtVerifier.verify("access-token") } returns DecodedToken(
+ userId = UUID.randomUUID(),
+ username = "alice",
+ type = JwtIssuer.TYPE_ACCESS,
+ expiresAt = Instant.now().plusSeconds(900),
+ )
+
+ assertFailure { service.refresh("access-token") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `refresh fails when user no longer exists`() {
+ val ghostId = UUID.randomUUID()
+ every { jwtVerifier.verify("rt") } returns DecodedToken(
+ userId = ghostId,
+ username = "ghost",
+ type = JwtIssuer.TYPE_REFRESH,
+ expiresAt = Instant.now().plusSeconds(86400),
+ )
+ every { users.findById(ghostId) } returns Optional.empty()
+
+ assertFailure { service.refresh("rt") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `refresh mints a fresh pair on the happy path`() {
+ val alice = aliceUser()
+ every { jwtVerifier.verify("rt") } returns DecodedToken(
+ userId = alice.id,
+ username = "alice",
+ type = JwtIssuer.TYPE_REFRESH,
+ expiresAt = Instant.now().plusSeconds(86400),
+ )
+ every { users.findById(alice.id) } returns Optional.of(alice)
+ stubMint(alice.id, "alice")
+
+ val pair = service.refresh("rt")
+
+ assertThat(pair.accessToken.value).isEqualTo("access-${alice.id}")
+ assertThat(pair.refreshToken.value).isEqualTo("refresh-${alice.id}")
+ }
+}
diff --git a/platform/platform-bootstrap/build.gradle.kts b/platform/platform-bootstrap/build.gradle.kts
index 4369ba2..3d00c0c 100644
--- a/platform/platform-bootstrap/build.gradle.kts
+++ b/platform/platform-bootstrap/build.gradle.kts
@@ -21,6 +21,8 @@ kotlin {
dependencies {
api(project(":api:api-v1"))
+ api(project(":platform:platform-persistence")) // for the audit listener wiring
+ api(project(":platform:platform-security")) // for AuthenticationFailedException, JwtIssuer wiring
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.reflect)
implementation(libs.jackson.module.kotlin)
diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
index 07b2e44..8f02b63 100644
--- a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
+++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
@@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.vibeerp.platform.security.AuthenticationFailedException
import java.time.Instant
/**
@@ -52,6 +53,20 @@ class GlobalExceptionHandler {
problem(HttpStatus.NOT_FOUND, ex.message ?: "not found")
/**
+ * All authentication failures collapse to a single 401 with a generic
+ * "invalid credentials" body. The framework deliberately does not
+ * distinguish "no such user" from "wrong password" / "expired token" /
+ * "wrong token type" — that distinction would help an attacker
+ * enumerate valid usernames. The cause is logged at DEBUG so operators
+ * can still triage from the server logs.
+ */
+ @ExceptionHandler(AuthenticationFailedException::class)
+ fun handleAuthFailed(ex: AuthenticationFailedException): ProblemDetail {
+ log.debug("Authentication failed: {}", ex.message)
+ return problem(HttpStatus.UNAUTHORIZED, "invalid credentials")
+ }
+
+ /**
* Last-resort fallback. Anything not handled above is logged with a
* full stack trace and surfaced as a generic 500 to the caller. The
* detail message is intentionally vague so we don't leak internals.
diff --git a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
index 65661e3..2faa24c 100644
--- a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
+++ b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
@@ -7,6 +7,7 @@ import jakarta.persistence.MappedSuperclass
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Version
+import org.vibeerp.platform.persistence.security.PrincipalContext
import java.time.Instant
import java.util.UUID
@@ -89,8 +90,5 @@ class AuditedJpaEntityListener {
}
}
- private fun currentPrincipal(): String {
- // TODO(v0.2): pull from PrincipalContext once pbc-identity wires up auth.
- return "__system__"
- }
+ private fun currentPrincipal(): String = PrincipalContext.currentOrSystem()
}
diff --git a/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt
new file mode 100644
index 0000000..e301633
--- /dev/null
+++ b/platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt
@@ -0,0 +1,69 @@
+package org.vibeerp.platform.persistence.security
+
+/**
+ * Per-thread holder of the current request's principal id.
+ *
+ * **Why a string id and not the full `Principal` object:** the audit
+ * listener (and any other consumer in `platform-persistence`) only needs
+ * a stable identifier to write into `created_by` / `updated_by`. Holding
+ * the whole api.v1 `Principal` here would force `platform-persistence`
+ * to depend on `api/api-v1`'s security types and would couple persistence
+ * to the principal type hierarchy. Stringly-typed at the boundary keeps
+ * the layering clean: the JWT filter (in `platform-bootstrap`) is the
+ * one place that knows about both `Principal` and `PrincipalContext`,
+ * and it bridges them at request entry.
+ *
+ * **Why a `ThreadLocal`:** the audit listener is invoked from inside
+ * Hibernate's flush, which may happen on a thread that is not currently
+ * in a Spring request scope (e.g. background event handlers, scheduled
+ * jobs). A `ThreadLocal` is the lowest common denominator that works
+ * across every code path that might trigger a JPA save.
+ *
+ * **For background jobs:** the job scheduler explicitly calls [runAs]
+ * with a system principal id for the duration of the job, then clears it.
+ * Never let a job thread inherit a stale principal from a previous run.
+ *
+ * **What happens when nothing is set:** [currentOrSystem] returns the
+ * sentinel `__system__`, matching the v0.1 placeholder. Once the JWT
+ * filter is in place, every HTTP request sets the real id and the
+ * sentinel only appears for genuinely-system writes (boot, migrations).
+ */
+object PrincipalContext {
+
+ private const val SYSTEM_SENTINEL = "__system__"
+
+ private val current = ThreadLocal()
+
+ fun set(principalId: String) {
+ require(principalId.isNotBlank()) { "principalId must not be blank" }
+ current.set(principalId)
+ }
+
+ fun clear() {
+ current.remove()
+ }
+
+ fun currentOrNull(): String? = current.get()
+
+ /**
+ * Returns the current principal id, or the system sentinel if no
+ * principal is bound. Safe to call from JPA listeners during boot
+ * or migration when no request is in flight.
+ */
+ fun currentOrSystem(): String = current.get() ?: SYSTEM_SENTINEL
+
+ /**
+ * Run [block] with [principalId] bound to the current thread, restoring
+ * the previous value (which is usually null) afterwards. Used by
+ * background jobs and tests.
+ */
+ fun runAs(principalId: String, block: () -> T): T {
+ val previous = current.get()
+ set(principalId)
+ try {
+ return block()
+ } finally {
+ if (previous == null) current.remove() else current.set(previous)
+ }
+ }
+}
diff --git a/platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt b/platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt
new file mode 100644
index 0000000..d036098
--- /dev/null
+++ b/platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt
@@ -0,0 +1,57 @@
+package org.vibeerp.platform.persistence.security
+
+import assertk.assertFailure
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import assertk.assertions.isNull
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+class PrincipalContextTest {
+
+ @AfterEach
+ fun cleanup() {
+ // Always clear so a leaked binding doesn't poison sibling tests.
+ PrincipalContext.clear()
+ }
+
+ @Test
+ fun `currentOrNull is null by default`() {
+ assertThat(PrincipalContext.currentOrNull()).isNull()
+ }
+
+ @Test
+ fun `currentOrSystem returns sentinel when nothing is bound`() {
+ assertThat(PrincipalContext.currentOrSystem()).isEqualTo("__system__")
+ }
+
+ @Test
+ fun `set and currentOrNull round-trip`() {
+ PrincipalContext.set("user-42")
+ assertThat(PrincipalContext.currentOrNull()).isEqualTo("user-42")
+ assertThat(PrincipalContext.currentOrSystem()).isEqualTo("user-42")
+ }
+
+ @Test
+ fun `runAs binds for the duration of the block then restores previous`() {
+ PrincipalContext.set("outer")
+ val inside = PrincipalContext.runAs("inner") { PrincipalContext.currentOrNull() }
+ assertThat(inside).isEqualTo("inner")
+ assertThat(PrincipalContext.currentOrNull()).isEqualTo("outer")
+ }
+
+ @Test
+ fun `runAs from empty context restores to empty after the block`() {
+ PrincipalContext.runAs("temp") { /* no-op */ }
+ assertThat(PrincipalContext.currentOrNull()).isNull()
+ }
+
+ @Test
+ fun `set rejects blank principal id`() {
+ assertFailure { PrincipalContext.set("") }
+ .isInstanceOf(IllegalArgumentException::class)
+ assertFailure { PrincipalContext.set(" ") }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+}
diff --git a/platform/platform-security/build.gradle.kts b/platform/platform-security/build.gradle.kts
new file mode 100644
index 0000000..f293878
--- /dev/null
+++ b/platform/platform-security/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kotlin.spring)
+ alias(libs.plugins.spring.dependency.management)
+}
+
+description = "vibe_erp platform security — JWT issuer/decoder, password encoder, Spring Security config. INTERNAL."
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+}
+
+kotlin {
+ jvmToolchain(21)
+ compilerOptions {
+ freeCompilerArgs.add("-Xjsr305=strict")
+ }
+}
+
+dependencies {
+ api(project(":api:api-v1"))
+ api(project(":platform:platform-persistence")) // for PrincipalContext
+
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlin.reflect)
+
+ implementation(libs.spring.boot.starter)
+ implementation(libs.spring.boot.starter.web)
+ implementation(libs.spring.boot.starter.security)
+ implementation(libs.spring.boot.starter.oauth2.resource.server)
+ implementation(libs.spring.security.oauth2.jose)
+ runtimeOnly(libs.bouncycastle) // required by Argon2PasswordEncoder
+
+ testImplementation(libs.spring.boot.starter.test)
+ testImplementation(libs.junit.jupiter)
+ testImplementation(libs.assertk)
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt
new file mode 100644
index 0000000..0a3dae7
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt
@@ -0,0 +1,23 @@
+package org.vibeerp.platform.security
+
+/**
+ * Single exception type for **all** authentication failures across the framework.
+ *
+ * Why one type instead of distinct ones for "no such user", "wrong password",
+ * "expired token", etc.: distinguishing the cases helps an attacker enumerate
+ * valid usernames and probe the auth flow. The framework is sold worldwide
+ * (CLAUDE.md guardrail #6) and the OWASP advice is unambiguous — fail
+ * uniformly.
+ *
+ * The platform's [org.vibeerp.platform.bootstrap.web.GlobalExceptionHandler]
+ * maps this to HTTP 401 with a generic body. The original cause is logged
+ * server-side at DEBUG so operators can still triage.
+ *
+ * Lives in `platform-security` (not `pbc-identity`) because it is a
+ * framework-level concept: every auth mechanism the framework grows in the
+ * future (built-in JWT, OIDC, API keys, …) raises the same exception type.
+ */
+class AuthenticationFailedException(
+ message: String = "invalid credentials",
+ cause: Throwable? = null,
+) : RuntimeException(message, cause)
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt
new file mode 100644
index 0000000..ed6df2e
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt
@@ -0,0 +1,61 @@
+package org.vibeerp.platform.security
+
+import com.nimbusds.jose.jwk.source.ImmutableSecret
+import com.nimbusds.jose.proc.SecurityContext
+import com.nimbusds.jose.jwk.source.JWKSource
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm
+import org.springframework.security.oauth2.jwt.JwtDecoder
+import org.springframework.security.oauth2.jwt.JwtEncoder
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
+import javax.crypto.spec.SecretKeySpec
+
+/**
+ * Wires Spring Security's [JwtEncoder] and [JwtDecoder] beans against an
+ * HMAC-SHA256 secret read from [JwtProperties].
+ *
+ * Why HS256 and not RS256:
+ * - vibe_erp is single-tenant per instance and the same process both
+ * issues and verifies the token, so there is no need for asymmetric
+ * keys to support a separate verifier.
+ * - HS256 keeps the deployment story to "set one env var" instead of
+ * "manage and distribute a keypair", which matches the self-hosted-
+ * first goal in CLAUDE.md guardrail #5.
+ * - RS256 is straightforward to add later if a federated trust story
+ * ever appears (it would land in OIDC, P4.2).
+ */
+@Configuration
+class JwtConfiguration(
+ private val properties: JwtProperties,
+) {
+
+ init {
+ require(properties.secret.length >= MIN_SECRET_BYTES) {
+ "vibeerp.security.jwt.secret is too short (${properties.secret.length} bytes); " +
+ "HS256 requires at least $MIN_SECRET_BYTES bytes. " +
+ "Set the VIBEERP_JWT_SECRET environment variable in production."
+ }
+ }
+
+ private val secretKey: SecretKeySpec by lazy {
+ SecretKeySpec(properties.secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
+ }
+
+ @Bean
+ fun jwtEncoder(): JwtEncoder {
+ val jwkSource: JWKSource = ImmutableSecret(secretKey)
+ return NimbusJwtEncoder(jwkSource)
+ }
+
+ @Bean
+ fun jwtDecoder(): JwtDecoder =
+ NimbusJwtDecoder.withSecretKey(secretKey)
+ .macAlgorithm(MacAlgorithm.HS256)
+ .build()
+
+ private companion object {
+ const val MIN_SECRET_BYTES = 32
+ }
+}
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt
new file mode 100644
index 0000000..fe74b4e
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt
@@ -0,0 +1,69 @@
+package org.vibeerp.platform.security
+
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm
+import org.springframework.security.oauth2.jwt.JwsHeader
+import org.springframework.security.oauth2.jwt.JwtClaimsSet
+import org.springframework.security.oauth2.jwt.JwtEncoder
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters
+import org.springframework.stereotype.Component
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.UUID
+
+/**
+ * Mints access and refresh tokens for authenticated users.
+ *
+ * Both token kinds carry the same payload shape (`sub`, `username`,
+ * `iss`, `iat`, `exp`, `type`) and differ only in the `type` claim
+ * (`access` vs `refresh`) and in lifetime. The `type` claim is what
+ * stops a refresh token from being accepted as an access token by the
+ * resource server, and vice versa.
+ */
+@Component
+class JwtIssuer(
+ private val encoder: JwtEncoder,
+ private val properties: JwtProperties,
+) {
+
+ fun issueAccessToken(userId: UUID, username: String): IssuedToken =
+ issue(userId, username, type = TYPE_ACCESS, ttlSeconds = properties.accessTokenTtl.seconds)
+
+ fun issueRefreshToken(userId: UUID, username: String): IssuedToken =
+ issue(userId, username, type = TYPE_REFRESH, ttlSeconds = properties.refreshTokenTtl.seconds)
+
+ private fun issue(
+ userId: UUID,
+ username: String,
+ type: String,
+ ttlSeconds: Long,
+ ): IssuedToken {
+ // Truncate to whole seconds because the JWT `iat` and `exp` claims
+ // are integer-second values (RFC 7519). Without this truncation
+ // the [IssuedToken] returned to the caller carries nanosecond
+ // precision that the decoder will then strip — round-trip equality
+ // tests fail and refresh-token expiry checks become inconsistent.
+ val now = Instant.now().truncatedTo(ChronoUnit.SECONDS)
+ val expiresAt = now.plusSeconds(ttlSeconds)
+ val claims = JwtClaimsSet.builder()
+ .issuer(properties.issuer)
+ .subject(userId.toString())
+ .issuedAt(now)
+ .expiresAt(expiresAt)
+ .claim("username", username)
+ .claim("type", type)
+ .build()
+ val headers = JwsHeader.with(MacAlgorithm.HS256).build()
+ val token = encoder.encode(JwtEncoderParameters.from(headers, claims)).tokenValue
+ return IssuedToken(value = token, expiresAt = expiresAt)
+ }
+
+ companion object {
+ const val TYPE_ACCESS = "access"
+ const val TYPE_REFRESH = "refresh"
+ }
+}
+
+data class IssuedToken(
+ val value: String,
+ val expiresAt: Instant,
+)
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt
new file mode 100644
index 0000000..8ce659f
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt
@@ -0,0 +1,39 @@
+package org.vibeerp.platform.security
+
+import org.springframework.boot.context.properties.ConfigurationProperties
+import java.time.Duration
+
+/**
+ * JWT configuration bound from `vibeerp.security.jwt.*`.
+ *
+ * Why this lives in `platform-bootstrap` rather than in `pbc-identity`:
+ * the JWT machinery (issuer, decoder, filter chain) is part of the
+ * **framework's** security wall, not part of the identity domain. Other
+ * PBCs and other auth mechanisms (OIDC, future API keys) all hang off
+ * the same `Principal`, the same filter chain, and the same set of
+ * properties. Identity owns the *user store*; bootstrap owns the
+ * *machinery* that turns a user into a request principal.
+ *
+ * The signing secret is **mandatory in production** and is read from an
+ * environment variable. A development default is provided in
+ * `application-dev.yaml` so `gradle bootRun` works out of the box.
+ */
+@ConfigurationProperties(prefix = "vibeerp.security.jwt")
+data class JwtProperties(
+ /**
+ * HMAC-SHA256 signing secret. Must be at least 32 bytes (256 bits)
+ * for HS256. The framework refuses to start if a shorter value is
+ * configured.
+ */
+ val secret: String = "",
+ /**
+ * Issuer claim (`iss`) embedded in every token. Identifies which
+ * vibe_erp instance minted the token; used by the verifier to reject
+ * tokens from unrelated installations.
+ */
+ val issuer: String = "vibe-erp",
+ /** Lifetime of access tokens. Short by default; refresh tokens carry the long-lived state. */
+ val accessTokenTtl: Duration = Duration.ofMinutes(15),
+ /** Lifetime of refresh tokens. */
+ val refreshTokenTtl: Duration = Duration.ofDays(7),
+)
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt
new file mode 100644
index 0000000..8753a9d
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt
@@ -0,0 +1,79 @@
+package org.vibeerp.platform.security
+
+import org.springframework.security.oauth2.jwt.JwtDecoder
+import org.springframework.security.oauth2.jwt.JwtException
+import org.springframework.stereotype.Component
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Validate-and-decode wrapper for inbound JWTs.
+ *
+ * Why this exists separately from Spring Security's [JwtDecoder]:
+ * - PBCs (and plug-ins, eventually) need to verify tokens without
+ * pulling in `spring-security-oauth2-jose` themselves. Adding that
+ * dependency to every PBC's compile classpath would mean every test
+ * suite has to construct a fake `JwtDecoder` and every plug-in author
+ * has to learn the OAuth2 type hierarchy.
+ * - Returning a small typed [DecodedToken] instead of Spring's `Jwt`
+ * keeps the surface in our control: when we add a `roles` claim
+ * later, we add a field here, not learn how to navigate Spring's
+ * `getClaimAsStringList`.
+ * - Translating `JwtException` and parse failures into a single
+ * [AuthenticationFailedException] aligns with the OWASP "fail
+ * uniformly" principle (see [AuthenticationFailedException] KDoc).
+ */
+@Component
+class JwtVerifier(
+ private val decoder: JwtDecoder,
+) {
+
+ /**
+ * Decode and validate [token]. Throws [AuthenticationFailedException]
+ * on any failure mode (bad signature, expired, malformed subject,
+ * unknown type) — the caller cannot distinguish, by design.
+ */
+ fun verify(token: String): DecodedToken {
+ val decoded = try {
+ decoder.decode(token)
+ } catch (ex: JwtException) {
+ throw AuthenticationFailedException("token decode failed", ex)
+ }
+
+ val type = decoded.getClaimAsString("type")
+ ?: throw AuthenticationFailedException("token has no type claim")
+
+ val subject = decoded.subject
+ ?: throw AuthenticationFailedException("token has no subject")
+
+ val userId = try {
+ UUID.fromString(subject)
+ } catch (ex: IllegalArgumentException) {
+ throw AuthenticationFailedException("token subject is not a UUID", ex)
+ }
+
+ val username = decoded.getClaimAsString("username")
+ ?: throw AuthenticationFailedException("token has no username claim")
+
+ val expiresAt = decoded.expiresAt
+ ?: throw AuthenticationFailedException("token has no exp claim")
+
+ return DecodedToken(
+ userId = userId,
+ username = username,
+ type = type,
+ expiresAt = expiresAt,
+ )
+ }
+}
+
+/**
+ * Plain data result of [JwtVerifier.verify]. Decoupled from Spring's
+ * `Jwt` so consumers do not need OAuth2 types on their classpath.
+ */
+data class DecodedToken(
+ val userId: UUID,
+ val username: String,
+ val type: String,
+ val expiresAt: Instant,
+)
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt
new file mode 100644
index 0000000..ea33273
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt
@@ -0,0 +1,57 @@
+package org.vibeerp.platform.security
+
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.security.core.context.SecurityContextHolder
+import org.springframework.security.oauth2.jwt.Jwt
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
+import org.springframework.stereotype.Component
+import org.springframework.web.filter.OncePerRequestFilter
+import org.vibeerp.platform.persistence.security.PrincipalContext
+
+/**
+ * Bridges Spring Security's authenticated principal into vibe_erp's
+ * [PrincipalContext] for the duration of the request.
+ *
+ * Spring Security puts the authenticated `Authentication` object on a
+ * thread-local of its own. Our JPA audit listener — which lives in
+ * `platform-persistence` — must NOT depend on Spring Security types
+ * (it would force every PBC's compile classpath to include Spring
+ * Security). The bridge solves this by reading the JWT subject from
+ * Spring Security's context and writing it as a plain string into
+ * [PrincipalContext], which is in the persistence module and so
+ * legitimately importable by the audit listener.
+ *
+ * The filter is registered immediately after Spring Security's
+ * authentication filter so the `SecurityContext` is fully populated by
+ * the time we read it.
+ *
+ * On unauthenticated requests (the public allowlist) the
+ * `SecurityContext` is empty and we leave the principal unset, which
+ * means the audit listener falls back to its `__system__` sentinel —
+ * the right behavior for boot-time and migration writes.
+ */
+@Component
+class PrincipalContextFilter : OncePerRequestFilter() {
+
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain,
+ ) {
+ val auth = SecurityContextHolder.getContext().authentication
+ val subject: String? = when (auth) {
+ is JwtAuthenticationToken -> (auth.principal as? Jwt)?.subject
+ else -> null
+ }
+
+ if (subject != null) {
+ PrincipalContext.runAs(subject) {
+ filterChain.doFilter(request, response)
+ }
+ } else {
+ filterChain.doFilter(request, response)
+ }
+ }
+}
diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
new file mode 100644
index 0000000..1f55a52
--- /dev/null
+++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
@@ -0,0 +1,84 @@
+package org.vibeerp.platform.security
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.http.SessionCreationPolicy
+import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
+import org.springframework.security.web.SecurityFilterChain
+
+/**
+ * Spring Security configuration for the vibe_erp HTTP surface.
+ *
+ * The setup:
+ * • CSRF disabled (this is a stateless REST API; cookies are not used).
+ * • Sessions are stateless — every request must carry its own JWT.
+ * • A small public-paths allowlist for actuator probes, the meta info
+ * endpoint, and the auth endpoints themselves (chicken-and-egg).
+ * • Everything else requires a valid Bearer JWT validated by Spring
+ * Security's resource server.
+ * • A custom filter ([PrincipalContextFilter]) runs immediately after
+ * authentication to bridge the resolved `Principal` into the
+ * [org.vibeerp.platform.persistence.security.PrincipalContext] so the
+ * audit listener picks it up.
+ */
+@Configuration
+class SecurityConfiguration {
+
+ @Bean
+ fun securityFilterChain(
+ http: HttpSecurity,
+ principalContextFilter: PrincipalContextFilter,
+ ): SecurityFilterChain {
+ http
+ .csrf { it.disable() }
+ .cors { } // default; tighten in v0.4 once the React SPA exists
+ .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
+ .authorizeHttpRequests { auth ->
+ auth.requestMatchers(
+ // Public actuator probes
+ "/actuator/health",
+ "/actuator/info",
+ // Meta info — no secrets, used by load balancers and CI
+ "/api/v1/_meta/**",
+ // Auth endpoints themselves cannot require auth
+ "/api/v1/auth/login",
+ "/api/v1/auth/refresh",
+ ).permitAll()
+ auth.anyRequest().authenticated()
+ }
+ .oauth2ResourceServer { rs ->
+ rs.jwt { } // uses the JwtDecoder bean from JwtConfiguration
+ }
+ // Bridge Spring Security's authenticated principal into
+ // platform-persistence's PrincipalContext so JPA listeners
+ // see the right user. Must run AFTER `BearerTokenAuthenticationFilter`
+ // (the OAuth2 resource server filter that resolves a Bearer JWT
+ // into a SecurityContext) — otherwise SecurityContextHolder is
+ // empty when we read it and every request looks anonymous to
+ // the audit listener.
+ .addFilterAfter(principalContextFilter, BearerTokenAuthenticationFilter::class.java)
+
+ return http.build()
+ }
+
+ /**
+ * Argon2id password encoder used by [org.vibeerp.pbc.identity.application.AuthService]
+ * to hash and verify credentials. Defined here (not in pbc-identity)
+ * because the encoder is a framework primitive — every PBC that ever
+ * stores a secret will reuse the same instance.
+ *
+ * Parameters are Spring Security 6's "modern" defaults
+ * (`Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8`):
+ * - 16 byte salt
+ * - 32 byte hash
+ * - parallelism 1
+ * - 4096 iterations
+ * - 19 MiB memory cost
+ */
+ @Bean
+ fun passwordEncoder(): PasswordEncoder =
+ Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
+}
diff --git a/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
new file mode 100644
index 0000000..6c7b9d7
--- /dev/null
+++ b/platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt
@@ -0,0 +1,81 @@
+package org.vibeerp.platform.security
+
+import assertk.assertFailure
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.util.UUID
+
+/**
+ * End-to-end JWT issuer/verifier round-trip test using the *real*
+ * Spring Security `NimbusJwtEncoder` / `NimbusJwtDecoder` against an
+ * HMAC-SHA256 secret.
+ *
+ * No Spring context is started — we instantiate `JwtConfiguration`
+ * manually and pull the encoder and decoder out of it. That keeps the
+ * test under 100ms and proves the wiring without dragging Spring Boot
+ * into a unit test.
+ */
+class JwtRoundTripTest {
+
+ private val properties = JwtProperties(
+ secret = "test-secret-with-at-least-32-chars-of-entropy",
+ issuer = "vibe-erp-test",
+ )
+ private lateinit var issuer: JwtIssuer
+ private lateinit var verifier: JwtVerifier
+
+ @BeforeEach
+ fun setUp() {
+ val cfg = JwtConfiguration(properties)
+ issuer = JwtIssuer(cfg.jwtEncoder(), properties)
+ verifier = JwtVerifier(cfg.jwtDecoder())
+ }
+
+ @Test
+ fun `issued access token decodes back to the same user`() {
+ val userId = UUID.randomUUID()
+ val issued = issuer.issueAccessToken(userId, "alice")
+
+ val decoded = verifier.verify(issued.value)
+
+ assertThat(decoded.userId).isEqualTo(userId)
+ assertThat(decoded.username).isEqualTo("alice")
+ assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_ACCESS)
+ assertThat(decoded.expiresAt).isEqualTo(issued.expiresAt)
+ }
+
+ @Test
+ fun `issued refresh token has refresh type`() {
+ val userId = UUID.randomUUID()
+ val issued = issuer.issueRefreshToken(userId, "bob")
+
+ val decoded = verifier.verify(issued.value)
+
+ assertThat(decoded.type).isEqualTo(JwtIssuer.TYPE_REFRESH)
+ }
+
+ @Test
+ fun `tampered token is rejected`() {
+ val issued = issuer.issueAccessToken(UUID.randomUUID(), "carol")
+ // Flip the last character of the signature segment.
+ val tampered = issued.value.dropLast(1) + if (issued.value.last() == 'a') 'b' else 'a'
+
+ assertFailure { verifier.verify(tampered) }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `garbage string is rejected`() {
+ assertFailure { verifier.verify("not-a-jwt") }
+ .isInstanceOf(AuthenticationFailedException::class)
+ }
+
+ @Test
+ fun `JwtConfiguration refuses too-short secret`() {
+ assertFailure { JwtConfiguration(JwtProperties(secret = "too-short")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0b506f4..1ef8bde 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -30,6 +30,9 @@ project(":platform:platform-persistence").projectDir = file("platform/platform-p
include(":platform:platform-plugins")
project(":platform:platform-plugins").projectDir = file("platform/platform-plugins")
+include(":platform:platform-security")
+project(":platform:platform-security").projectDir = file("platform/platform-security")
+
// ─── Packaged Business Capabilities (core PBCs) ─────────────────────
include(":pbc:pbc-identity")
project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")