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