From 540d916f6d62856f1712f362655e326b97e8c25a Mon Sep 17 00:00:00 2001 From: vibe_erp Date: Tue, 7 Apr 2026 18:02:04 +0800 Subject: [PATCH] feat(auth): P4.1 — built-in JWT auth, Argon2id passwords, bootstrap admin --- distribution/build.gradle.kts | 1 + distribution/src/main/resources/application-dev.yaml | 6 ++++++ distribution/src/main/resources/application.yaml | 9 +++++++++ distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------ gradle/libs.versions.toml | 5 +++++ pbc/pbc-identity/build.gradle.kts | 2 ++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt | 22 ++++++++++++++++++++++ pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-bootstrap/build.gradle.kts | 2 ++ platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt | 15 +++++++++++++++ platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt | 6 ++---- platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/build.gradle.kts | 43 +++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt | 23 +++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt | 39 +++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 3 +++ 29 files changed, 1336 insertions(+), 76 deletions(-) create mode 100644 distribution/src/main/resources/db/changelog/pbc-identity/002-identity-credential.xml create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/AuthService.kt create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/bootstrap/BootstrapAdminInitializer.kt create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/UserCredential.kt create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/AuthController.kt create mode 100644 pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserCredentialJpaRepository.kt create mode 100644 pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/AuthServiceTest.kt create mode 100644 platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/security/PrincipalContext.kt create mode 100644 platform/platform-persistence/src/test/kotlin/org/vibeerp/platform/persistence/security/PrincipalContextTest.kt create mode 100644 platform/platform-security/build.gradle.kts create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/AuthenticationFailedException.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtConfiguration.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtIssuer.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtProperties.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/JwtVerifier.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/PrincipalContextFilter.kt create mode 100644 platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt create mode 100644 platform/platform-security/src/test/kotlin/org/vibeerp/platform/security/JwtRoundTripTest.kt 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") -- libgit2 0.22.2