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.