diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54584d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.idea +.agents +.codex +.aws +.ssh +.env +.env.* +.local +**/target +**/__pycache__ diff --git a/.env.example b/.env.example index 73eae4e..1a371d4 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,7 @@ +# Secrets are generated into ignored protected files by scripts/local/prepare.py. +# No runtime database passwords or HMAC keys belong here. COMPOSE_PROJECT_NAME=payguard - -ORACLE_IMAGE=gvenzl/oracle-free:23-slim-faststart -WALLET_ORACLE_PORT=1521 -LOAN_ORACLE_PORT=1522 -COLLATERAL_ORACLE_PORT=1523 -WALLET_ORACLE_SYSTEM_PASSWORD=change-wallet-system-password -LOAN_ORACLE_SYSTEM_PASSWORD=change-loan-system-password -COLLATERAL_ORACLE_SYSTEM_PASSWORD=change-collateral-system-password -WALLET_ORACLE_APP_USER=payguard_wallet -LOAN_ORACLE_APP_USER=payguard_loan -COLLATERAL_ORACLE_APP_USER=payguard_collateral -WALLET_ORACLE_APP_PASSWORD=change-wallet-app-password -LOAN_ORACLE_APP_PASSWORD=change-loan-app-password -COLLATERAL_ORACLE_APP_PASSWORD=change-collateral-app-password -WALLET_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1521/FREE -LOAN_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1522/FREE -COLLATERAL_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1523/FREE -OTP_ORACLE_PORT=1524 -OTP_ORACLE_SYSTEM_PASSWORD=change-otp-system-password -OTP_ORACLE_APP_USER=payguard_otp -OTP_ORACLE_APP_PASSWORD=change-otp-app-password -OTP_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1524/FREE -OTP_SECURITY_PEPPER=replace-with-a-random-secret-at-least-32-characters -OTP_SERVICE_PORT=8084 -REDIS_HOST=localhost -REDIS_PORT=6379 - -WALLET_SERVICE_PORT=8081 -LOAN_SERVICE_PORT=8082 -COLLATERAL_SERVICE_PORT=8083 - -KAFKA_IMAGE=apache/kafka:latest +VAULT_PORT=8200 KAFKA_PORT=9092 -KAFKA_CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk -KAFKA_BOOTSTRAP_SERVERS=localhost:9092 +PAYGUARD_LOCAL_UID=1000 +PAYGUARD_LOCAL_GID=1000 diff --git a/.github/workflows/_database-security.yml b/.github/workflows/_database-security.yml new file mode 100644 index 0000000..69b76b8 --- /dev/null +++ b/.github/workflows/_database-security.yml @@ -0,0 +1,55 @@ +name: PostgreSQL and Vault verification + +on: + workflow_call: + +permissions: + contents: read + +jobs: + real-dependencies: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: '25' + cache: maven + - name: PostgreSQL migrations and competing transactions + run: >- + ./mvnw -B -ntp -pl payguard-identity-service,payguard-loan-service,payguard-collateral-service -am + -Dtest=PostgreSqlTlsTest,PostgresqlMigrationRehearsalTest,SanitizedDependencyErrorTest,OtpInfrastructureIntegrationTest,OtpApplicationContractsTest,LoanServiceApplicationTests,CollateralServiceApplicationTests + -Dsurefire.failIfNoSpecifiedTests=false clean test + - name: Build protected migration and integration runners + run: >- + ./mvnw -B -ntp -pl payguard-identity-service,payguard-wallet-service,payguard-loan-service,payguard-collateral-service -am -DskipTests package dependency:copy-dependencies -DincludeScope=test + - name: Verify synthetic export and approved identifier mapping + run: python3 -m unittest discover -s scripts/migration -p 'test_*.py' + - name: Prepare isolated TLS infrastructure + run: | + python3 scripts/local/prepare.py + docker compose --env-file .env.example up -d --wait postgres-wallet postgres-loan postgres-collateral postgres-identity vault + python3 scripts/local/vault_bootstrap.py + - name: Apply separately privileged Flyway migrations + run: | + python3 scripts/local/migrate.py identity + python3 scripts/local/migrate.py loan + python3 scripts/local/migrate.py collateral + - name: Verify actual pools, scoped secrets and Vault audit + run: python3 scripts/local/verify_vault.py + - name: Boot actual packaged services with scoped credentials + run: python3 scripts/local/verify_applications.py + - name: Preserve test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: database-security-reports + path: | + **/target/surefire-reports/TEST-*.xml + payguard-shared/target/vault-test-reports/*.xml + if-no-files-found: warn + - name: Stop isolated infrastructure + if: always() + run: docker compose --env-file .env.example stop diff --git a/.github/workflows/_publish.yml b/.github/workflows/_publish.yml index 9a71967..638311a 100644 --- a/.github/workflows/_publish.yml +++ b/.github/workflows/_publish.yml @@ -34,10 +34,13 @@ jobs: | grep -A8 -B2 spring-boot-maven-plugin - name: Build Wallet Service Image - run: docker build -f payguard-wallet-service/Dockerfile -t payguard/wallet-service:latest . + run: docker build -f payguard-wallet-service/Dockerfile -t payguard/wallet-service:${{ github.sha }} . - name: Build Loan Service Image - run: docker build -f payguard-loan-service/Dockerfile -t payguard/loan-service:latest . + run: docker build -f payguard-loan-service/Dockerfile -t payguard/loan-service:${{ github.sha }} . - name: Build Collateral Service Image - run: docker build -f payguard-collateral-service/Dockerfile -t payguard/collateral-service:latest . + run: docker build -f payguard-collateral-service/Dockerfile -t payguard/collateral-service:${{ github.sha }} . + + - name: Build Identity Service Image + run: docker build -f payguard-identity-service/Dockerfile -t payguard/identity-service:${{ github.sha }} . diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 974f79f..9a5ffc5 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -7,6 +7,9 @@ on: types: [ opened, reopened, synchronize ] jobs: + database-security: + uses: ./.github/workflows/_database-security.yml + # 1. Always run quality checks on PRs and main branch pushes quality-check: uses: ./.github/workflows/_quality.yml @@ -14,5 +17,5 @@ jobs: # 2. Build container images on every PR and main push. # This job validates images; it does not publish to an external registry. publish-docker: - needs: quality-check + needs: [quality-check, database-security] uses: ./.github/workflows/_publish.yml diff --git a/.gitignore b/.gitignore index 5698431..7f96ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ target/ # Local workflow guidance GIT_WORKFLOW_CONVENTION.md +PAYGUARD_GOAL.md + +# Protected local PostgreSQL/Vault rehearsal artifacts +.local/ +__pycache__/ diff --git a/README.md b/README.md index 440c179..847f023 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -
- # PayGuard **A privacy-focused, secure modular fintech backend for wallet ledgering, loan origination, and digital-collateral risk management.** @@ -79,262 +77,48 @@ It is not a payment-card switch or a full core-banking platform. The scope is in ### System Overview -```mermaid -flowchart LR - client["Client (REST)"] - gateway["API Gateway
(REST in, gRPC out)"] - wallet["wallet-service
(Ledger)"] - loan["loan-service"] - collateral["collateral-service"] - oracle["Oracle 23c"] - redis["Redis 7
(price cache, distributed locks)"] - kafka["Kafka (KRaft)
domain events"] - - client --> gateway - gateway -->|gRPC| wallet - gateway -->|gRPC| loan - gateway -->|gRPC| collateral - - loan -->|REST: disburse/repay| wallet - loan -->|REST: check LTV| collateral - collateral -->|REST: outstanding balance| loan - collateral --> redis - - wallet --> oracle - loan --> oracle - collateral --> oracle - - loan -.->|planned| kafka - collateral -.->|planned| kafka -``` - -### Hexagonal Structure (per service) - -```mermaid -flowchart LR - Inbound["Inbound Adapters
REST Controllers"] - Application["Application Layer
Use-Case Services"] - Domain["Domain Layer
Aggregates / Value Objects / Invariants"] - Ports["Outbound Ports"] - Outbound["Outbound Adapters
jOOQ / JPA / HTTP Clients"] - - Inbound --> Application - Application --> Domain - Application --> Ports - Outbound --> Ports -``` - -### Dependency Rules - -- Domain code has no Spring, persistence, or HTTP dependencies — invariants (e.g. debit = credit) are testable without a running application. -- Each service owns its own Oracle instance, credentials, volume, and schema; no service queries another's tables directly. -- Cross-service calls happen only through the declared client ports (`WalletServiceClient`, `LoanServiceClient`, `CollateralServiceClient`), each with a REST adapter today and a gRPC adapter available for the gateway path. -- Dependencies point inward, toward the domain core. - ---- - -## Key Engineering Decisions - -### No stored balance column - -`wallet-service` never persists a mutable `balance` field. Every balance is derived from the sum of immutable `ledger_entry` rows. This eliminates an entire class of race conditions that come from concurrent `UPDATE balance = balance - x` statements, at the cost of computing balances on read — a trade-off documented here rather than hidden. - -### Holds are not ledger entries - -A hold (reservation) reduces *available* balance without touching the *ledger* balance. Only capture — the actual settlement of a hold — creates real, immutable ledger entries. This mirrors the authorize-then-capture pattern used throughout the payments industry. +## Current services -### Concurrency: inserts don't need locks, holds do +| Module | Persistence available in source | Current database integration | +| --- | --- | --- | +| `payguard-identity-service` | JPA challenge/audit adapters; existing-customer OTP issue/verify | PostgreSQL 17.11, Flyway, serialized verification/replacement; Vault database leases and scoped KV secrets | +| `payguard-loan-service` | Domain/application ports and loan SQL schema; concrete repository adapter absent | PostgreSQL schema and real precision/constraint tests; Vault pool | +| `payguard-collateral-service` | Domain/application ports and collateral SQL schema; concrete repository adapter absent | PostgreSQL schema and real valuation/constraint tests; Vault pool | +| `payguard-wallet-service` | Domain/ports and OSS jOOQ dependency; no SQL schema, adapter or code-generation configuration | POSTGRES dialect and Vault pool configured; ledger persistence remains separate work | +| `payguard-api-gateway`, `payguard-proto` | No owned database | Database-free | -Posting a ledger transaction is a pure `INSERT` and needs no row locking. Creating a *hold* does need one (`SELECT ... FOR UPDATE` or a Redis-based distributed lock), because two concurrent hold requests could otherwise both read a stale available balance and jointly overdraw the account. +Identity contains access, customer, verification and registration modules in one deployment and one owned database. Access and registration workflows remain largely planned. The retired step-up-token endpoint is not restored. -### gRPC only at the gateway boundary +## Ownership and engineering decisions -Internal service-to-service calls use plain REST. gRPC is used exclusively between the API Gateway and the internal services, where the latency and schema-contract benefits are worth the added operational complexity — not applied uniformly out of habit. +Each business service has its own PostgreSQL database, runtime group, durable schema owner and independently scoped Vault AppRole. Local rehearsal uses four private PostgreSQL containers. Runtime roles have DML privileges; Flyway runs through a separate migration identity. Applications never receive a database administrator or Vault root token. -### Derived, never-stored LTV +Domain rules remain in the domain, use-case coordination in application code, and JDBC/JPA/jOOQ/Vault details in infrastructure. Wallet's intended append-only ledger and hold/capture separation remain design decisions; the absent SQL adapter means their persisted concurrency behavior has not been established. Loan and Collateral continue to use their existing JPA dependency rather than being rewritten to jOOQ. Cross-service access belongs behind explicit ports, with no foreign keys or table reads across service databases. -Loan-to-Value is calculated on demand from the latest price snapshot and the loan's current outstanding principal, never cached as a stored "current LTV" field. This keeps the number provably correct at the moment it's checked, which matters more here than raw read speed. +- [PostgreSQL ownership and cutover ADR](docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md) +- [Vault secrets, bootstrap and licensing ADR](docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md) +- [Database credential and pool lifecycle ADR](docs/decission/ADR-0013-database-leases-and-pool-replacement.md) +- [OTP concurrency ADR](docs/decission/ADR-0014-otp-postgresql-concurrency.md) +- [Identity architecture](payguard-identity-service/ARCHITECTURE.md) ---- - -## Getting Started - -### Prerequisites +## Build and local rehearsal -- Java 25 or newer -- Docker with Docker Compose -- Maven Wrapper (included, no separate Maven install required) +Use JDK **25**, Docker/Compose, Python 3 and OpenSSL. The Maven wrapper is included. Database integration tests start real PostgreSQL containers; they do not require an unset opt-in flag. ```bash -java -version -docker --version -docker compose version +./mvnw -B -ntp clean verify ``` -### 1. Clone the Repository +The full build still exposes existing domain/static-analysis defects described in the implementation guide. Do not bypass those checks to claim a passing build. -```bash -git clone https://github.com//payguard.git -cd payguard -``` +For the isolated, persistent TLS PostgreSQL/Vault rehearsal, follow [the implementation guide](docs/guides/postgresql-vault-migration.md). It gives the exact preparation, initialization, migration and test commands. Generated credentials, certificates and unseal material live under ignored `.local/payguard` with protected permissions. `.env.example` contains only public settings. Never copy secrets into an image or publish that directory. -### 2. Configure the Environment - -Create a `.env` file in the repository root: - -```env -# Independent Oracle databases (copy `.env.example` to `.env` and set secrets) -ORACLE_IMAGE=gvenzl/oracle-free:23-slim-faststart -WALLET_ORACLE_PORT=1521 -LOAN_ORACLE_PORT=1522 -COLLATERAL_ORACLE_PORT=1523 -WALLET_ORACLE_SYSTEM_PASSWORD=change-wallet-system-password -LOAN_ORACLE_SYSTEM_PASSWORD=change-loan-system-password -COLLATERAL_ORACLE_SYSTEM_PASSWORD=change-collateral-system-password -WALLET_ORACLE_APP_USER=payguard_wallet -LOAN_ORACLE_APP_USER=payguard_loan -COLLATERAL_ORACLE_APP_USER=payguard_collateral -WALLET_ORACLE_APP_PASSWORD=change-wallet-app-password -LOAN_ORACLE_APP_PASSWORD=change-loan-app-password -COLLATERAL_ORACLE_APP_PASSWORD=change-collateral-app-password -WALLET_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1521/FREE -LOAN_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1522/FREE -COLLATERAL_ORACLE_JDBC_URL=jdbc:oracle:thin:@localhost:1523/FREE - -# Kafka (KRaft mode — no Zookeeper) -KAFKA_IMAGE=apache/kafka:latest -KAFKA_CLUSTER_ID=replace-with-a-generated-cluster-id - -# Per-service ports -WALLET_SERVICE_PORT=8081 -LOAN_SERVICE_PORT=8082 -COLLATERAL_SERVICE_PORT=8083 -API_GATEWAY_PORT=8080 -``` - -> Never commit real credentials. The values above are placeholders for local development. - -### 3. Start Infrastructure - -```bash -docker compose up -d payguard-oracle payguard-redis payguard-kafka --wait -``` - -### 4. Run a Service - -```bash -./mvnw spring-boot:run -pl payguard-wallet-service -``` - -### 5. Open the API Documentation - -```text -http://localhost:8081/swagger-ui/index.html -``` - -To stop the infrastructure: - -```bash -docker compose down -``` - ---- +Runtime Flyway is disabled: apply migrations using the migration identity before starting applications. The `applications` Compose profile is separate from the database/Vault infrastructure. Provider delivery, customer enrollment, external authentication, production certificate issuance, HA/auto-unseal and durable event delivery are not implemented by this migration. -## API Documentation +## Existing data and historical decisions -Each service exposes its own OpenAPI specification and Swagger UI at `/swagger-ui/index.html`. Representative endpoints: - -| Service | Method | Path | Purpose | -|---|---|---|---| -| wallet-service | `POST` | `/accounts/{id}/holds` | Reserve funds ahead of a capture | -| wallet-service | `GET` | `/accounts/{id}/balance` | Ledger and available balance | -| loan-service | `POST` | `/loans/{id}/approve` | Generate the amortization schedule | -| loan-service | `POST` | `/loans/{id}/repayments` | Apply a payment through the waterfall | -| collateral-service | `GET` | `/collateral/{id}` | Current position and live LTV | -| collateral-service | `POST` | `/collateral/refresh-prices` | Re-evaluate risk against latest prices | - -The OpenAPI document for each service is the source of truth for exact request/response schemas. - ---- - -## Code Quality - -| Check | Tool | Execution | -|---|---|---| -| Build & tests | Maven | CI and local | -| Formatting / static analysis | *(planned)* | CI | - -```bash -./mvnw clean verify -``` - -Install the local Git hook so every `git push` runs the same verification first: - -```bash -./mvnw -Pinstall-git-hooks initialize -``` - ---- - -## Roadmap - -### wallet-service -- [ ] Account CRUD -- [ ] Direct transfer flow (Flow A) -- [ ] Hold → capture/release flow (Flow B) -- [ ] Idempotency-key enforcement - -### loan-service -- [ ] Loan application and state machine -- [ ] Amortization schedule generation -- [ ] Repayment waterfall -- [ ] Delinquency/default detection - -### collateral-service -- [ ] Collateral locking -- [ ] Price-oracle integration -- [ ] LTV monitoring and margin calls -- [ ] Liquidation flow - -### Platform -- [ ] API Gateway (REST → gRPC) -- [ ] Kafka domain events -- [ ] Full CI via GitHub Actions - ---- - -## Current Limitations - -- PayGuard is an engineering-demonstration project, not a production lending platform. -- No real price feed, KYC, or payment-network integration is connected; these are simulated or stubbed. -- Continuous deployment is out of scope for the current phase — the focus is on correctness and depth of the core domain services. -- Each service currently assumes a single instance; distributed-lock behavior for holds is documented but not yet load-tested at scale. - ---- -## Author - -**PayGuard** is designed, architected, and developed independently by: - -
- -### Amir Golmoradi - -**Backend Engineer** - -[![GitHub](https://img.shields.io/badge/GitHub-Amir--Golmoradi-181717?style=flat-square&logo=github&logoColor=white)](https://github.com/Amir-Golmoradi) - -
- -Amir is the sole author, architect, and maintainer of this project — from domain -modeling and hexagonal architecture design through implementation, infrastructure, -and documentation. All architectural decisions, ADRs, and engineering trade-offs -recorded in this repository reflect his individual design work. - -For questions, architectural feedback, or collaboration inquiries, please open an -issue or reach out via GitHub. - ---- +Oracle scripts are preserved byte-for-byte under each owning module's `db/legacy/oracle`. PostgreSQL has a separate migration history and never executes those scripts. No existing volume is deleted or treated as empty. Legacy UUID challenge/customer records require an approved persistent identifier mapping; no identities are regenerated to make an import succeed. -## License +The cutover is an offline write freeze, rehearsed export/import and reconciliation. After PostgreSQL accepts new writes, returning to Oracle requires reverse migration or reconciliation; retaining an old volume alone is insufficient. Historical ADRs retain their original context, with database-related decisions superseded by ADR 0011–0014. -This project is licensed under the [MIT License](LICENSE). +See [the migration guide](docs/guides/postgresql-vault-migration.md), [durable work log](docs/tasks/postgresql-vault-migration.md) and [separate issue drafts](docs/issues/postgresql-vault-follow-up.md) for actual verification evidence and remaining work. diff --git a/docker-compose.yaml b/docker-compose.yaml index b8c0190..8db1dc3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,99 +1,124 @@ -name: ${COMPOSE_PROJECT_NAME} +name: ${COMPOSE_PROJECT_NAME:-payguard} services: - oracle-wallet: - image: ${ORACLE_IMAGE} - container_name: ${COMPOSE_PROJECT_NAME}-oracle-wallet - ports: - - "${WALLET_ORACLE_PORT}:1521" + postgres-wallet: + image: postgres:17.11-bookworm@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 + entrypoint: ["/bin/sh", "/opt/payguard/entrypoint.sh"] environment: - ORACLE_PASSWORD: ${WALLET_ORACLE_SYSTEM_PASSWORD} - APP_USER: ${WALLET_ORACLE_APP_USER} - APP_PASSWORD: ${WALLET_ORACLE_APP_PASSWORD} + POSTGRES_DB: payguard_wallet + POSTGRES_PASSWORD_FILE: /run/payguard/admin-password + volumes: + - postgres_wallet_data:/var/lib/postgresql/data + - ./.local/payguard/wallet:/run/payguard:ro + - ./infrastructure/postgresql/entrypoint.sh:/opt/payguard/entrypoint.sh:ro + - ./infrastructure/postgresql/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro healthcheck: - test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"] - interval: 10s - timeout: 5s + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "payguard_wallet"] + interval: 5s + timeout: 3s retries: 20 - start_period: 45s - volumes: - - oracle_wallet_data:/opt/oracle/oradata + networks: [database] - redis: - image: redis:8-alpine - container_name: payguard-redis - ports: - - "${REDIS_PORT}:6379" + postgres-loan: + image: postgres:17.11-bookworm@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 + entrypoint: ["/bin/sh", "/opt/payguard/entrypoint.sh"] + environment: + POSTGRES_DB: payguard_loan + POSTGRES_PASSWORD_FILE: /run/payguard/admin-password + volumes: + - postgres_loan_data:/var/lib/postgresql/data + - ./.local/payguard/loan:/run/payguard:ro + - ./infrastructure/postgresql/entrypoint.sh:/opt/payguard/entrypoint.sh:ro + - ./infrastructure/postgresql/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "payguard_loan"] interval: 5s timeout: 3s - retries: 10 + retries: 20 + networks: [database] - oracle-loan: - image: ${ORACLE_IMAGE} - container_name: ${COMPOSE_PROJECT_NAME}-oracle-loan - ports: - - "${LOAN_ORACLE_PORT}:1521" + postgres-collateral: + image: postgres:17.11-bookworm@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 + entrypoint: ["/bin/sh", "/opt/payguard/entrypoint.sh"] environment: - ORACLE_PASSWORD: ${LOAN_ORACLE_SYSTEM_PASSWORD} - APP_USER: ${LOAN_ORACLE_APP_USER} - APP_PASSWORD: ${LOAN_ORACLE_APP_PASSWORD} + POSTGRES_DB: payguard_collateral + POSTGRES_PASSWORD_FILE: /run/payguard/admin-password + volumes: + - postgres_collateral_data:/var/lib/postgresql/data + - ./.local/payguard/collateral:/run/payguard:ro + - ./infrastructure/postgresql/entrypoint.sh:/opt/payguard/entrypoint.sh:ro + - ./infrastructure/postgresql/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro healthcheck: - test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"] - interval: 10s - timeout: 5s + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "payguard_collateral"] + interval: 5s + timeout: 3s retries: 20 - start_period: 45s - volumes: - - oracle_loan_data:/opt/oracle/oradata + networks: [database] - oracle-otp: - image: ${ORACLE_IMAGE} - container_name: ${COMPOSE_PROJECT_NAME}-oracle-otp - ports: - - "${OTP_ORACLE_PORT}:1521" + postgres-identity: + image: postgres:17.11-bookworm@sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826 + entrypoint: ["/bin/sh", "/opt/payguard/entrypoint.sh"] environment: - ORACLE_PASSWORD: ${OTP_ORACLE_SYSTEM_PASSWORD} - APP_USER: ${OTP_ORACLE_APP_USER} - APP_USER_PASSWORD: ${OTP_ORACLE_APP_PASSWORD} + POSTGRES_DB: payguard_identity + POSTGRES_PASSWORD_FILE: /run/payguard/admin-password + volumes: + - postgres_identity_data:/var/lib/postgresql/data + - ./.local/payguard/identity:/run/payguard:ro + - ./infrastructure/postgresql/entrypoint.sh:/opt/payguard/entrypoint.sh:ro + - ./infrastructure/postgresql/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro healthcheck: - test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"] - interval: 10s - timeout: 5s + test: ["CMD", "pg_isready", "-U", "postgres", "-d", "payguard_identity"] + interval: 5s + timeout: 3s retries: 20 - start_period: 45s - volumes: - - oracle_otp_data:/opt/oracle/oradata + networks: [database] - oracle-collateral: - image: ${ORACLE_IMAGE} - container_name: ${COMPOSE_PROJECT_NAME}-oracle-collateral - ports: - - "${COLLATERAL_ORACLE_PORT}:1521" + vault: + image: hashicorp/vault:2.1.2@sha256:c2f666266f383d2cf424d86b8bb8ce7d065562173ffec2b476d762943608bb55 + user: "0:0" environment: - ORACLE_PASSWORD: ${COLLATERAL_ORACLE_SYSTEM_PASSWORD} - APP_USER: ${COLLATERAL_ORACLE_APP_USER} - APP_PASSWORD: ${COLLATERAL_ORACLE_APP_PASSWORD} + SKIP_SETCAP: "1" + SKIP_CHOWN: "1" + entrypoint: ["/bin/sh", "/opt/payguard/entrypoint.sh"] + ports: ["127.0.0.1:${VAULT_PORT:-8200}:8200"] + volumes: + - vault_data:/vault/data + - vault_audit:/vault/audit + - ./.local/payguard/vault:/run/payguard:ro + - ./infrastructure/vault/server.hcl:/vault/config/server.hcl:ro + - ./infrastructure/vault/entrypoint.sh:/opt/payguard/entrypoint.sh:ro + networks: [database, operations] + + redis: + image: redis:8.0.3-alpine healthcheck: - test: ["CMD-SHELL", "/opt/oracle/checkDBStatus.sh"] - interval: 10s - timeout: 5s - retries: 20 - start_period: 45s + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + networks: [database] + + kafka-init: + image: apache/kafka:4.0.1 + user: "0:0" + entrypoint: ["/bin/sh", "-c"] + command: ["chown 1000:1000 /tmp/kraft-combined-logs"] volumes: - - oracle_collateral_data:/opt/oracle/oradata + - kafka_data:/tmp/kraft-combined-logs kafka: - image: ${KAFKA_IMAGE} - container_name: ${COMPOSE_PROJECT_NAME}-kafka + image: apache/kafka:4.0.1 + networks: [database] + container_name: ${COMPOSE_PROJECT_NAME:-payguard}-kafka + depends_on: + kafka-init: { condition: service_completed_successfully } ports: - - "${KAFKA_PORT}:9092" + - "${KAFKA_PORT:-9092}:9092" environment: KAFKA_NODE_ID: 1 KAFKA_PROCESS_ROLES: broker,controller KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:${KAFKA_PORT} + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:${KAFKA_PORT:-9092} KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL @@ -103,7 +128,7 @@ services: KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 KAFKA_LOG_DIRS: /tmp/kraft-combined-logs - CLUSTER_ID: ${KAFKA_CLUSTER_ID} + CLUSTER_ID: MDEyMzQ1Njc4OWFiY2RlZg healthcheck: test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:29092 --list"] interval: 10s @@ -114,76 +139,103 @@ services: - kafka_data:/tmp/kraft-combined-logs wallet-service: + profiles: [applications] build: context: . dockerfile: payguard-wallet-service/Dockerfile - container_name: ${COMPOSE_PROJECT_NAME}-wallet-service - ports: - - "${WALLET_SERVICE_PORT}:8080" + user: "${PAYGUARD_LOCAL_UID:-1000}:${PAYGUARD_LOCAL_GID:-1000}" + ports: ["127.0.0.1:8081:8080"] environment: - SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-wallet:1521/FREE - SPRING_DATASOURCE_USERNAME: ${WALLET_ORACLE_APP_USER} - SPRING_DATASOURCE_PASSWORD: ${WALLET_ORACLE_APP_PASSWORD} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-wallet:5432/payguard_wallet?sslmode=verify-full&sslrootcert=/run/payguard/ca.crt¤tSchema=payguard + VAULT_ADDR: https://vault:8200 + VAULT_CA_FILE: /run/payguard/ca.crt + VAULT_ROLE_ID_FILE: /run/payguard/approle/role-id + VAULT_SECRET_ID_FILE: /run/payguard/approle/secret-id + REDIS_HOST: redis KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + volumes: + - ./.local/payguard/apps/wallet:/run/payguard/approle:ro + - ./.local/payguard/ca.crt:/run/payguard/ca.crt:ro depends_on: - oracle-wallet: { condition: service_healthy } - kafka: { condition: service_healthy } + postgres-wallet: {condition: service_healthy} + networks: [database, operations] loan-service: + profiles: [applications] build: context: . dockerfile: payguard-loan-service/Dockerfile - container_name: ${COMPOSE_PROJECT_NAME}-loan-service - ports: - - "${LOAN_SERVICE_PORT}:8080" + user: "${PAYGUARD_LOCAL_UID:-1000}:${PAYGUARD_LOCAL_GID:-1000}" + ports: ["127.0.0.1:8082:8080"] environment: - SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-loan:1521/FREE - SPRING_DATASOURCE_USERNAME: ${LOAN_ORACLE_APP_USER} - SPRING_DATASOURCE_PASSWORD: ${LOAN_ORACLE_APP_PASSWORD} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-loan:5432/payguard_loan?sslmode=verify-full&sslrootcert=/run/payguard/ca.crt¤tSchema=payguard + VAULT_ADDR: https://vault:8200 + VAULT_CA_FILE: /run/payguard/ca.crt + VAULT_ROLE_ID_FILE: /run/payguard/approle/role-id + VAULT_SECRET_ID_FILE: /run/payguard/approle/secret-id + REDIS_HOST: redis KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + volumes: + - ./.local/payguard/apps/loan:/run/payguard/approle:ro + - ./.local/payguard/ca.crt:/run/payguard/ca.crt:ro depends_on: - oracle-loan: { condition: service_healthy } - kafka: { condition: service_healthy } + postgres-loan: {condition: service_healthy} + networks: [database, operations] collateral-service: + profiles: [applications] build: context: . dockerfile: payguard-collateral-service/Dockerfile - container_name: ${COMPOSE_PROJECT_NAME}-collateral-service - ports: - - "${COLLATERAL_SERVICE_PORT}:8080" + user: "${PAYGUARD_LOCAL_UID:-1000}:${PAYGUARD_LOCAL_GID:-1000}" + ports: ["127.0.0.1:8083:8080"] environment: - SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-collateral:1521/FREE - SPRING_DATASOURCE_USERNAME: ${COLLATERAL_ORACLE_APP_USER} - SPRING_DATASOURCE_PASSWORD: ${COLLATERAL_ORACLE_APP_PASSWORD} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-collateral:5432/payguard_collateral?sslmode=verify-full&sslrootcert=/run/payguard/ca.crt¤tSchema=payguard + VAULT_ADDR: https://vault:8200 + VAULT_CA_FILE: /run/payguard/ca.crt + VAULT_ROLE_ID_FILE: /run/payguard/approle/role-id + VAULT_SECRET_ID_FILE: /run/payguard/approle/secret-id + REDIS_HOST: redis KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + volumes: + - ./.local/payguard/apps/collateral:/run/payguard/approle:ro + - ./.local/payguard/ca.crt:/run/payguard/ca.crt:ro depends_on: - oracle-collateral: { condition: service_healthy } - kafka: { condition: service_healthy } + postgres-collateral: {condition: service_healthy} + networks: [database, operations] - otp-service: + identity-service: + profiles: [applications] build: context: . - dockerfile: payguard-otp-service/Dockerfile - container_name: ${COMPOSE_PROJECT_NAME}-otp-service - ports: - - "${OTP_SERVICE_PORT}:8084" + dockerfile: payguard-identity-service/Dockerfile + user: "${PAYGUARD_LOCAL_UID:-1000}:${PAYGUARD_LOCAL_GID:-1000}" + ports: ["127.0.0.1:8084:8084"] environment: - SPRING_DATASOURCE_URL: jdbc:oracle:thin:@oracle-otp:1521/FREE - SPRING_DATASOURCE_USERNAME: ${OTP_ORACLE_APP_USER} - SPRING_DATASOURCE_PASSWORD: ${OTP_ORACLE_APP_PASSWORD} - OTP_SECURITY_PEPPER: ${OTP_SECURITY_PEPPER} + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-identity:5432/payguard_identity?sslmode=verify-full&sslrootcert=/run/payguard/ca.crt¤tSchema=payguard + VAULT_ADDR: https://vault:8200 + VAULT_CA_FILE: /run/payguard/ca.crt + VAULT_ROLE_ID_FILE: /run/payguard/approle/role-id + VAULT_SECRET_ID_FILE: /run/payguard/approle/secret-id REDIS_HOST: redis - REDIS_PORT: 6379 KAFKA_BOOTSTRAP_SERVERS: kafka:29092 + volumes: + - ./.local/payguard/apps/identity:/run/payguard/approle:ro + - ./.local/payguard/ca.crt:/run/payguard/ca.crt:ro depends_on: - oracle-otp: { condition: service_healthy } - redis: { condition: service_healthy } - kafka: { condition: service_healthy } + postgres-identity: {condition: service_healthy} + networks: [database, operations] + +networks: + operations: + database: + internal: true volumes: - oracle_wallet_data: - oracle_loan_data: - oracle_collateral_data: + postgres_wallet_data: + postgres_loan_data: + postgres_collateral_data: + postgres_identity_data: + vault_data: + vault_audit: kafka_data: - oracle_otp_data: diff --git a/docs/decission/ADR-0001: Use jOOQ (not JPA) in wallet-service.md b/docs/decission/ADR-0001: Use jOOQ (not JPA) in wallet-service.md index 51e53be..7264026 100644 --- a/docs/decission/ADR-0001: Use jOOQ (not JPA) in wallet-service.md +++ b/docs/decission/ADR-0001: Use jOOQ (not JPA) in wallet-service.md @@ -1,5 +1,7 @@ # ADR-0001: Use jOOQ (not JPA) in wallet-service +> Database/secret implementation update: ADR 0011–0014 supersede Oracle runtime configuration and define PostgreSQL ownership, scoped Vault key/credential handling and OTP concurrency. Historical context below is retained; Wallet still has no schema/code-generation adapter in this checkout. + ## Status Accepted diff --git a/docs/decission/ADR-0007-otp-code-and-totp-strategy.md b/docs/decission/ADR-0007-otp-code-and-totp-strategy.md index 2de8bba..fcc6ba2 100644 --- a/docs/decission/ADR-0007-otp-code-and-totp-strategy.md +++ b/docs/decission/ADR-0007-otp-code-and-totp-strategy.md @@ -1,5 +1,7 @@ # ADR-0007: Use random numeric OTPs for delivery and RFC 6238 for authenticator apps +> Database/secret implementation update: ADR 0011–0014 supersede Oracle runtime configuration and define PostgreSQL ownership, scoped Vault key/credential handling and OTP concurrency. Historical context below is retained. + ## Status Accepted diff --git a/docs/decission/ADR-0009-otp-device-binding.md b/docs/decission/ADR-0009-otp-device-binding.md index d139c7b..74a069d 100644 --- a/docs/decission/ADR-0009-otp-device-binding.md +++ b/docs/decission/ADR-0009-otp-device-binding.md @@ -1,5 +1,7 @@ # ADR-0009: Optional device/session binding for OTP challenges +> Database/secret implementation update: ADR 0011–0014 supersede Oracle runtime configuration and define PostgreSQL ownership, scoped Vault key/credential handling and OTP concurrency. Historical context below is retained. + ## Status Accepted diff --git a/docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md b/docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md new file mode 100644 index 0000000..2b71c58 --- /dev/null +++ b/docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md @@ -0,0 +1,34 @@ +# ADR-0011: PostgreSQL ownership and offline cutover + +## Status +Accepted for this implementation; live cutover is not authorized. + +## Context +Java 25 / Spring Boot 4.1.1 is the existing platform. Identity has JPA adapters; Loan and Collateral have Oracle schemas but no concrete domain repositories. Wallet declares jOOQ OSS 3.19.15 but has no schema, SQL adapters, generator configuration or generated records. API Gateway has no database. Existing Oracle data and legacy identity mappings are unknown. + +## Options considered +Retain Oracle; replace every adapter with jOOQ; or adopt PostgreSQL while preserving actual persistence approaches. Choose the last option. H2 cannot validate the target engine. + +## Decision +Pin PostgreSQL 17.11 (supported major until November 2029). Keep independent databases for Identity, Wallet, Loan and Collateral. Identity's four modules share its database. Local rehearsal uses separate containers to isolate credential managers; runtime roles never receive superuser, schema ownership, CREATE or another service's privileges. Use a no-login owner, separate migration login and no-login DML group, with owner default privileges for future migrations. + +Normal Flyway locations are `db/migration/postgresql`; unchanged Oracle files live under `db/legacy/oracle`. PostgreSQL V1 installs current types directly; Identity V2 installs issuance serialization and an active-row unique index. Never copy Oracle Flyway history. Runtime starts only after separately authorized migration provisioning. Hibernate validates mappings. + +Map real UUID RAW(16) values through a documented canonical byte-order conversion to PostgreSQL uuid; preserve prefixed case-sensitive IDs as varchar with C collation. Decimal precision remains numeric, never floating point; identity counters are integer; BIGINT identity replaces numeric identity; CLOB is text. Instant values use timestamptz(6): microseconds and UTC instants, not original offsets or Java nanoseconds. Oracle DATE is reviewed per column; last_accrual_date is a LocalDate/date. Empty-string/null differences require explicit export normalization for optional fields, never identifiers. + +Use TLS verify-full and SCRAM for non-disposable application and Vault connections. Ephemeral Testcontainers alone may use isolated plaintext transport with synthetic credentials. + +## Cutover and failure behavior +Choose offline write freeze, verified backup/export, parent-before-child import, row counts, orphan/constraint checks and representative comparisons. Existing UUID challenge/customer references require an approved persistent mapping; refuse invented mappings. Rehearse with synthetic fixtures. No clean, truncate or volume removal on existing data. Before PostgreSQL writes, Oracle remains rollback source; after writes, reverse migration/reconciliation is mandatory. + +## Tradeoffs and scope +No Java/Spring upgrade; no new ledger schema invented for Wallet. Missing adapters and generated SQL remain separate work and limit platform-wide claims. Local single-instance containers do not provide production HA, encrypted hosting volumes or operational certification. + +## Verification +Run actual Flyway and PostgreSQL round trips, upgrade V1 to V2, numeric/UUID/time/case/foreign-key checks, role denial and Identity races. Live-data migration requires authorized source/mapping. + +## References +- https://www.postgresql.org/support/versioning/ +- https://www.postgresql.org/docs/17/datatype.html +- https://www.postgresql.org/docs/17/ddl-priv.html +- https://www.jooq.org/download/ diff --git a/docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md b/docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md new file mode 100644 index 0000000..645f861 --- /dev/null +++ b/docs/decission/ADR-0012-vault-service-secrets-and-bootstrap.md @@ -0,0 +1,40 @@ +# ADR-0012: Vault service secrets and bootstrap + +## Status +Accepted for this implementation. + +## Context +Database passwords currently arrive through environment variables. Identity has a known pepper fallback and no production authenticator-secret source. Provider delivery adapters are placeholders without provider credentials. Public customer/challenge IDs are identifiers, not credentials. + +## Options considered +Environment-only static secrets; paid managed/Enterprise Vault; or self-hosted Community Vault. Choose Community Vault 2.1.2 using database secrets, KV v2, AppRole, response wrapping and file audit. No namespaces, replication, rootless static-role Enterprise capability or paid jOOQ distribution is required. + +## Decision +Use independently provisioned environment/service policies and AppRoles. Runtime reads only its dynamic database role and existing application secret paths. Identity reads a versioned HMAC key ring and customer-keyed synthetic/provisioned authenticator seeds. Missing seed returns Optional.empty; denied/unavailable Vault raises a sanitized dependency error before an attempt is recorded. No unauthenticated enrollment API is added. + +Operators provision response-wrapped short-lived SecretIDs into ignored mode-0600 files and deliver role IDs separately. Bootstrap root material stays in protected operator artifacts, never application configuration. Application tokens renew; expiring SecretIDs require operator reprovisioning for later reauthentication. Restarts use the protected provisioned identity. Do not describe a long-lived root token in .env as bootstrap. + +Persistent local rehearsal uses TLS with CA/hostname verification, integrated Raft storage, manual initialization/unseal and a file audit device retaining default HMAC protection. No dev mode is the deployment design. Backups include Raft snapshots, protected keys and restore procedures; no HA/auto-unseal is claimed. Audit readers are operators; disk capacity and rotation must be monitored because audit failure can stop requests. + +Database-management credentials are independently scoped to a dedicated PostgreSQL server per service and rotated by Vault's rotate-root endpoint after provisioning. Runtime never reads manager or migration credentials. Separate migration policies remain operator/build identities. + +## Key handling +HMAC uses current key ID plus retained named keys. New hashes include key ID; legacy salt:digest is accepted only with explicitly named legacy key. Retire a key only after every valid bound challenge has expired; changing KV alone is not compatibility. TOTP seed rotation requires separately provisioned recovery/enrollment. Vault token and database lease rotation do not rotate peppers or seeds. + +KV stores recoverable TOTP seeds under encrypted Vault storage; Transit field encryption is not introduced where no existing justified field-encryption use case exists. TLS, Vault storage encryption and hosting volume/backup encryption have different scopes. + +## Licensing +The selected upstream LICENSE is BUSL 1.1 with an additional use grant for non-competing production use, and MPL 2.0 after the change date. PayGuard uses Vault internally for its financial application; it does not offer a competing hosted Vault service. Community is source-available, not described as OSI open source. License review is required if the product is repackaged into a competing secrets offering. No Enterprise subscription/license key is needed for chosen capabilities. + +## Failure behavior and tradeoffs +Required missing keys/credentials fail startup. Dependency errors are sanitized; no known password/pepper fallback. Manual unseal/bootstrap adds operational responsibility. Production hosting, backup encryption, independent key custody and HA remain deployment decisions. + +## Verification and references +Prove allowed/denied paths, AppRole/restart, real KV-backed lookup, secret-marker diagnostics, outage semantics and persistent TLS/audit behavior. +- https://github.com/hashicorp/vault/blob/v2.1.2/LICENSE +- https://www.hashicorp.com/en/license-faq +- https://developer.hashicorp.com/vault/docs/updates/release-notes +- https://developer.hashicorp.com/vault/docs/auth/approle +- https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2 +- https://developer.hashicorp.com/vault/docs/secrets/transit +- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html diff --git a/docs/decission/ADR-0013-database-leases-and-pool-replacement.md b/docs/decission/ADR-0013-database-leases-and-pool-replacement.md new file mode 100644 index 0000000..6c6c328 --- /dev/null +++ b/docs/decission/ADR-0013-database-leases-and-pool-replacement.md @@ -0,0 +1,34 @@ +# ADR-0013: Replace pools before database leases expire + +## Status +Accepted for this implementation. + +## Context +Spring Cloud Vault documents that reaching database max_ttl does not automatically acquire replacement credentials and reconfigure the DataSource. Merely adding configuration cannot support continuous traffic. Existing BOM manages HikariCP 7.0.2 and PostgreSQL JDBC 42.7.13. + +## Options considered +Static runtime passwords; orchestrated process replacement; or in-process pool generation replacement. Choose bounded in-process replacement because this checkout has Docker and no rollout coordinator. Do not assume an absent orchestrator implements rotation. + +## Decision +A shared infrastructure DataSource obtains one lease through a service-scoped AppRole, constructs and validates a new Hikari pool, atomically switches future borrowers, evicts old idle connections and drains checked-out old connections before revocation. Acquire replacement at half lease lifetime, well before max TTL; renewal is supported/tested but not relied upon to extend credentials indefinitely. Failed replacement keeps only already valid credentials, retries with a bounded interval and rejects new connections at the safety deadline. No database credentials or lease material appear in pool names, errors or metrics labels. + +Bound connection acquisition, SQL statement duration and idle transaction duration. Keep the old generation until transactions return or the bounded drain/deadline expires. At the deadline close the generation; callers may fail and must retry according to their business idempotency contract. Never replay an in-flight financial transaction automatically. + +Use no-login runtime groups with DML grants and separate migration ownership. Dynamic roles own no objects. Revoke membership and drop the expiring login after draining. Revoking PostgreSQL login does not guarantee that established sessions immediately stop; selected graceful revocation permits existing work until pool drain/closure. Forced external revocation has a documented session-latency limit rather than a universal immediate-disconnection claim. + +Validate database CONNECT and schema USAGE when installing a generation and on each borrow. A revoked role can retain an established session that passes SELECT 1 but lacks application rights. Failed borrowing requests asynchronous replacement on the one-second maintenance cycle; it never replays a caller transaction. Arbitrary changes to individual table grants still require configuration/permission checks. Runtime login defaults bound statement, lock and idle-transaction time to ten seconds. + +Vault tokens renew independently; reauthentication uses a still-valid provisioned SecretID. Never grant runtime policies lease-administration privileges over another service. Lease renew/revoke policy is constrained to its own backend/role prefix. + +## Failure behavior +Fail startup when authentication, credential issuance or validation fails. Readiness probes must borrow and validate through the active DataSource; a running scheduler alone is insufficient. Temporary Vault outages permit bounded use until credential deadline; prolonged Vault/database outage denies readiness and new persistence work. Recovery installs a tested pool generation. Administrative rotate-root rotates only the manager connection credential and requires its own recovery procedure. + +## Scope and tradeoffs +Hikari pools overlap briefly and consume bounded additional connections. Long transactions exceeding drain limits can fail. This is infrastructure outside domain models, not automatic transaction retry or exactly-once processing. + +## Verification and references +Use short TTL/max TTL with real Vault/PostgreSQL/Hikari, held transactions, renew, repeated replacement, revoked logins, connection deadline, outage/recovery and manager credential rotation. +- https://docs.spring.io/spring-cloud-vault/reference/secret-backends.html +- https://developer.hashicorp.com/vault/docs/secrets/databases/postgresql +- https://developer.hashicorp.com/vault/docs/secrets/databases +- https://github.com/brettwooldridge/HikariCP diff --git a/docs/decission/ADR-0014-otp-postgresql-concurrency.md b/docs/decission/ADR-0014-otp-postgresql-concurrency.md new file mode 100644 index 0000000..103e68d --- /dev/null +++ b/docs/decission/ADR-0014-otp-postgresql-concurrency.md @@ -0,0 +1,29 @@ +# ADR-0014: Serialize OTP persistence transitions in PostgreSQL + +## Status +Accepted for this implementation. + +## Context +Separate verification load/save transactions permit duplicate VERIFIED results and lost failed-attempt increments. Replacement locks on existing challenges cannot serialize the first issuance. Throwing unchecked business rejections inside a transaction would discard intended failed-attempt/expiry transitions. + +## Options considered +JVM locks; optimistic updates with bounded application retry; or PostgreSQL row locks. Choose row locks without automatic business retry. JVM locks do not coordinate independent processes; optimistic retry would complicate quotas and remote TOTP calls. + +## Decision +Verification resolves ownership and acquires quota once, performs any Vault-backed TOTP lookup outside database locks, then invokes an adapter transaction that locks the challenge row, rechecks ownership/current state, applies one domain transition and flushes it. Return the result after transaction commit; only then throw OtpVerificationException for business rejection. Competing successful callers observe USED and receive ALREADY_USED, never a new VERIFIED result. Failed callers serialize increments and terminal state; no stale state is copied into the locked row. + +Issuance upserts a persistent (user_id,purpose) scope row, locks that row, invalidates matching ACTIVE rows, flushes invalidations and inserts the replacement atomically. A partial unique index is a final active-row invariant. No existing row is required for locking. Customer/purpose scopes remain independent. Retain scope rows; cleanup is separate retention work. + +Public save is an insert operation; updates must use the locked mutation contract. This prevents detached stale overwrite. Locks never cover delivery, Vault calls or Kafka publication. The current audit and event calls remain separate commits; durable publication/delivery is separate work and no exactly-once claim is made. + +## Required correctness fixes +Restore nullable consumedAt/deviceBindingHash copying, enforce USED iff consumedAt present, and permit null numeric hash for TOTP only. Redact hashes from diagnostics. These fixes restore existing declared contracts required for persistence flows; do not redesign authentication or domain ownership. + +## Failure behavior and tradeoffs +Lock/query deadlines fail a dependency request; there is no automatic quota replay. Mutation exceptions roll back; replacement insertion failure restores previous ACTIVE state. Expected business rejection commits its domain transition before transport rejection. Ownership is checked again under lock. Persistent scope rows cost storage and require a later retention policy. + +## Verification and references +Independent PostgreSQL transactions/connections compete through barriers: same challenge success/failure, final boundaries, first issuance/replacement, unrelated scopes, expiry, ownership, case and historical-state restoration. +- https://www.postgresql.org/docs/17/transaction-iso.html +- https://www.postgresql.org/docs/17/explicit-locking.html +- https://www.postgresql.org/docs/17/indexes-partial.html diff --git a/docs/guides/postgresql-vault-migration.md b/docs/guides/postgresql-vault-migration.md new file mode 100644 index 0000000..1e0ec13 --- /dev/null +++ b/docs/guides/postgresql-vault-migration.md @@ -0,0 +1,304 @@ +# PostgreSQL and Vault implementation guide + +## Scope and evidence boundary + +This guide records the migration implementation originally developed in `amg-postgresql-vault` and published separately on `amg-postgres-vault`. The checkout already contained substantial owner changes, including the replacement of the retired OTP service by Identity. Those changes were preserved. No commit, push, PR, existing-volume deletion or live-data cutover was performed. + +Available business services are Identity, Wallet, Loan and Collateral. Gateway and Proto have no owned database. Identity contains four package modules in one deployment, so it keeps one owned database. Source inspection found actual Identity JPA challenge/audit adapters, but only repository ports in Loan/Collateral and no Wallet schema, SQL adapter or jOOQ code-generation setup. Configuring their PostgreSQL/Vault infrastructure does not establish persisted financial workflows that do not yet exist. + +In the complete implementation checkout, normal runtime/build/service tests select PostgreSQL. Oracle migrations remain historical input, unchanged, in vendor-separated legacy locations. No running Oracle source or approved historical UUID-to-prefixed-ID mapping was supplied; live-data migration remains untested and blocked on those inputs. Absence of a running database was never treated as evidence that data could be discarded. + +## Publication scope and dependencies + +The migration-only branch intentionally excludes the owner's separate Identity domain work in PR #33 (`amg-identity-otp-domain`), unrelated local edits, retirement of the old OTP module, and the root module switch. Those changes are not attributed to this migration commit. Some pre-existing Identity application/port/bootstrap files also remain outside this branch because they were not changed during the migration. + +The test/image/readiness results below were executed against the complete local implementation checkout, including those owner foundations. They are not claims that this migration-only commit builds independently from current main. Apply/merge the separately owned Identity integration and retire the old module before treating the reproduction commands as a clean-branch result. The PR records this dependency and the existing PMD findings explicitly; do not resolve them by committing someone else's pending work or disabling checks. + +## Versions and decisions + +| Component | Selected/resolved version | Reason | +| --- | --- | --- | +| Java / Spring Boot | 25 / 4.1.1 | Actual parent; no unrelated framework upgrade | +| PostgreSQL | 17.11-bookworm, digest in Compose | Supported PostgreSQL major, reproducible server image | +| Vault Community | 2.1.2, digest in Compose | Self-hosted AppRole, KV v2, database engine, Raft and audit; no Enterprise dependency | +| Flyway / JDBC / Hikari | 12.4.0 / 42.7.13 / 7.0.2 | Actual Boot dependency management | +| Testcontainers / JUnit | 2.0.5 / 6.0.3 | Actual managed dependencies | +| jOOQ | OSS 3.19.15 | Existing Wallet dependency; POSTGRES dialect, no paid distribution | +| Redis / Kafka | 8.0.3-alpine / 4.0.1 | Explicit local tags, no `latest` infrastructure images | + +PostgreSQL 17 support is scheduled through November 2029. Vault 2.1.2's actual LICENSE is BUSL 1.1 with an additional noncompeting internal-production use grant and a later MPL 2.0 change license. Community is not described here as OSI open source. The selected features do not need an Enterprise subscription. Recheck the actual license before offering a competing hosted product. [PostgreSQL support](https://www.postgresql.org/support/versioning/), [Vault 2.1.2 LICENSE](https://github.com/hashicorp/vault/blob/v2.1.2/LICENSE), [HashiCorp licensing FAQ](https://www.hashicorp.com/en/license-faq), [jOOQ editions](https://www.jooq.org/download/). + +Accepted decisions: + +- [ADR 0011: PostgreSQL ownership, types and offline cutover](../decission/ADR-0011-postgresql-service-ownership-and-cutover.md) +- [ADR 0012: Vault classification, AppRole, key handling and license](../decission/ADR-0012-vault-service-secrets-and-bootstrap.md) +- [ADR 0013: Lease/pool replacement and failure policy](../decission/ADR-0013-database-leases-and-pool-replacement.md) +- [ADR 0014: OTP row/scope locking and rejection commits](../decission/ADR-0014-otp-postgresql-concurrency.md) + +## Ownership and credential flow + +| Service | Database | Durable owner / runtime group | Runtime Vault identity and role | Schema owner | +| --- | --- | --- | --- | --- | +| Identity | `payguard_identity` | `identity_owner` / `identity_runtime` | `local-identity` / `local-identity-runtime` | Verification migrations in Identity | +| Wallet | `payguard_wallet` | `wallet_owner` / `wallet_runtime` | `local-wallet` / `local-wallet-runtime` | No business schema supplied | +| Loan | `payguard_loan` | `loan_owner` / `loan_runtime` | `local-loan` / `local-loan-runtime` | Loan migrations | +| Collateral | `payguard_collateral` | `collateral_owner` / `collateral_runtime` | `local-collateral` / `local-collateral-runtime` | Collateral migrations | + +Before: independent Oracle containers and datasource passwords from environment variables. Identity additionally accepted a known development pepper fallback. After: four private PostgreSQL servers with TLS/SCRAM, protected server/bootstrap artifacts, separate migration AppRoles, and short-lived Vault-issued runtime logins. Four servers make each credential manager's CREATEROLE authority local to one service rather than relying on cluster-wide role names to isolate a shared server. + +`prepare.py` creates no-login owner/runtime groups, a separate bootstrap migrator and dedicated `vault_manager`. PUBLIC database and public-schema privileges are revoked. The owner creates schema `payguard`; runtime gets CONNECT, schema USAGE, table SELECT/INSERT/UPDATE/DELETE and sequence USAGE/SELECT through owner default privileges. It gets no schema CREATE, TEMP, role administration or migration-history access. Owner-created functions have no public EXECUTE by default. `SchemaMigrator` revokes audit UPDATE/DELETE and Identity scope DELETE after migrations. Runtime DML permissions do not establish a complete append-only financial adapter. + +Vault's manager has LOGIN/CREATEROLE and ADMIN membership only in this server's runtime/owner groups, with INHERIT/SET disabled. Applications never receive it. `database/rotate-root/local-` replaces its initial password; initial protected passwords are intentionally stale afterward. Manager rotation differs from runtime lease rotation. Losing the manager connection requires operator repair with separately protected PostgreSQL administrative authority, then an explicit rotate-root and issuance test. + +Migration logins inherit the durable owner group and execute `SET ROLE _owner` before DDL, so objects are owned by a no-login role. Revoking an expiring migration login can therefore clean it up without dropping business objects. Runtime logins inherit only their runtime DML group and own no schema objects. PostgreSQL plugin revocation explicitly removes membership and drops the login. [PostgreSQL privileges](https://www.postgresql.org/docs/17/ddl-priv.html), [Vault PostgreSQL engine](https://developer.hashicorp.com/vault/docs/secrets/databases/postgresql). + +The application reads its service-only RoleID/SecretID files, verifies Vault's HTTPS certificate, authenticates with AppRole, reads only its database role and installs a validated Hikari pool. Identity can additionally read `secret/data/local/identity/otp-keys` and its authenticator subtree. Wallet cannot obtain Identity/Loan/Collateral credentials or secrets. Runtime policies cannot issue migration credentials, administer Vault or revoke another role's leases. + +| Existing material | Classification and resulting source | Rotation / missing behavior | +| --- | --- | --- | +| Four runtime database logins | Dynamic database secrets engine, independently scoped runtime AppRoles | Pool generation replacement before expiry; no static fallback | +| Schema migration logins | Separate database roles and operator/build AppRoles | Expiring login owns no objects; durable owner retains schema | +| Vault database manager passwords | Separate operational credential per PostgreSQL server | `database/rotate-root`; never supplied to applications | +| PostgreSQL bootstrap passwords, Vault shares/operator token, AppRole SecretIDs, CA private key | Ignored mode-0600 operator artifacts for rehearsal | Operator-only provisioning, renewal/recovery and protected custody | +| Identity OTP/device-binding HMAC pepper | KV v2 named key ring at `secret/data/local/identity/otp-keys` | Required at startup; versioned/explicit legacy compatibility, bounded cache | +| Customer authenticator seeds | KV v2 customer path under `local/identity/authenticators` | Missing seed returns empty; denial/outage fails as dependency error before attempt mutation | +| Public customer/challenge IDs and AppRole RoleIDs | Identifiers | Preserve exact values; they are not authentication credentials | +| Delivery-provider/API signing credentials | No implemented credential-consuming provider/authentication adapter found | No fictitious secret path or integration added; delivery defaults remain synthetic/mock | + +Runtime and migration policies explicitly permit lookup/renew/revoke of their own token and do not inherit Vault's default policy. Lease renewal/revocation is constrained to the runtime role prefix. The operator policy enumerates its four migration credential paths; Vault ACL glob syntax must not be mistaken for arbitrary middle-of-path wildcard matching. + +## Schema and type mapping + +| Service / source type | PostgreSQL representation | Preserved meaning and limits | +| --- | --- | --- | +| Identity current challenge/customer IDs | `varchar(26)` / `varchar(255) COLLATE "C"` | Opaque `otp_`/`cus_` values, exact case and bytes; no trimming/lowercasing/UUID conversion | +| Identity audit `RAW(16)` / Java UUID | native `uuid` | UUID audit identity remains UUID; canonical RAW hex conversion uses network byte order, verified by synthetic vector | +| Identity attempts / limits | `integer` | Attempts bounded 0..max; ACTIVE cannot be exhausted; existing domain transitions retained | +| Identity hashes / enum names | bounded `varchar` | TOTP code hash nullable; device binding nullable; RANDOM_NUMERIC needs a nonblank hash | +| Identity creation/expiry/consumption/retention | `timestamptz(6)` | Instant semantics; consumed nullable except USED; expiry and consumption checks enforced | +| Loan account/collateral/event IDs | native `uuid` | Cross-service collateral identifier remains opaque reference with no cross-database foreign key | +| Loan amount / annual rate | `numeric(28,8)` / `numeric(20,12)` | Same decimal precision/scale; no floating point | +| Loan accrual day | `date` | Business LocalDate retained | +| Loan version / generated audit/outbox ID | `bigint` / bigint identity | Exact integer representation; generated IDs remain numeric | +| Collateral IDs / loan reference | native `uuid` | Owned valuation FK retained; no FK into Loan database | +| Collateral amount/value / FX / LTV thresholds | `numeric(28,8)` / `numeric(28,12)` / `numeric(10,8)` | Existing exact scales and currency column retained | +| Loan/Collateral CLOB payload | `text` | Outbox content retained as text, no invented JSON schema | +| Loan/Collateral timestamps | `timestamptz(6)` | Nullable retention/publication stays nullable; no original-offset preservation claim | +| Wallet Money | Existing BigDecimal + Currency | Domain currency fraction digits/HALF_EVEN unchanged; no database mapping exists to migrate | + +PostgreSQL stores an instant for timestamptz, not the original offset. Precision is microseconds; arbitrary Java nanoseconds are not preserved. Identity explicitly truncates application timestamps to microseconds, including the time re-evaluated after acquiring a verification lock. The offline converter also explicitly truncates source precision. Review/reconcile any historical sub-microsecond data before approving cutover. + +Oracle's empty-string-as-null behavior is not reproduced automatically. Existing domain validation and null fields are reviewed explicitly; PostgreSQL constraints reject important blank hashes. Unquoted names remain lower-case SQL identifiers. SYS_GUID defaults become PostgreSQL UUID generation only for actual UUID audit identity. PL/SQL is preserved historically rather than translated mechanically. There are no Oracle native business queries, jOOQ generated statements or code-generation configuration in the available runtime source. + +## Flyway history and OTP concurrency + +PostgreSQL Identity V1 creates the current string-ID shape directly. V2 adds persistent customer/purpose scope rows, backfills existing scopes and creates a partial unique ACTIVE index. Loan/Collateral each have PostgreSQL V1. These are new vendor histories: Oracle checksums/history remain separate and are never copied into PostgreSQL as proof of migration. + +Normal application Flyway is disabled. Provision the owner/schema, run separate migration tooling, then start the service; Hibernate validates Identity mappings instead of creating tables. Integration tests enable real Flyway before Hibernate validate. `flyway.clean` stays disabled. Duplicate historical ACTIVE challenges cause V2 to fail transactionally; the migration never deletes or guesses which challenge to retain. + +Verification uses a PostgreSQL row lock inside a bounded REQUIRES_NEW adapter transaction. The adapter restores current state, applies the callback, flushes and commits. The application throws its business rejection only after that return, so failed attempts, final lock and expiry transitions persist. One concurrent caller gets fresh VERIFIED; the competitor sees USED and gets ALREADY_USED. Failed attempts serialize; successful and failed final allowed attempts each produce a valid terminal transition. Lock/connection failures propagate as persistence/dependency failures and are not reported as verification success or automatically retried. + +Issuance upserts and locks an enduring `(user_id,purpose)` scope before invalidating locked ACTIVE rows and inserting a replacement. This also serializes first issuance when no challenge exists. The partial unique index is a final invariant. Other customers/purposes are unaffected. Insert-only `save` prevents merging stale snapshots over current state. + +Quota acquisition and Vault-backed TOTP verification occur outside the database lock. The locked mutation rechecks ownership, current state and current time. Audit, delivery and Kafka remain separate side effects; their existing failure/commit gap is documented as separate work. No exactly-once delivery, stored verification response idempotency or database/broker atomicity is claimed. + +## Persistent local setup: exact commands + +Prerequisites: clean source checkout with JDK 25 selected in JAVA_HOME/PATH, Docker/Compose, Python 3 and OpenSSL. Use a disposable rehearsal host. Existing `.local/payguard` or named volumes may contain data; preparation refuses to overwrite artifacts and no command below deletes volumes. + +```bash +./mvnw -B -ntp -pl payguard-identity-service,payguard-wallet-service,payguard-loan-service,payguard-collateral-service -am -DskipTests package dependency:copy-dependencies -DincludeScope=test +python3 scripts/local/prepare.py +docker compose --env-file .env.example up -d --wait postgres-wallet postgres-loan postgres-collateral postgres-identity vault +python3 scripts/local/vault_bootstrap.py +python3 scripts/local/migrate.py identity +python3 scripts/local/migrate.py loan +python3 scripts/local/migrate.py collateral +python3 scripts/local/verify_vault.py +python3 scripts/local/verify_applications.py +``` + +Skipping tests in the first command builds helper classpaths; it does not claim verification. Run the suites below. Wallet has no schema migration command because no business schema was supplied. + +Preparation creates mode-0600 files under a mode-0700 ignored directory: synthetic random bootstrap passwords, CA/private keys and per-server TLS certificates. Both application-to-database and Vault-to-database URLs use `sslmode=verify-full` with the local CA. The CA/certificates expire after 30 days; this is explicit local rehearsal material, not production certificate lifecycle. TLS remains enabled on persistent Vault/Raft. Testcontainers tests use isolated disposable test passwords/plain local JDBC; they are not the non-disposable design. + +`PostgreSqlTls` validates the configured single-host JDBC URL before any connection. It requires an absolute CA path and exactly one `sslmode=verify-full`; duplicate/encoded overrides, URL passwords, userinfo and custom SSL factories/hostname verifiers are refused. Multi-host failover is not implemented by this local topology. Owner default EXECUTE is revoked globally for future functions because a per-schema revoke cannot remove PostgreSQL's global PUBLIC default grant. + +Vault startup remains sealed until initialization/unseal. Bootstrap initializes three shares with a two-share threshold, stores them in protected local operator artifacts, unseals and waits for leader readiness. It enables KV v2, the database engine, AppRole and a file audit device, provisions independent service policies, rotates manager passwords, creates a separately scoped orphan operator token and revokes/removes the initial root token. Apps mount only their AppRole directory and CA. Administrative/unseal/operator artifacts are not mounted into applications or copied into Docker images. + +RoleIDs are public identifiers; SecretIDs/tokens/unseal shares are secrets. Provisioning obtains a 60-second response-wrapped SecretID and unwraps once into a protected service file; service SecretIDs expire after one hour. Runtime tokens have 20-minute renewable leases with a four-hour maximum. The operator coordinates fresh files atomically before SecretID expiry: + +```bash +python3 scripts/local/refresh_approle.py +# Run under a supervised local operator process for a long rehearsal: +python3 scripts/local/refresh_approle.py --watch +``` + +The watcher renews its operator token and refreshes runtime files every 20 minutes. It stops on operator failure. It is an operator process, not application administration; production needs actual protected supervision/workload identity and alerting. Existing application tokens continue until their valid limits, then reauthenticate using the newest file. If refresh stops and material expires, startup/re-authentication fail; no default credential is accepted. Vault's operator token also has a server-imposed maximum lifetime; recover/re-provision before that boundary. + +After a Vault restart, rerun bootstrap to unseal using the protected local shares. If the operator token expired/lost, explicit threshold-key recovery is available: + +```bash +python3 scripts/local/vault_bootstrap.py --recover-operator +``` + +This uses Vault's threshold recovery protocol, creates an independent operator token and revokes the temporary recovery root token. It requires protected unseal shares; no long-lived root token is put in `.env`. Local share co-location is a rehearsal convenience; production needs separate custody and recovery storage. + +Ordinary bootstrap is repeatable: it retains an existing database engine configuration and rotated manager password, retains existing OTP key material, refreshes policies/AppRoles and never reinitializes Raft or business data. `--diagnostics` displays only failed bootstrap API paths and HTTP codes, never response bodies or credentials. After operator renewal fails, use explicit recovery; elapsed time is not authorization to regenerate or delete data. + +Applications start only after migration and secret provisioning. Set `PAYGUARD_LOCAL_UID`/`PAYGUARD_LOCAL_GID` in a public `.env` to the host owner of protected service files if different from 1000. Keep `PAYGUARD_ENVIRONMENT` aligned with independently provisioned environment policies/paths; the included bootstrap is explicitly local. + +```bash +docker compose --env-file .env.example --profile applications up -d --build +``` + +This is a local start command, not evidence that every financial workflow exists. Provider integration/configuration and missing repository adapters can block application business flows; inspect actual startup/readiness. Identity readiness includes db, Vault authentication and valid OTP keys. No service reports persistence readiness merely because a scheduler is alive. + +`verify_applications.py` instead boots each actual packaged JAR on the private network with only its own service AppRole directory and CA. It polls HTTP readiness inside the temporary container, checks secret markers in captured logs and removes only its own application probe containers. It does not mount administrative material, expose a public API, delete volumes or simulate a missing financial adapter. All four packaged services reached readiness UP locally. This proves startup/wiring, not Wallet ledger or Loan/Collateral repository flows. + +## Pool, key and failure lifecycle + +`LeasedDataSource` acquires a credential lease (default one hour, max four hours), validates a Hikari generation and switches future borrowers at half TTL. Old idle connections are evicted; checked-out old transactions drain for up to ten seconds, also bounded by lease safety deadline. Replacement uses a fresh credential rather than assuming Spring Cloud Vault automatically rotates/reconfigures a DataSource at max_ttl. It retries failed replacement once per second while old credentials remain valid, limits retained generations to eight and rejects borrowers at TTL minus two seconds. Lease timing starts conservatively before the issuing request. Old generations close/revoke after drain/deadline. [Spring Cloud Vault limitation](https://docs.spring.io/spring-cloud-vault/reference/secret-backends.html). + +Long transactions beyond drain/deadline can fail. PostgreSQL runtime statement/lock/idle-transaction timeouts are ten seconds. No transaction is replayed automatically: callers need their actual business idempotency/recovery contract. The pool has four maximum connections per generation and briefly overlaps generations. No capacity measurement is claimed. + +Revoking a PostgreSQL role stops new logins; existing established sessions can remain valid. Tests demonstrate this. Application-owned generations close after the bounded drain, but emergency external revocation does not imply immediate termination of every session. If forced termination is required, operators need an explicitly authorized database session-termination procedure. + +The pool validates CONNECT and schema USAGE on startup and each borrow, so an established session passing `SELECT 1` after role revocation cannot masquerade as a usable runtime connection. Failed borrowing requests asynchronous credential/pool replacement, with a one-second maintenance interval and bounded network/acquisition calls; legitimate work resumes only after validation. It does not retry the caller's transaction. Forced revocation may break in-flight DML even though the session itself survives; detection of every arbitrary table-grant configuration change is outside this check. + +Vault outage: existing valid database credentials work only to their safety deadline; new issuance/authentication fails, and readiness is down. Database outage: connection acquisition fails within the pool timeout, no verification succeeds, and recovery allows fresh legitimate work. Invalid required keys, missing provisioning, denied policy and expired authentication have sanitized dependency errors. There is no known password/pepper fallback. Secret values are omitted from errors, pool names and snapshot/key-ring diagnostics. + +Identity KV key ring has `current`, `keys` and an optional explicit `legacy`. New hashes are `keyId:salt:digest`; key material cannot change under an existing ID. To rotate, add a new random named key, select it as current and retain the previous key for at least max(random OTP TTL, TOTP TTL) plus ten seconds after its last use. Keep an explicit legacy key until all unversioned stored challenges/device bindings are expired or reconciled. Never guess the old key. Refresh happens every five seconds outside SQL locks; valid cached keys have a bounded five-minute outage lifetime, then fail closed. Removing a needed key early is rejected; do not overwrite pepper material under the same name. + +The explicit legacy alias cannot be reassigned to another key by a live refresh: unversioned hashes carry no information to select that different key. Removing the alias is also rejected during the compatibility window, even if the old key material remains in the ring. Removing key material and removing the alias are both retirement operations. + +Retirement is an operator-coordinated decision across instances, not a distributed retention registry. Allow the five-minute cached-key outage window plus maximum challenge TTL plus ten seconds after switching the Vault current key, and confirm all issuers have refreshed/stopped using the old key before removal. Local refresh guards reject unsafe removal for already running instances; newly started instances cannot recover key material removed from Vault. Failed refresh sets the OTP key readiness contributor DOWN while still-valid cached keys remain bounded; startup requires valid provisioned material. Preserve legacy key provenance across restart rather than guessing from a hash. + +OTP pepper rotation, AppRole token renewal, PostgreSQL login replacement and authenticator seed re-enrollment are separate operations. Seed rotation is not implemented as enrollment/recovery. Vault stores recoverable TOTP seeds in encrypted storage with scoped KV reads. No existing business field needing a new recoverable field-encryption adapter was identified; no Transit/card vault or indiscriminate column encryption was invented. Local TLS and Vault storage protection do not implement PostgreSQL volume/backup encryption; hosting must supply that layer. + +## Audit, backup and recovery + +Persistent Vault audit output is `/vault/audit/audit.json` on the protected `vault_audit` volume. Default HMAC protection remains enabled. The test runner verifies allowed/denied accesses and hashed credential values without printing raw audit output. Reading audit files belongs to operators; paths and access metadata remain sensitive even when values are protected. + +Vault can reject requests if it cannot write to any enabled audit device. This single-device local design therefore requires disk-capacity monitoring, retention/rotation and protected backups before production. No automatic audit retention, HA or auto-unseal was implemented. Do not solve disk failure by disabling audit or raw-value protection. PostgreSQL error-statement/parameter logging is suppressed to avoid CREATE ROLE password leakage. + +For a local backup with an installed compatible Vault CLI, supply the operator token via protected input; never print it or put it in command-line arguments. Set only public TLS settings normally: + +```bash +export VAULT_ADDR=https://127.0.0.1:8200 +export VAULT_CACERT="$PWD/.local/payguard/ca.crt" +umask 077 +read -r -s -p 'Operator token: ' VAULT_TOKEN +export VAULT_TOKEN +vault operator raft snapshot save .local/payguard/operator/raft.snapshot +unset VAULT_TOKEN +``` + +Back up PostgreSQL with separately authorized owner/admin tooling, protect/encrypt exports, retain the approved ID mapping, and store Vault recovery shares separately from encrypted snapshots. Rehearse restore into isolated infrastructure with the original key/certificate custody. Snapshot restore replaces Vault state and is an explicit operator recovery action, not an automatic command in setup. These backup/restore CLI steps were documented, not executed; live hosting/encryption/custody remain production-dependent. + +## Existing-data rehearsal and cutover + +1. Establish an authorized immutable Oracle export and inventory its vendor migration history, rows, numeric totals, UUID byte order, empty strings and timestamp precision. Export no secrets into ordinary logs. Keep Oracle histories unchanged. +2. Approve a persistent mapping artifact for every legacy customer/challenge identifier. Do not create new customer identities per request. Already-current prefixed IDs pass through exactly; mapping is still a provenance-reviewed artifact. +3. Freeze writes for an offline cutover. Preserve source export, mapping checksum, row counts and representative records. Do not copy Oracle `flyway_schema_history` into PostgreSQL. +4. Provision an empty owned PostgreSQL destination and apply its own Flyway migrations using the separate migration identity. +5. Convert a protected export with `scripts/migration/convert_identity_export.py`. Its JSON input has `challenges` and `audit` arrays named after current database columns. The mapping has `approved_by`, `approval_reference`, `customers` and `challenges` maps keyed by exact source identifiers. Actual audit UUID/RAW hex is preserved as UUID; timestamps require offsets and explicitly truncate to microseconds. Missing mapping, duplicate identity and orphan audit records are refused. +6. Import parents before children with an explicit command. The importer locks/checks an empty destination, inserts in one transaction, relies on constraints/ACTIVE uniqueness, verifies audit subject ownership, row counts and every supplied field, then commits. Populated destinations and reconciliation errors are refused; failures roll back without truncation. + +```bash +python3 scripts/migration/convert_identity_export.py \ + .local/payguard/operator/source-export.json \ + .local/payguard/operator/approved-id-mapping.json \ + .local/payguard/operator/converted-identity.json +python3 scripts/local/migrate.py identity \ + --import-identity .local/payguard/operator/converted-identity.json +``` + +The mapping/export must be supplied by an authorized operator; there is no live exporter credential or invented customer mapping. Conversion outputs are exclusive-create/mode 0600 and include mapping SHA-256 plus counts/provenance. Verify full source/destination counts, orphan queries, case-sensitive identity/reference equality, challenge state/attempts, expiry/consumption/retention and sampled audit records independently before opening traffic. + +Loan/Collateral tests rehearse their supplied schemas and representative exact numeric/UUID/date/time/null/idempotency/FK records. Live Oracle export/import and financial balance reconciliation were not executed. Wallet lacks a schema/adapter, so no Wallet ledger migration or platform-wide financial concurrency claim is made. Before a real Loan/Collateral cutover, supply authorized exports, import in owned parent/child order, preserve sequence identities or advance sequences deliberately and reconcile principal/outstanding/accrued/valuation totals and currency grouping. Those live tooling decisions remain blocked on actual source data and missing adapters. + +Before PostgreSQL accepts writes, rollback can redirect to the frozen Oracle source after verifying no target-only writes occurred. After it accepts writes, rollback requires reconciling/reverse-migrating target changes and side effects; retaining an Oracle volume alone is not sufficient. Prefer rehearsing recovery forward with backups and correction procedures before choosing a reverse cutover. Do not enable clean, drop volumes, truncate tables or regenerate identities to bypass a failed rehearsal. + +## Change ledger + +| Repository-relative path | Before → resulting behavior | Reason / evidence | +| --- | --- | --- | +| `pom.xml` | Oracle dependency management removed; separate PostgreSQL Flyway plugin configured | Managed compatible versions; Java/Spring unchanged; helper library path exercised, alternate Maven plugin path not yet exercised | +| `payguard-{identity,loan,collateral,wallet}-service/pom.xml` | Oracle JDBC/Flyway/test containers → PostgreSQL and actuator | Actual service persistence approach retained; compilation and real database suites | +| `payguard-{identity,loan,collateral,wallet}-service/src/main/resources/application.yaml` | Oracle/env runtime passwords → PostgreSQL/Vault pool, explicit readiness | No pepper/password fallback; runtime Flyway disabled; separate migration path | +| `payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql` | New vendor history in final prefixed-ID shape | UUID audit/string identity/timestamp/check constraints | +| `payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql` | Scope-lock rows and partial ACTIVE index | First issuance race and populated V1 upgrade/rejection rehearsal | +| `payguard-{loan,collateral}-service/src/main/resources/db/migration/postgresql/V1__*_schema.sql` | Oracle types/defaults → PostgreSQL exact equivalents | Real Flyway and exact numeric/UUID/date/time/constraint tests | +| `payguard-{identity,loan,collateral}-service/src/main/resources/db/legacy/oracle` | Original scripts relocated unchanged | Preserve deployed Oracle histories/guards; never normal Flyway locations | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/port/OtpRepository.java` | Separate load/save → locked mutation contract; save inserts only | Cross-process state freshness/explicit commit boundary | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java` | Unserialized writes → row/scope locks and ordered replacement | Real concurrent verification/issuance and rollback tests | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java` | Ordinary queries → explicit locked current-row/ACTIVE queries | PostgreSQL transactions | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java` | Stale verification save → committed result boundary, remote calls outside locks, microsecond/current locked time | Rejection commits, final attempts, ownership and expiry tests | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/domain/model/OtpSnapshot.java` | Nullable lifecycle contradictions → conditional consumed/binding validation and safe copies | Needed to restore real persisted states; no unrelated domain redesign | +| `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security` | Known pepper/unavailable seed → named HMAC ring and scoped Vault adapter | Real seed/rotation tests; enrollment remains absent | +| `payguard-shared/src/main/java/infrastructure/vault/VaultClient.java` | No client → verified TLS AppRole, bounded auth/renew/read calls, sanitized errors | Token renewal/re-authentication and policy/outage tests | +| `payguard-shared/src/main/java/infrastructure/vault/LeasedDataSource.java` | Static pool → validated credential/pool generations and bounded drain | Real short TTL/maxTTL, held transaction and outage tests | +| `payguard-shared/src/main/java/infrastructure/vault/VaultDatabaseAutoConfiguration.java` and `src/main/resources/META-INF/spring` | No shared wiring → infrastructure auto-configuration/readiness | Actual Spring wiring test | +| `payguard-shared/src/main/java/infrastructure/vault/SchemaMigrator.java` | No separate executable → real Flyway as owner role | Actual local Identity/Loan/Collateral migration commands and post-migration privilege restrictions | +| `payguard-shared/src/main/java/infrastructure/vault/IdentityDataImporter.java`, `scripts/migration` | No rehearsal path → approved mapping, empty transactional import/reconciliation | Synthetic Python/real PostgreSQL tests; no live source claim | +| `scripts/local/{common,prepare,vault_bootstrap,refresh_approle,migrate,verify_vault,verify_applications}.py` | Environment passwords/manual assumptions → protected provisioning and actual dependency/application runners | Local TLS initialization, recovery, manager rotation, credential tests and four real readiness probes | +| `payguard-identity-service/.../customer/domain/value_object/PrefixedIdValidator.java`, `package-info.java` | Missing referenced validator → exact existing prefix/22 ASCII character validation and package nullness | Current checkout failed compilation without this helper; existing typed-ID tests pass, no accessor/ID changes | +| `docker-compose.yaml`, `infrastructure/{postgresql,vault}`, `.env.example`, `.dockerignore` | Oracle/dev configuration → private TLS PostgreSQL and persistent non-dev Vault | No existing volume touched; secret directories excluded from images | +| `payguard-{identity,loan,collateral}-service/src/test` | Oracle/gated or datasource-excluding fixtures → actual PostgreSQL/Flyway tests | No opt-in test flag; disposable test profile is explicit | +| `.github/workflows/_database-security.yml`, `pipeline.yml`, `_publish.yml` | No Vault lifecycle CI → independent security suite, four real application probes and SHA-tagged service image builds | Workflow calls real test/migration/runner commands; remote CI execution not claimed | +| `README.md`, `payguard-identity-service/{README,ARCHITECTURE}.md`, `docs/decission/ADR-0011..0014` | Outdated Oracle/unresolved claims → implemented scope and explicit limits | Source-based handover; no compliance/production claim | + +## Verification commands and results + +Use JDK 25; a default older JDK fails `release version 25 not supported` and is not an application regression. + +```bash +./mvnw -B -ntp -pl payguard-identity-service,payguard-loan-service,payguard-collateral-service -am \ + -Dtest=PostgreSqlTlsTest,PostgresqlMigrationRehearsalTest,SanitizedDependencyErrorTest,OtpInfrastructureIntegrationTest,OtpApplicationContractsTest,LoanServiceApplicationTests,CollateralServiceApplicationTests \ + -Dsurefire.failIfNoSpecifiedTests=false clean test +python3 -m unittest discover -s scripts/migration -p 'test_*.py' +./mvnw -B -ntp -pl payguard-identity-service,payguard-wallet-service,payguard-loan-service,payguard-collateral-service -am -DskipTests package dependency:copy-dependencies -DincludeScope=test +python3 scripts/local/verify_vault.py +python3 scripts/local/verify_applications.py +./mvnw -B -ntp verify +``` + +Do not run clean builds concurrently with the Java Vault runner: it reads shared target classes/dependencies and writes reports there. The runner stops/restarts only the isolated Identity PostgreSQL container, seals/unseals only the local rehearsal Vault and restores its shortened role/key fixtures. It must never point to a production environment. + +Baseline full verify failed existing shared import formatting before tests. Baseline Identity diagnostic run executed 53 tests with 4 failures, 25 errors and 8 skips. Required nullable lifecycle fixes made persisted flows testable. An earlier verification executed 76 tests with two domain-contract failures; the current checkout contains owner changes to aggregate identity hashing and pre-creation validation, and those regressions now pass. They are recorded as historical findings, not unresolved defects. + +Observed evidence as of this checkpoint: + +- Current normal `./mvnw -B -ntp verify`: **95 tests passed, zero failures/errors/skips**. Shared 4, Proto 1, Gateway 2, Wallet 1, Loan 5, Collateral 4, Identity 78. Exit code **1** because Identity PMD reports six existing `AvoidFieldNameMatchingMethodName` findings; no checks were disabled. Log `/tmp/payguard-final-current-verify.log`. +- All modules before Identity passed normal Spotless/Checkstyle/PMD/CPD/SpotBugs. Independent Identity SpotBugs passed with zero bug/error instances; its six typed-ID PMD naming findings remain separate work. Package build with tests skipped succeeded for all four service JARs; it is not counted as test evidence. +- Identity real PostgreSQL suite: 14 passing tests, including competing verification, failed attempts, first issuance/replacement, final boundaries, restored states, ownership, case and rollback. Application contracts: 11 passing tests. Loan/Collateral tests exercise exact schema amounts/UUID/time/null/constraints; absent persisted financial flows remain unproved. +- Synthetic export conversion: 3 Python tests passed, zero skipped. +- Shared TLS parameter guard and secret-error redaction: one passing test each; populated V1/V2/import rehearsal: two passing tests. Expected duplicate ACTIVE upgrade refusal rolls back without deleting records. +- All four actual packaged applications reached HTTP readiness UP using dedicated AppRole files and verified PostgreSQL TLS (`/tmp/payguard-application-readiness.log`), and probe containers were stopped/removed. No financial API-flow claim follows from startup. +- Initial/recovered and ordinary repeat bootstrap, atomic SecretID refresh and all three local migration commands succeeded with retained volumes. Identity V1/V2, Loan V1 and Collateral V1 have their own Flyway histories; migration token revocation completed. +- Previous real Vault run XML contained 15 passing tests, zero skips (~115 seconds). The restored runner first failed 12 shared setup cases because the operator migration path used an invalid middle glob; explicit per-service paths corrected that defect. +- Final `python3 scripts/local/verify_vault.py` completed with exit **0** after legacy-alias hardening: **15 tests passed, zero failures/errors/skips**, approximately 116 seconds, including four runtime DML/default grants, denied DDL/admin/cross-service reads, ten-second statement defaults, real renewal/revocation, continuous pool replacement beyond 45-second max TTL, held transactions, token renewal/re-authentication, manager rotation, seal/unseal, database stop/start, forced revocation recovery, real scoped authenticator lookup and HMAC rotation. This includes refusal to reassign/remove the legacy alias prematurely while retaining old key material. Allowed/denied audit trace and plaintext secret-marker checks passed (`/tmp/payguard-vault-final-alias-tests.log` and `payguard-shared/target/vault-test-reports/TEST-junit-jupiter.xml`). +- Actual Identity Dockerfile build completed with exit **0** (`docker build -f payguard-identity-service/Dockerfile -t payguard/identity-service:rehearsal .`, `/tmp/payguard-identity-docker-build.log`). The image uses pinned Java 25 builder/runtime digests; protected local artifacts are excluded from build context. Tests skipped inside the image build are not counted as executed tests. +- Actual Wallet/Loan/Collateral Dockerfile builds completed with exit **0** (`docker compose --env-file .env.example --profile applications build wallet-service loan-service collateral-service`, `/tmp/payguard-financial-docker-build.log`). Thus all four service Dockerfiles built locally. CI tags output images with the source SHA; Compose derives local image names from its build configuration. No image was pushed or deployed. +- A fresh disposable PostgreSQL initialization using the current `prepare.py` SQL succeeded; runtime CONNECT/USAGE were allowed and schema CREATE/default function EXECUTE denied. The first proof waited on the temporary Unix bootstrap server and failed before initialization finished; a TCP readiness check corrected the fixture. This test used new temporary artifacts/storage with no network and stopped its own container; existing volumes were untouched (`/tmp/payguard-fresh-init-proof.log`). +- A final application probe after Vault/database recovery again reached readiness UP for all four services, scanning both stdout and stderr for secret markers (`/tmp/payguard-application-readiness-final.log`, exit 0). Rehearsal PostgreSQL/Vault containers retain their volumes; temporary application/provisioning probes were stopped/removed. +- After the final legacy-alias guard, normal verification again passed all 95 tests and stopped only at the same six PMD findings. Identity SpotBugs again passed with zero bugs/errors; its actual Docker image was rebuilt successfully from the final Java source. The final Identity-only application probe also reached readiness UP (`python3 scripts/local/verify_applications.py --service identity`, `/tmp/payguard-identity-final-readiness.log`, exit 0). + +Surefire reports are under each module's `target/surefire-reports`; the Vault runner writes `payguard-shared/target/vault-test-reports`. Dedicated CI invokes both paths without an unset environment gate. No remote GitHub run, live-data migration, production outage/restore or load/capacity measurement is claimed. + +## Remaining Oracle references and limits + +The migration-only publication also retains the original tracked `payguard-otp-service` and root module selection, because their retirement/switch was owner work excluded from this commit. They still reference Oracle until that separate integration is applied. The list below describes the complete implementation checkout after that integration, not a claim that the migration-only branch has already removed those owner-controlled paths. + +- `payguard-{identity,loan,collateral}-service/src/main/resources/db/legacy/oracle/*.sql`: exact historical vendor migrations and the legacy populated-data guard. +- `OtpInfrastructureIntegrationTest.archivedOracleMigrationRetainsPopulatedDataGuard`: reads the legacy guard as text, without an Oracle engine. `PostgresqlMigrationRehearsalTest` mentions the unavailable Oracle source in Javadoc; it executes PostgreSQL only. +- Historical ADRs, `CLAUDE.md`, migration ADRs/work log/this guide: original context, source-data handling and supersession rationale; no normal runtime Oracle configuration. +- `payguard-collateral-service/.../domain/PriceOracle.java` and its callers: price-feed domain terminology, unrelated to Oracle Database. +- Owner-retired `payguard-otp-service` tracked deletions and ignored stale `target/` output are not available runtime modules and are not migration evidence. + +Full remaining-reference search is `rg -n -i 'oracle|ojdbc|varchar2|sys_guid|gvenzl|pl/sql' --glob '!**/target/**' --glob '!.local/**'`. Treat matched market-oracle names and URLs to Java documentation separately from JDBC/database references. All original Oracle script bytes are retained; no clean/truncation command is added. + +Implemented: available PostgreSQL dependency/config/schema paths, Identity database concurrency, service-scoped Vault client/pool/secrets, persistent local TLS/SCRAM/Raft/audit provisioning, separate migrations and reviewable rehearsal tooling. Locally tested scope is listed above and in the work log. Remaining quality gate: six existing typed-ID PMD naming findings. Production-dependent: certificate/SecretID/operator supervision, HA/auto-unseal, encrypted volume/backup hosting, custody, audit retention, capacity and disaster recovery. Not implemented: missing financial repositories, authenticator enrollment/recovery, external auth/Keycloak, durable event/delivery recovery or a new IAM system. + +Review [separate GitHub Issue drafts](../issues/postgresql-vault-follow-up.md) for acceptance criteria. Security references describe the PayGuard design, not invented internal company deployments: [OWASP secrets management](https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html), [cryptographic storage](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html), [Vault AppRole](https://developer.hashicorp.com/vault/docs/auth/approle), [KV v2](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2), [Transit](https://developer.hashicorp.com/vault/docs/secrets/transit), [Stripe customer API-key guidance](https://docs.stripe.com/keys-best-practices). diff --git a/docs/issues/postgresql-vault-follow-up.md b/docs/issues/postgresql-vault-follow-up.md new file mode 100644 index 0000000..820210c --- /dev/null +++ b/docs/issues/postgresql-vault-follow-up.md @@ -0,0 +1,55 @@ +# Separate GitHub Issue drafts + +These are reviewable drafts, not issues posted to GitHub. They describe existing work outside the database/secrets migration scope. + +## Keep OTP identity hashing stable across state changes + +**Current status:** Historical finding, resolved in the current checkout's owner domain changes. The normal verification on 2026-10-12 passed the existing stable-hash and collection-membership regressions. Do not open this as an unresolved issue without a new failing reproduction. + +**Original problem:** `OTP.equals`/`hashCode` included mutable snapshot state. `OTPTest.aggregateHashCodeRemainsStableAfterStateTransition` failed when consumption changed state; a stored hash collection key could become unreachable. + +**Acceptance criteria:** Decide aggregate identity semantics explicitly; ensure equality/hash-code consistency and stable collection membership before/after attempts, consumption, lock and expiry. Preserve ValueObject structural comparison and typed ID semantics. Run the existing failing regression and collection membership tests. + +## Decide verification timestamps before challenge creation + +**Current status:** Historical finding, resolved in the current checkout's owner domain changes. Both pre-creation rejection regressions passed normal verification on 2026-10-12, including wrong-code input without state mutation. + +**Original problem:** `OTPTest.verificationBeforeCreationIsRejectedWithoutMutation` expected `IllegalArgumentException`; validation threw `OtpConsumedTimeOutsideValidityPeriodException`. This was a domain/API rejection contract decision, separate from PostgreSQL microsecond precision. + +**Acceptance criteria:** Define which public inputs can precede creation and the exact rejection type/result. Rejection must not mutate attempts/status/consumption. Add matching application/REST boundary evidence and resolve the existing test without weakening its invariant. + +## Implement missing financial persistence adapters + +**Problem:** Wallet has no SQL schema/adapter/code-generation configuration; Loan and Collateral have schemas and repository ports but no concrete persistence adapter/entity. PostgreSQL schema tests cannot establish persisted financial workflows. + +**Acceptance criteria:** Implement owning adapters under a separately approved scope. Use OSS jOOQ for Wallet, retain approved domain ownership, explicit transactions, exact amounts/currency, operation idempotency and cross-process competing posting/hold/repayment/valuation tests. No shared tables, cross-service foreign keys or runtime DDL privileges. Generate jOOQ sources reproducibly only once a real schema exists. + +## Make Redis quota increment and initial expiry atomic + +**Problem:** Existing INCR and first-use EXPIRE are separate commands. A crash between them can leave a permanent quota key. + +**Acceptance criteria:** Define an atomic bounded operation and outage policy. Verify concurrent limits, first-use TTL, interrupted requests and recovery against real Redis. Keep per-challenge attempt limits separate and do not repeat quota acquisition when retrying database work. + +## Recover audit, broker publication and delivery after committed challenge state + +**Problem:** Challenge transactions commit before synchronous audit/Kafka/delivery calls. Failure can leave committed state without its expected side effect; no durable recovery mechanism is implemented. + +**Acceptance criteria:** Define durable event/audit/delivery intent, consumer deduplication, ordering and retry ownership. Test failures before/after commit, duplicate deliveries and restart recovery. Protect any recoverable delivery code, restrict access and purge it on a bounded schedule. Do not claim exactly-once publication. + +## Define authenticator enrollment, seed recovery and authorization + +**Problem:** Vault lookup now reads provisioned customer-scoped seeds, but no approved enrollment/recovery or trusted caller/tenant authorization boundary exists. Synthetic seeds only demonstrate adapter behavior. + +**Acceptance criteria:** Approve provider-owned MFA versus PayGuard-owned authenticator use cases, authenticated enrollment/recovery, seed access policy, retention and seed rotation/re-enrollment behavior. Do not introduce an unauthenticated seed endpoint or resurrect step-up tokens. Keep missing enrollment separate from dependency failure. + +## Establish production secret and infrastructure operations + +**Problem:** Local rehearsal is single-node Raft with manually held unseal shares, a local CA, host-level operator refresh and private Docker databases. It does not implement production HA, managed auto-unseal, disk encryption or backup hosting. + +**Acceptance criteria:** Select the real hosting/workload identity and operator custody boundaries, certificate renewal, audit retention/capacity alerting, encrypted backup/restore rehearsal, least-privilege operator provisioning, SecretID refresh supervision and resource limits. Exercise disaster recovery and key retention before claiming production readiness. + +## Resolve existing typed-identifier PMD naming findings + +**Problem:** Identity's existing AccountId, MembershipId, OtpAuditId, OtpId, RegistrationId and TenantId each use a `value` field and `value()` accessor. PMD flags these six `AvoidFieldNameMatchingMethodName` findings. The migration preserves their actual accessor/API contracts. + +**Acceptance criteria:** Decide a repository-wide convention or a justified precise analysis configuration for these value objects. Preserve serialized prefixed values and public accessors; do not silence broader checks or rename CustomerId.getValue incidentally. Run normal PMD/CPD/SpotBugs and full verify while retaining the passing domain regressions. diff --git a/docs/tasks/postgresql-vault-migration.md b/docs/tasks/postgresql-vault-migration.md new file mode 100644 index 0000000..19639f8 --- /dev/null +++ b/docs/tasks/postgresql-vault-migration.md @@ -0,0 +1,92 @@ +# PostgreSQL and Vault implementation work log + +Status: scoped implementation and local verification complete. Normal full verify is blocked by six existing Identity PMD naming findings; live-data and missing financial-adapter work remain outside the proven scope. See the guide for production-dependent limitations. Current source is authoritative. + +## Authorization and preservation + +- Explicit implementation request covers PostgreSQL, Vault, required correctness fixes, real dependency tests, infrastructure, ADRs and documentation. +- Branch `amg-postgresql-vault` was created from local main on 2026-10-11. During implementation no commit/push/PR was authorized. On 2026-10-12 the owner authorized committing/pushing only migration work on `amg-postgres-vault` and creating a PR to main; unrelated owner changes must remain excluded. No deployment, external Issue or live cutover is authorized. +- Preserve substantial owner changes, including retirement of `payguard-otp-service`, untracked Identity, domain validation/identity changes and other dirty files. No old Oracle volume/data was deleted or reset. +- Read full `CLAUDE.md`; no additional applicable `AGENTS.md` found. Explicit implementation overrides its mentor-only default. +- Live Oracle data and approved historical UUID-to-prefixed-ID mapping remain unavailable. Stopped containers never established that existing data was disposable. +- Protected `.local/payguard` files must never be printed, committed, uploaded or copied into images. Mode-0600 files, mode-0700 directories, gitignore and dockerignore remain in place. + +## Completed checklist + +- [x] Inspect source, parent/module builds, adapters, schemas, configuration, CI and existing ADRs. +- [x] Preserve owner changes and create appropriate temporary branch. +- [x] Record baseline, official selected-version documentation and actual Vault license. +- [x] Migrate available normal build/runtime/tests to PostgreSQL while retaining each persistence approach. +- [x] Preserve historical Oracle scripts in vendor-separated legacy paths. +- [x] Isolate four databases and runtime/migration/manager roles with TLS/SCRAM. +- [x] Implement cross-process Identity verification and first-issuance/replacement serialization, explicit rejection commits. +- [x] Implement scoped AppRole/KV/database client, actual leased pools, bounded credential replacement and failures. +- [x] Implement versioned/legacy HMAC compatibility and customer-scoped authenticator lookup. +- [x] Persistent TLS/Raft/audit rehearsal, bootstrap, threshold recovery, operator refresh and separate migrations. +- [x] Real PostgreSQL migrations/round trips/concurrency and real Vault privileges/lifecycle/outage/key tests. +- [x] Synthetic offline mapping/import/reconciliation with refusal of populated destinations and missing mappings. +- [x] Boot all four actual packaged service JARs with only their own AppRole and CA; HTTP readiness UP. +- [x] Dedicated mandatory CI job, updated README/architecture, ADRs 0011–0014, English guide and Issue drafts. +- [x] All four actual Dockerfile builds succeeded; no images pushed/deployed. + +## Source-based service matrix + +| Service | Actual owned source | Previous credentials | Result and evidence boundary | +| --- | --- | --- | --- | +| Identity | `payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence`; actual JPA challenges/audit, Oracle V1/V2 | datasource environment and known pepper fallback | PostgreSQL final V1/scope V2, real adapter transactions/races, Vault pool/keys/seeds; one database for four package modules | +| Loan | `payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql`; repository port, no concrete entity/adapter | datasource environment | PostgreSQL exact schema/Flyway/SQL fixtures and Vault pool; absent persisted business flows not invented | +| Collateral | `payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql`; repository port, no concrete entity/adapter | datasource environment | PostgreSQL exact valuation schema/Flyway/fixtures and Vault pool; absent persisted business flows not invented | +| Wallet | `payguard-wallet-service/pom.xml` OSS jOOQ dependency/domain/ports; no schema/adapter/codegen/generated source | datasource environment | PostgreSQL driver/POSTGRES dialect and Vault pool; no fabricated ledger schema or platform-wide financial concurrency claim | +| Gateway / Proto | database-free modules | no database credentials | Remain database-free | + +## Decisions and versions + +- Java 25, Boot 4.1.1, managed Flyway 12.4.0/JDBC 42.7.13/Hikari 7.0.2/Testcontainers 2.0.5/JUnit 6.0.3; existing jOOQ OSS 3.19.15. No Java/Spring upgrade. +- PostgreSQL 17.11-bookworm digest `sha256:3645570cccdfa447589da9f57dd740faa29b30938e861289a5574b6ca6b03826`. +- Vault Community 2.1.2 digest `sha256:c2f666266f383d2cf424d86b8bb8ce7d065562173ffec2b476d762943608bb55`. +- Actual Vault license BUSL 1.1 with additional noncompeting internal-production grant/future MPL 2.0 change; not described as OSI open source. No Enterprise capabilities required. +- Four separate private PostgreSQL servers scope manager CREATEROLE authority per service. Persistent non-dev Vault is single-node Raft/TLS/manual unseal/file audit; no HA/auto-unseal or encrypted hosting-volume claim. +- Separate migration AppRoles issue expiring owner-member logins; SET ROLE durable no-login owner keeps objects revocable. Runtime never receives DDL/admin/root material; runtime Flyway disabled, Hibernate validate. +- Row lock/REQUIRES_NEW committed-result verification; persistent customer/purpose scope locking plus ACTIVE unique index handles first issuance. Remote calls/quota outside locks; no automatic transaction retry or exactly-once side-effect claim. +- Named HMAC key material stays outside source; operator retention includes cached-issuer outage window and OTP lifetime. Missing seed differs from denied/unavailable dependency. +- ADRs: `docs/decission/ADR-0011-postgresql-service-ownership-and-cutover.md`, `ADR-0012-vault-service-secrets-and-bootstrap.md`, `ADR-0013-database-leases-and-pool-replacement.md`, `ADR-0014-otp-postgresql-concurrency.md`. + +## Current verification: actual commands and results + +1. `JAVA_HOME=/home/amirgm/.jdks/corretto-25.0.4.1 ./mvnw -B -ntp verify`: **95 tests passed, 0 failures/errors/skips**. Shared 4, Proto 1, Gateway 2, Wallet 1, Loan 5, Collateral 4, Identity 78. Exit **1** at Identity PMD: six existing typed-ID field/accessor naming findings. Checks were not disabled. `/tmp/payguard-final-current-verify.log`; all module Surefire XML inspected. +2. Identity real PostgreSQL 14 tests passed; application contracts 11 passed. Includes deterministic independent transaction verification/attempt/first-issuance/replacement races, final boundaries, ownership/case/expiry/restoration, rollback and dependency failure before mutation. +3. Shared real populated V1/V2/import rehearsal 2, strict TLS URL guard 1 and secret-error redaction 1 passed. Expected duplicate ACTIVE upgrade failure rolls back, malformed/orphan imports roll back and populated destinations refuse import. +4. `python3 -m unittest discover -s scripts/migration -p 'test_*.py'`: **3 passed**; approved exact IDs, UUID RAW hex byte order, microsecond truncation, missing mapping/orphan/duplicate/provenance refusal. +5. `python3 scripts/local/verify_vault.py`: exit **0**, **15 passed, 0 failures/errors/skips**, 116 seconds, after final legacy-alias hardening. Real four-service DML/default grants, denied DDL/admin/cross-DB/Vault paths, renew/revoke, existing-session limit, continuous pool replacement past maxTTL, held transaction, forced credential revocation/replacement, token renewal/re-auth/restart, manager rotation, sealed Vault and database stop/start recovery, real TOTP seed and HMAC version/legacy/retirement compatibility. Also rejects premature legacy alias reassignment/removal with old material retained. Audit allowed/denied trace and plaintext secret-marker checks passed. `/tmp/payguard-vault-final-alias-tests.log`; `payguard-shared/target/vault-test-reports/TEST-junit-jupiter.xml` inspected. +6. `python3 scripts/local/verify_applications.py`: all four actual packaged service JARs reached HTTP readiness UP on private network with only dedicated service AppRole directory/CA. Probe containers stopped/removed. Final stdout+stderr secret-marker rerun also passed, exit 0: `/tmp/payguard-application-readiness-final.log`. After final legacy-alias guard, Identity-only probe also passed: `/tmp/payguard-identity-final-readiness.log`. +7. Initial/threshold recovery and ordinary repeat bootstrap succeeded; `refresh_approle.py` renewed operator and replaced SecretIDs atomically. All three separate local migration commands succeeded and revoked their migration tokens. Original volumes retained. +8. Four-module package/dependency build succeeded with tests skipped; `/tmp/payguard-final-package.log`. Not counted as executed tests. +9. All modules before Identity passed normal formatting/Checkstyle/PMD/CPD/SpotBugs. Identity independent SpotBugs passed (0 bugs/errors). Standalone current PMD confirms precisely six existing findings. Final shared/Identity Spotless+Checkstyle passed. No broad analysis suppression added. +10. `docker build -f payguard-identity-service/Dockerfile -t payguard/identity-service:rehearsal .`: exit 0, actual image built using pinned Java25 digests; `/tmp/payguard-identity-docker-build.log`. `docker compose --env-file .env.example --profile applications build wallet-service loan-service collateral-service`: exit 0, all three actual financial service images built; `/tmp/payguard-financial-docker-build.log`. No images pushed/deployed. +11. Compose/security workflow YAML parsed. Remaining Oracle source search found only historical scripts/guard/rehearsal documentation and market `PriceOracle` terminology. Original Loan/Collateral Oracle script bytes match HEAD; no local secret artifact tracked. Diff whitespace check passed. + +## Historical findings and continuation repairs + +- Baseline full verify stopped on existing shared import formatting before tests. Baseline Identity diagnostic recorded 53 tests, 4 failures, 25 errors, 8 skips. Earlier final run recorded 76 tests, 2 domain failures, no skips. These are historical observations, not current test results. +- Current owner domain changes made stable aggregate hashing and pre-creation validation regressions pass. Preserve those changes; historical Issue drafts are marked resolved, not active blockers. +- At this continuation, previously implemented Python scripts were absent, OtpRepository's locked method was missing, and typed IDs referred to a missing PrefixedIdValidator. Restored actual runnable scripts/contract and supplied exact-format helper based on existing tests. No missing service/shared architecture fabricated. +- Restored operator policy initially lacked audit-list sudo and used a middle wildcard for migration credential paths. Repeat bootstrap and real tests exposed these; explicit policy paths/self-token permissions fixed them. First restored Vault run had 12 setup errors; final rerun all 15 passed. +- Runtime role creation explicitly sets 10-second statement/lock/idle transaction limits; real suite checks statement timeout. Function PUBLIC default EXECUTE is revoked globally, not via ineffective per-schema revoke. +- A new disposable PostgreSQL initialization proof initially hit the temporary bootstrap server before initialization finished; corrected health probe waits for TCP. Final proof passed, exit 0: fresh initialization/CONNECT/USAGE allowed, CREATE/default function EXECUTE denied. `/tmp/payguard-fresh-init-proof.log`. No existing data involved; only the new temporary container stopped/removed. +- Do not run clean builds concurrently with Vault runner; no runner currently active. Secret files remain protected and their contents must not enter tool output. + +## Documentation and remaining boundaries + +- Guide: `docs/guides/postgresql-vault-migration.md`; change ledger, safe reproduction commands, mappings, cutover/reconciliation, rollback boundaries, permissions, pool/key/outage behavior and observed evidence. +- Issue drafts: `docs/issues/postgresql-vault-follow-up.md`; six typed-ID PMD findings, missing financial adapters, Redis quota atomicity, durable audit/event/delivery recovery, authenticated seed enrollment and production operations. No external Issues posted. +- Live Oracle export/mapping/reconciliation and financial persisted adapter/concurrency flows remain unavailable/unimplemented, not green by inference. +- Production certificate/SecretID/operator supervision, HA/auto-unseal, independent key custody, audit retention/capacity, encrypted volumes/backups and restore exercise remain hosting/operational work. +- Remote GitHub CI has not been run. Alternate Maven Flyway plugin command path and backup/restore CLI are documented, not claimed as executed. Actual Java Flyway helper/migrations are proven. + +## Handover boundary + +All Docker builds/fresh provisioning/application probes passed. Final review found that changing/removing the legacy alias while retaining old key material bypassed the existing key-retirement guard. Added explicit alias validation and real KV regression coverage. Normal verify again executed all 95 tests successfully and failed only on the same six existing PMD findings; all 15 Vault tests passed, Identity SpotBugs passed, the final image rebuilt and final Identity readiness reached UP. No active build/test runner remains. Persistent local PostgreSQL/Vault containers retain their volumes. No commit/push/deployment performed or authorized. Further work requires the documented PMD decision, missing financial adapters, authorized live data/mappings and production operational decisions; do not infer production readiness from the local result. + +## Migration-only publication + +An isolated worktree on `amg-postgres-vault` preserves the original checkout/index. The publication excludes the owner's 32-file Identity domain PR #33, existing shared/Wallet nullness changes, CLAUDE.md, OTP module retirement, event-schema and Git-hook edits, and the owner root-module rename. Only migration files are selected. Earlier 95-test and 15-test evidence belongs to the full implementation checkout; a clean migration-only branch still needs the separately owned Identity integration and cannot honestly claim that validation. Commit/push/PR results are reported to the owner separately. diff --git a/infrastructure/postgresql/entrypoint.sh b/infrastructure/postgresql/entrypoint.sh new file mode 100644 index 0000000..33d4f5d --- /dev/null +++ b/infrastructure/postgresql/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +mkdir -p /var/lib/postgresql/tls +cp /run/payguard/server.crt /var/lib/postgresql/tls/server.crt +cp /run/payguard/server.key /var/lib/postgresql/tls/server.key +cp /run/payguard/init.sql /docker-entrypoint-initdb.d/010-payguard.sql +chown -R postgres:postgres /var/lib/postgresql/tls /docker-entrypoint-initdb.d/010-payguard.sql +chmod 600 /var/lib/postgresql/tls/server.key /docker-entrypoint-initdb.d/010-payguard.sql +exec docker-entrypoint.sh postgres -c ssl=on \ + -c ssl_cert_file=/var/lib/postgresql/tls/server.crt \ + -c ssl_key_file=/var/lib/postgresql/tls/server.key \ + -c password_encryption=scram-sha-256 \ + -c log_min_error_statement=panic \ + -c log_parameter_max_length=0 \ + -c log_parameter_max_length_on_error=0 \ + -c hba_file=/etc/postgresql/pg_hba.conf diff --git a/infrastructure/postgresql/pg_hba.conf b/infrastructure/postgresql/pg_hba.conf new file mode 100644 index 0000000..548abca --- /dev/null +++ b/infrastructure/postgresql/pg_hba.conf @@ -0,0 +1,6 @@ +# Unix sockets are reserved for local bootstrap/operator maintenance inside the container. +local all all trust +hostnossl all all 0.0.0.0/0 reject +hostnossl all all ::/0 reject +hostssl all all 0.0.0.0/0 scram-sha-256 +hostssl all all ::/0 scram-sha-256 diff --git a/infrastructure/vault/entrypoint.sh b/infrastructure/vault/entrypoint.sh new file mode 100644 index 0000000..b5082c2 --- /dev/null +++ b/infrastructure/vault/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +mkdir -p /vault/tls /vault/audit +cp /run/payguard/server.crt /vault/tls/server.crt +cp /run/payguard/server.key /vault/tls/server.key +cp /run/payguard/ca.crt /vault/tls/ca.crt +chown -R vault:vault /vault/tls /vault/data /vault/audit +chmod 700 /vault/tls /vault/audit +chmod 600 /vault/tls/server.key +exec /usr/local/bin/docker-entrypoint.sh server diff --git a/infrastructure/vault/server.hcl b/infrastructure/vault/server.hcl new file mode 100644 index 0000000..f4a2cc2 --- /dev/null +++ b/infrastructure/vault/server.hcl @@ -0,0 +1,17 @@ +ui = false +disable_mlock = true +/* Threshold-key recovery remains protected by unseal keys and the loopback operations endpoint. */ +enable_unauthenticated_access = ["generate-root"] +api_addr = "https://vault:8200" +cluster_addr = "https://vault:8201" +storage "raft" { + path = "/vault/data" + node_id = "payguard-local-1" +} +listener "tcp" { + address = "0.0.0.0:8200" + cluster_address = "0.0.0.0:8201" + tls_cert_file = "/vault/tls/server.crt" + tls_key_file = "/vault/tls/server.key" + tls_min_version = "tls12" +} diff --git a/payguard-collateral-service/Dockerfile b/payguard-collateral-service/Dockerfile index fd7502c..7da8a1e 100644 --- a/payguard-collateral-service/Dockerfile +++ b/payguard-collateral-service/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1 -FROM eclipse-temurin:25-jdk-alpine AS build +FROM eclipse-temurin:25-jdk-alpine@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a AS build WORKDIR /workspace COPY . . RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-collateral-service -am -DskipTests package -FROM eclipse-temurin:25-jre-alpine +FROM eclipse-temurin:25-jre-alpine@sha256:3c0a9084927a221ccd1d007fcaf614465672c0af37aaa834c5184483afe56d61 WORKDIR /app RUN addgroup -S payguard && adduser -S payguard -G payguard COPY --from=build /workspace/payguard-collateral-service/target/payguard-collateral-service-*.jar /app/app.jar diff --git a/payguard-collateral-service/README.md b/payguard-collateral-service/README.md index 7f1c486..0c98a6e 100644 --- a/payguard-collateral-service/README.md +++ b/payguard-collateral-service/README.md @@ -1,5 +1,13 @@ # Collateral service -The service owns cash collateral locks, valuation history, LTV thresholds, margin calls, liquidation authorization, retention metadata, audit records, and outbox publication. Price and liquidation integrations are ports; the portfolio profile supplies a simulated foreign-currency oracle and execution adapter. +The domain/application layer models cash collateral locks, valuation history, LTV thresholds, margin calls, liquidation authorization, retention metadata, audit records, and outbox publication. Price and liquidation integrations are ports; the portfolio profile supplies a simulated foreign-currency oracle and execution adapter. -Build independently from the repository root with `docker build -f payguard-collateral-service/Dockerfile -t payguard/collateral-service .`. Run it with `SPRING_DATASOURCE_URL="$COLLATERAL_ORACLE_JDBC_URL"`, `SPRING_DATASOURCE_USERNAME="$COLLATERAL_ORACLE_APP_USER"`, and `SPRING_DATASOURCE_PASSWORD="$COLLATERAL_ORACLE_APP_PASSWORD"` from `.env`; its Flyway schema is applied only to the collateral-owned Oracle instance. +The supplied SQL schema and infrastructure now use the service-owned PostgreSQL database. Concrete repository adapters/entities are still absent, so schema tests do not prove the planned persisted business workflows. Flyway runs separately with a Vault migration identity; runtime obtains scoped DML credentials through the rotating Vault DataSource. + +From the repository root with JDK 25 and Docker: + +```bash +./mvnw -B -ntp -pl payguard-collateral-service -am test +``` + +The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Original Oracle migrations remain unchanged under `src/main/resources/db/legacy/oracle` for authorized historical migration only. Runtime database passwords do not belong in `.env`. diff --git a/payguard-collateral-service/pom.xml b/payguard-collateral-service/pom.xml index 3e1b822..852fbdb 100644 --- a/payguard-collateral-service/pom.xml +++ b/payguard-collateral-service/pom.xml @@ -13,6 +13,14 @@ payguard-collateral-service + + org.springframework.boot + spring-boot-starter-actuator + + + org.flywaydb + flyway-database-postgresql + org.springframework.boot spring-boot-starter-data-jpa @@ -22,12 +30,12 @@ spring-boot-starter-kafka - org.flywaydb - flyway-core + org.springframework.boot + spring-boot-starter-flyway - com.oracle.database.jdbc - ojdbc17 + org.postgresql + postgresql runtime @@ -46,7 +54,7 @@ org.testcontainers - testcontainers-oracle-xe + testcontainers-postgresql test diff --git a/payguard-collateral-service/src/main/resources/application.yaml b/payguard-collateral-service/src/main/resources/application.yaml index 6cc583f..fe479db 100644 --- a/payguard-collateral-service/src/main/resources/application.yaml +++ b/payguard-collateral-service/src/main/resources/application.yaml @@ -3,10 +3,42 @@ spring: name: payguard-collateral-service datasource: url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: oracle.jdbc.OracleDriver + driver-class-name: org.postgresql.Driver jpa: + properties: + hibernate.default_schema: payguard open-in-view: false + hibernate: + ddl-auto: validate flyway: + enabled: false + locations: classpath:db/migration/postgresql + clean-disabled: true + url: ${SPRING_DATASOURCE_URL} + user: ${PAYGUARD_MIGRATION_USERNAME} + password: ${PAYGUARD_MIGRATION_PASSWORD} + default-schema: payguard + schemas: payguard + create-schemas: false + +payguard: + vault: enabled: true + address: ${VAULT_ADDR} + ca-file: ${VAULT_CA_FILE} + role-id-file: ${VAULT_ROLE_ID_FILE} + secret-id-file: ${VAULT_SECRET_ID_FILE} + database-role: ${PAYGUARD_ENVIRONMENT:local}-collateral-runtime + +management: + endpoint: + health: + probes: + enabled: true + group: + readiness: + include: readinessState,db,vault + endpoints: + web: + exposure: + include: health,info diff --git a/payguard-collateral-service/src/main/resources/db/migration/V1__collateral_schema.sql b/payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql similarity index 100% rename from payguard-collateral-service/src/main/resources/db/migration/V1__collateral_schema.sql rename to payguard-collateral-service/src/main/resources/db/legacy/oracle/V1__collateral_schema.sql diff --git a/payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql b/payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql new file mode 100644 index 0000000..6b2992b --- /dev/null +++ b/payguard-collateral-service/src/main/resources/db/migration/postgresql/V1__collateral_schema.sql @@ -0,0 +1,35 @@ +CREATE TABLE collateral ( + id uuid PRIMARY KEY, owner_token varchar(128) NOT NULL, loan_id uuid NOT NULL, + amount numeric(28,8) NOT NULL, asset_currency char(3) NOT NULL, + warning_ltv numeric(10,8) NOT NULL, margin_ltv numeric(10,8) NOT NULL, + liquidation_ltv numeric(10,8) NOT NULL, status varchar(24) NOT NULL, + version bigint DEFAULT 0 NOT NULL, retention_until timestamptz(6), + created_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +CREATE INDEX ix_collateral_loan_status ON collateral(loan_id, status); +CREATE TABLE collateral_valuation ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + collateral_id uuid NOT NULL REFERENCES collateral(id), + asset_value numeric(28,8) NOT NULL, fx_rate numeric(28,12) NOT NULL, + observed_at timestamptz(6) NOT NULL +); +CREATE INDEX ix_collateral_val_time ON collateral_valuation(collateral_id, observed_at); +CREATE TABLE collateral_audit_log ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + collateral_id uuid NOT NULL, subject_token varchar(128), actor_token varchar(128), + action varchar(64) NOT NULL, reason varchar(256), + occurred_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + previous_hash varchar(128), record_hash varchar(128) NOT NULL +); +CREATE INDEX ix_collateral_audit_subject_time ON collateral_audit_log(subject_token, occurred_at); +CREATE TABLE collateral_processed_event ( + event_id uuid NOT NULL, consumer_name varchar(128) NOT NULL, + processed_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY(event_id, consumer_name) +); +CREATE TABLE collateral_outbox ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id uuid UNIQUE NOT NULL, event_type varchar(128) NOT NULL, + aggregate_id varchar(128) NOT NULL, payload text NOT NULL, + occurred_at timestamptz(6) NOT NULL, published_at timestamptz(6) +); diff --git a/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java b/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java index 1e729ba..3401ef3 100644 --- a/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java +++ b/payguard-collateral-service/src/test/java/dev/amg/payguard/collateral/CollateralServiceApplicationTests.java @@ -5,30 +5,87 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.OracleContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; @SpringBootTest @ActiveProfiles("test") @Testcontainers class CollateralServiceApplicationTests { + @org.springframework.beans.factory.annotation.Autowired + org.springframework.jdbc.core.JdbcTemplate jdbc; @Container - static final OracleContainer ORACLE = - new OracleContainer("gvenzl/oracle-xe:21-slim-faststart") - .withUsername("PAYGUARD_TEST") + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer("postgres:17.11-bookworm") + .withInitScript("postgres-test-init.sql") + .withUsername("payguard_test") .withPassword("payguard_test"); @DynamicPropertySource - static void oracleProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", ORACLE::getJdbcUrl); - registry.add("spring.datasource.username", ORACLE::getUsername); - registry.add("spring.datasource.password", ORACLE::getPassword); - registry.add("spring.datasource.driver-class-name", ORACLE::getDriverClassName); - registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.OracleDialect"); + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> POSTGRES.getJdbcUrl() + "¤tSchema=payguard"); + registry.add("spring.flyway.url", POSTGRES::getJdbcUrl); + registry.add("spring.flyway.user", POSTGRES::getUsername); + registry.add("spring.flyway.password", POSTGRES::getPassword); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); } @Test - void contextLoads() {} + void contextLoads() { + org.junit.jupiter.api.Assertions.assertEquals( + 1, + jdbc.queryForObject( + "SELECT count(*) FROM flyway_schema_history WHERE success", Integer.class)); + } + + @Test + void valuationPreservesUuidMoneyCurrencyAndTimestampPrecision() { + var id = java.util.UUID.randomUUID(); + var loan = java.util.UUID.randomUUID(); + var amount = new java.math.BigDecimal("99999999999999999999.12345678"); + var fx = new java.math.BigDecimal("1.123456789012"); + var observed = java.time.Instant.parse("2026-01-01T12:00:00.123456Z"); + jdbc.update( + "INSERT INTO collateral(id,owner_token,loan_id,amount,asset_currency,warning_ltv,margin_ltv,liquidation_ltv,status) VALUES (?,'synthetic-owner',?,?,'EUR',0.5,0.6,0.8,'LOCKED')", + id, + loan, + amount); + jdbc.update( + "INSERT INTO collateral_valuation(collateral_id,asset_value,fx_rate,observed_at) VALUES (?,?,?,?)", + id, + amount, + fx, + java.time.OffsetDateTime.ofInstant(observed, java.time.ZoneOffset.UTC)); + jdbc.query( + "SELECT c.id,c.loan_id,c.amount,c.asset_currency,c.retention_until,v.fx_rate,v.observed_at FROM collateral c JOIN collateral_valuation v ON v.collateral_id=c.id WHERE c.id=?", + rows -> { + org.junit.jupiter.api.Assertions.assertEquals( + id, rows.getObject("id", java.util.UUID.class)); + org.junit.jupiter.api.Assertions.assertEquals( + loan, rows.getObject("loan_id", java.util.UUID.class)); + org.junit.jupiter.api.Assertions.assertEquals(amount, rows.getBigDecimal("amount")); + org.junit.jupiter.api.Assertions.assertEquals("EUR", rows.getString("asset_currency")); + org.junit.jupiter.api.Assertions.assertEquals(fx, rows.getBigDecimal("fx_rate")); + org.junit.jupiter.api.Assertions.assertEquals( + observed, rows.getObject("observed_at", java.time.OffsetDateTime.class).toInstant()); + org.junit.jupiter.api.Assertions.assertNull(rows.getObject("retention_until")); + }, + id); + } + + @Test + void valuationCannotReferenceMissingOwnedCollateral() { + var missing = java.util.UUID.randomUUID(); + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.dao.DataIntegrityViolationException.class, + () -> + jdbc.update( + "INSERT INTO collateral_valuation(collateral_id,asset_value,fx_rate,observed_at) VALUES (?,1,1,CURRENT_TIMESTAMP)", + missing)); + } } diff --git a/payguard-collateral-service/src/test/resources/application-test.yaml b/payguard-collateral-service/src/test/resources/application-test.yaml index 53dc98c..3f74d54 100644 --- a/payguard-collateral-service/src/test/resources/application-test.yaml +++ b/payguard-collateral-service/src/test/resources/application-test.yaml @@ -1,16 +1,13 @@ +payguard: + vault: + enabled: false spring: - datasource: - # Replaced by CollateralServiceApplicationTests.oracleProperties at runtime. - url: ${TEST_ORACLE_JDBC_URL:jdbc:oracle:thin:@//localhost:1521/XEPDB1} - username: ${TEST_ORACLE_USERNAME:PAYGUARD_TEST} - password: ${TEST_ORACLE_PASSWORD:payguard_test} - driver-class-name: oracle.jdbc.OracleDriver - jpa: - database-platform: org.hibernate.dialect.OracleDialect - hibernate: - ddl-auto: none - open-in-view: false flyway: - # Schema migrations are exercised independently; context tests only need - # the Oracle-backed JPA infrastructure to start. - enabled: false + enabled: true + +management: + endpoint: + health: + group: + readiness: + include: readinessState,db diff --git a/payguard-collateral-service/src/test/resources/application.yaml b/payguard-collateral-service/src/test/resources/application.yaml deleted file mode 100644 index f9b727c..0000000 --- a/payguard-collateral-service/src/test/resources/application.yaml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - autoconfigure: - exclude: - - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration - - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration diff --git a/payguard-collateral-service/src/test/resources/postgres-test-init.sql b/payguard-collateral-service/src/test/resources/postgres-test-init.sql new file mode 100644 index 0000000..d1ce84d --- /dev/null +++ b/payguard-collateral-service/src/test/resources/postgres-test-init.sql @@ -0,0 +1 @@ +CREATE SCHEMA payguard; diff --git a/payguard-identity-service/ARCHITECTURE.md b/payguard-identity-service/ARCHITECTURE.md new file mode 100644 index 0000000..4344fcc --- /dev/null +++ b/payguard-identity-service/ARCHITECTURE.md @@ -0,0 +1,712 @@ +# PayGuard Identity Service Architecture + +**Scope:** `payguard-identity-service`\ +**Root package:** `dev.amg.payguard.identity`\ +**Status:** Target architecture; implementation and migration in progress\ +**Last updated:** 2026-10-10 + +> Identity separates authentication integration, access policy, customer identity, +> verification, and registration. These responsibilities do not all have the same +> owner, and successful verification is not permission to execute a financial action. + +## Contents + +1. [Document scope and implementation status](#1-document-scope-and-implementation-status) +2. [Architectural decisions](#2-architectural-decisions) +3. [Module ownership](#3-module-ownership) +4. [Package structure and layer responsibilities](#4-package-structure-and-layer-responsibilities) +5. [Module contracts and dependency rules](#5-module-contracts-and-dependency-rules) +6. [Identity model and typed identifiers](#6-identity-model-and-typed-identifiers) +7. [Authentication and authorization boundaries](#7-authentication-and-authorization-boundaries) +8. [Verification model and state transitions](#8-verification-model-and-state-transitions) +9. [Application workflows](#9-application-workflows) +10. [Persistence, transactions, and concurrency](#10-persistence-transactions-and-concurrency) +11. [Security, privacy, and operations](#11-security-privacy-and-operations) +12. [Testing and architectural enforcement](#12-testing-and-architectural-enforcement) +13. [Migration and implementation order](#13-migration-and-implementation-order) +14. [Open decisions and extraction criteria](#14-open-decisions-and-extraction-criteria) +15. [References](#15-references) + +## 1. Document scope and implementation status + +This document is the implementation guide for Identity, not a description of the +entire PayGuard platform. Other services are mentioned only to define Identity's +boundaries. It contains no claim about Revolut's or Stripe's internal architecture. + +The Verification baseline was inspected in the current working tree for this +refactoring. Other modules remain target responsibilities unless stated otherwise. +Update this file when implementation changes. + +| Area | Current implementation status | +| --- | --- | +| OTP aggregate | Complete immutable-snapshot transitions retained; final success/failure and exception-safety regression tests pass. | +| Typed identifiers | `issue()`/`restore()`, use cases, audit, delivery, and repository use `OtpId`/`CustomerId`; inbound and persistence adapters parse strings. The inverted prefix guard was corrected without changing the specified formats or equality. | +| Package migration | Verification's packages, imports, adapter wiring, and test packages corrected. Existing IDs remain in `customer.domain.value_object`; final exported-ID ownership is still open. | +| Required outbound contracts | `OtpRepository` in Domain; delivery, audit, request quota, and trusted TOTP verification in Application. | +| Step-up tokens | Retired by explicit developer decision: issuance, Redis adapter, consumption endpoint, and token TTL removed. Verification returns a completion result, not an authorization credential. | +| Persistence | PostgreSQL JPA adapters preserve prefixed strings and UUID audit IDs. Row locks serialize verification; durable scope rows and a partial unique index serialize issuance. Historical Oracle V2 still refuses unmapped populated legacy tables. | +| Keycloak, access/customer/registration workflows | Architectural direction only; not implemented by this refactoring. | +| Concurrency, durable evidence, idempotency | Still unresolved; no distributed single-use or exactly-once claim. | + + +**Established decisions** below summarize the agreed direction. **Proposed +conventions** make that direction implementable but still require review. **Open +decisions** must not be silently treated as completed design. + +## 2. Architectural decisions + +Identity is one deployable Java/Spring Boot service organized around four business +modules: `access`, `customer`, `verification`, and `registration`. Each module owns +its rules and state. Internal calls normally use Java application contracts, not +HTTP or gRPC between modules in the same process. + +The previous names `account` and `onboarding` are replaced by `access` and +`registration`. An Account remains a valid domain concept inside Access; it means +an internal login/access identity, not a financial ledger account. + +| Decision | Reason | Accepted cost | +|------------------------------------------|-----------------------------------------------------------------|----------------------------------------------------------------| +| Business modules before technical layers | Make responsibility and change ownership visible. | Contracts, mapping, and boundary tests require effort. | +| One deployment initially | Independent deployment is not yet justified by measured needs. | Shared releases, resources, and process failure boundary. | +| Keycloak owns authentication | Adopt established identity protocols and credential mechanisms. | PayGuard must operate and integrate the provider reliably. | +| PayGuard owns business authorization | Permissions alone cannot decide every resource-specific action. | Services must enforce explicit, tested policies. | +| Typed, prefixed domain identifiers | Prevent accidental identifier substitution and validate format. | Boundary conversions and data migration are required. | +| Verification produces bounded evidence | Challenge completion and business permission are distinct. | Consumers must validate evidence and their own business rules. | + +This structure does not replace DDD, prove that four bounded contexts exist, or +make a layered alternative incorrect. It is a way to express the chosen ownership +boundaries. Package names alone do not enforce them. + +A shared process is neither an automatic transaction nor a security isolation +boundary. Complete-state assignment is not distributed atomicity. Those guarantees +require separate mechanisms and tests. + +## 3. Module ownership + +### 3.1 Access + +**Question:** Which internal account is acting, and what access relationships and +permissions does it have? + +Access owns internal account identity and access status, external identity links, +tenant memberships, invitations, roles, and permission assignments. Tenant access +administration belongs here initially; a broader tenant/business-organization +lifecycle may justify a separate module later. + +Key operations include resolving an external identity, suspending access, managing +membership, and evaluating access permissions. Other modules may request these +operations through Access's public contract. + +Access does not store PayGuard login passwords, implement WebAuthn verification, +manage customer profiles, or decide whether funds can be withdrawn. It does not +need a Customer record for every account: an organizational employee can have +membership without being a financial customer. + +### 3.2 Customer + +**Question:** Which stable PayGuard business customer does this record represent? + +Customer owns `CustomerId`, the customer record, its lifecycle, and its explicit +relationship to internal access accounts. Proposed lifecycle terms include +`PENDING`, `ACTIVE`, `SUSPENDED`, and `CLOSED`; the final transition rules require +implementation-specific decisions. + +Changing a login provider or email must not regenerate the customer identifier. +Suspending access must not implicitly delete customer or financial records. + +Customer owns neither credentials nor OTP state. Legal identity-checking providers +and their detailed workflows are outside this document; Customer may later keep +references to approved outcomes without implementing the checking mechanism. + +### 3.3 Verification + +**Question:** Has this challenge been completed for its intended subject and purpose? + +Verification owns OTP challenges, expiration, attempt accounting, consumption, +invalidation, verification audit entries, and the planned verification-evidence +lifecycle. Its adapters integrate with hashing, delivery, persistence, and any +explicitly approved verification provider. + +Verification does not complete registration, activate a customer, or authorize a +financial action on its own. It does not implement every authentication mechanism +supported by Keycloak. + +A challenge subject is not necessarily an existing customer. Registration and +account-recovery subjects require an explicit contract before those flows ship. + +### 3.4 Registration + +**Question:** Which required registration steps have completed, and what remains? + +Registration owns the registration identifier, progress, completion criteria, +expiry, and retry/recovery coordination. It requests behavior from Access, +Customer, and Verification through their contracts. + +It must not write their tables, modify their aggregates, or bypass their rules. +A verified contact is input to registration completion, not completion itself. +Customer activation and access grants remain decisions of their respective owners. + +Give Registration a domain model when its states and rules justify one. Do not +build empty abstractions only to mirror another module's directory structure. + +## 4. Package structure and layer responsibilities + +The following is a **target structure**, not an assertion that these directories +already exist. The `api` convention is proposed for explicit internal contracts. + +```text +payguard-identity-service/ + ARCHITECTURE.md + src/main/java/dev/amg/payguard/identity/ + access/ + api/ + domain/ + application/ + infrastructure/ + keycloak/ + interfaces/ + customer/ + api/ + domain/ + application/ + infrastructure/ + interfaces/ + verification/ + api/ + domain/ + application/ + infrastructure/ + interfaces/ + registration/ + api/ + domain/ + application/ + infrastructure/ + interfaces/ + shared/ + domain/ + identifier/ +``` + +`api` is not another business layer. It is the small exported surface of a module. +`interfaces` contains transport adapters. They solve different problems. + +| Package | Responsibility | Examples | Must not do | +| --- | --- | --- | --- | +| `api` | Publish internal application contracts and immutable results. | Request verification; ensure customer exists. | Expose repositories, builders, provider SDK types, or mutable aggregates. | +| `domain` | Define local business rules and valid state. | `OTP`, `OtpSnapshot`, policies, value objects. | Depend on Spring, SQL implementations, HTTP, or another module's implementation. | +| `application` | Execute a use case and coordinate dependencies. | Load OTP, invoke `verify`, persist the result. | Reimplement attempt counting or lifecycle rules already owned by the aggregate. | +| `infrastructure` | Implement outbound ports and technical integration. | PostgreSQL JPA repository, Vault adapter, Redis adapter, delivery client. | Become the owner of business eligibility rules. | +| `interfaces` | Receive external requests and map results/errors. | REST controllers, gRPC endpoints, message consumers. | Mutate domain state directly or trust unverified caller identity fields. | +| `shared/domain` | Hold deliberately shared, framework-free primitives. | `AggregateRoot`, `ValueObject`. | Contain workflows, repositories, or miscellaneous code without an owner. | + +A port is an interface defined on the inside of a dependency boundary. Its adapter +is the external implementation. An aggregate repository contract may live in its +own domain; an application-only delivery or provider port may live in application. +Do not move every interface into Domain just because it is an interface. + +Within a module, Interfaces calls the application contract, Application invokes +Domain and ports, and Infrastructure implements ports. Runtime dependency injection +connects them; Domain does not import Infrastructure. + +Bootstrap and security wiring are composition concerns, not additional business +modules. Configure module detection intentionally rather than treating every +technical package as a capability. + +### Where existing classes belong + +| Class or concern | Owner / placement | +| --- | --- | +| `OTP`, `OtpSnapshot` | `verification.domain.model` | +| `OtpVerificationPolicy` and OTP enums | Verification domain | +| `OtpId`, `OtpAuditId` | Verification-owned value objects | +| `CustomerId` | Customer-owned value object | +| `OtpVerificationCommand` | Verification-owned contract; its exact layer depends on whether it is an application request or domain operation input. | +| `PrefixedIdValidator` | Customer domain value-object package; exact prefix and ASCII suffix validation | +| Keycloak API client implementation | `access.infrastructure.keycloak` | +| HTTP JWT security configuration | Service composition / inbound security, not a domain aggregate | + +Keep the current `value_object` spelling during this migration. A naming-style +cleanup must not be mixed into behavioral changes. + +## 5. Module contracts and dependency rules + +**Baseline rules:** no circular module dependencies, no imports of another module's +persistence implementation, and no cross-module aggregate mutation. A module +exposes outcomes and operations, not its entire model. + +The following capability-call direction is the proposed baseline: + +| Caller | Allowed destination | Purpose | +| --- | --- | --- | +| Registration | Access, Customer, Verification APIs | Coordinate registration steps. | +| Customer | Access API, only where required | Establish or validate an account relationship. | +| Verification | Access API, only where required | Resolve trusted actor/access context for protected operations. | +| Access | No other Identity capability by default | Keep access identity independent from customer/registration workflows. | +| Any module | Approved shared primitives | Reuse pure validation or deliberately shared contracts. | + +Do not add a reverse call for convenience. For example, Verification returns a +verification outcome; it does not call Registration to complete registration. +A consumer may depend on an event contract published by its owner, but asynchronous +communication does not excuse circular type dependencies or an undocumented flow. + +A type reference is also a dependency. Verification may need an exported +`CustomerId` without permission to call Customer repositories. Export only the +required identity contract, not a whole domain package. + +**Open contract placement:** the final exported-ID package arrangement must be +settled before architecture rules are enforced. In particular, making Verification +import `RegistrationId` from Registration while Registration depends on Verification +would create a cycle. Resolve this with an explicitly reviewed pure identity +contract or a Verification-owned subject/reference contract mapped by the caller. +Do not solve it with an unrestricted raw `String` or by opening both modules. + +For Spring Modulith, a package called `api` is not automatically an exported named +interface. Its default API is the module base package; additional exports require +explicit configuration. Configure the chosen convention rather than relying on +its name. [Module fundamentals][4] + +## 6. Identity model and typed identifiers + +### 6.1 Concepts that must remain distinct + +| Concept | Meaning | Owner | +| --- | --- | --- | +| External login identity | Trusted issuer and subject from the configured provider. | Keycloak; linked internally by Access. | +| Account | Stable internal access identity and access status. | Access | +| Customer | Person or business receiving PayGuard services. | Customer | +| Tenant | Organizational access/resource-isolation scope. | Access initially, subject to lifecycle review. | +| Membership | An account's relationship and permissions within a tenant. | Access | +| Registration | An unfinished or completed registration workflow. | Registration | +| Service principal | A machine identity, not a human customer session. | Provider integration and Access policy | + +Customer relationships and tenant memberships branch from an internal account; +they are not a mandatory account-to-customer-to-membership chain. Cardinalities +and uniqueness constraints must match the chosen product model, not be inferred +from this table. + +OpenID Connect specifies issuer and subject together as the stable provider +identity reference. Email is not an equivalent stable identifier. Provider changes +and account linking require an explicit, verified workflow. [OIDC Core][3] + +### 6.2 Identifier contract + +These are PayGuard conventions inspired by prefixed public IDs, not claims about +Stripe's internal generation rules. + +| Value object | Prefix | Total ASCII length | Generation owner | +| --- | --- | --- | --- | +| `AccountId` | `acc_` | 26 | Access | +| `CustomerId` | `cus_` | 26 | Customer | +| `OtpId` | `otp_` | 26 | Verification | +| `OtpAuditId` | `oaud_` | 27 | Verification | +| `RegistrationId` | `reg_` | 26 | Registration | +| `TenantId` | `ten_` | 26 | Access initially | +| `MembershipId` | `mem_` | 26 | Access | + +Every suffix has exactly 22 case-sensitive ASCII Base62 characters: `A-Z`, `a-z`, +and `0-9`. Reject nulls, incorrect prefixes, incorrect lengths, whitespace, and +all other characters. Do not trim, normalize, or change case. + +Value objects are immutable `final` classes with private constructors, a +`parse(String)` factory, `value()`, and type-specific value equality/hash codes. +Constructor validation must prevent invalid construction; parsing does not generate +an identity or contact a database. + +A separate generator supplies new IDs to the owning module. Use a cryptographically +secure source such as Java `SecureRandom` and an unbiased alphabet-selection +procedure. A random suffix is not an absolute uniqueness guarantee. [Java API][8] + +Persistence must enforce uniqueness and preserve case-sensitive comparison +semantics. JSON, message payloads, database columns, and Redis serialization use +strings at their boundaries; internal application/domain contracts use typed IDs. + +`CustomerId` parsing establishes format, not existence, ownership, or permission. +Identifiers must contain no customer information and must not serve as credentials. + +### 6.3 Current migration rule + +Both `OTP.issue()` and `OTP.restore()` must accept typed identifiers. Convert raw +incoming values in boundary adapters, not inside the aggregate. Repository adapters +reconstitute typed values when reading persisted representations. + +Verification never generates a new customer identifier for each challenge. It +receives the existing relationship, or an explicitly supported non-customer subject. + +## 7. Authentication and authorization boundaries + +Keycloak is the selected authentication provider. Its supported capabilities include +credentials, sessions, authentication flows, and WebAuthn/passkeys; deployment and +configuration remain PayGuard's operational responsibility. [Keycloak guide][1] + +An Access-to-Keycloak adapter is needed for actual provider API operations, not to +proxy every login through Identity. Domain types must not expose provider SDK types. +Login can take place through the provider's standard client flow. + +The target inbound security contract requires appropriate access-token validation: +trusted issuer, intended audience, accepted algorithm, signature, and time claims. +Spring Security provides JWT resource-server mechanisms; explicitly configure the +checks against the versions pinned in the build. An ID token is not the API bearer +credential. [Resource server documentation][2] [OIDC Core][3] + +PayGuard-specific authorization remains explicit. The selected tenant, actor, +resource, action, membership status, and applicable policy must agree. A supplied +customer ID, tenant ID, or internal-looking header is not trusted identity evidence. +Deny-by-default and request-level authorization are the baseline. [OWASP authorization][6] + +Membership and permission changes require a documented freshness/revocation bound. +Do not assume logout or a role update makes every previously issued, locally +validated JWT immediately unusable. Define cache invalidation, session handling, +and rejection behavior before relying on them. + +**Passkeys:** integrate the provider flow without adding a WebAuthn implementation +to `OTP` or `OtpSnapshot`. Login assurance may contribute to a sensitive-operation +policy, but is not approval of an arbitrary operation. Keycloak supplies the +mechanism; PayGuard defines the meaning of the result. [Keycloak guide][1] + +The existing TOTP verification branch must receive a separate ownership review: +provider-owned login MFA must not silently become a second credential store in +Verification. A caller-supplied boolean is not trusted verification evidence. + +## 8. Verification model and state transitions + +### 8.1 Existing domain contract + +The last supplied snapshot design enforces these rules: + +| Rule | Meaning | +| --- | --- | +| `maxAttempts >= 1` | A challenge permits at least one attempt. | +| `0 <= attempts <= maxAttempts` | Recorded attempts remain within the configured limit. | +| `ACTIVE` implies `attempts < maxAttempts` | An active snapshot is not already exhausted. | +| `expiresAt > createdAt` | The validity interval is nonempty. | +| `USED` if and only if `consumedAt` is present | Consumption state and timestamp agree. | +| `createdAt <= consumedAt < expiresAt` | Consumption falls within the validity interval. | +| Random-numeric mode requires a nonblank hash | Verification data is consistent with the selected mode. | + +Retention must satisfy the existing `OtpRetentionPolicy`; this document does not +invent a retention duration. `ACTIVE` is not sufficient eligibility by itself: +expiry, purpose, and other required context still have to be checked. + +For an eligible attempt, the required completed transitions are: + +| Outcome | Next status | Attempt change | Consumption time | +| --- | --- | --- | --- | +| Wrong code; another attempt remains | `ACTIVE` | Add one | Absent | +| Wrong code; final allowed attempt | `LOCKED` | Add one | Absent | +| Correct code, including the final attempt | `USED` | Add one | Set to the accepted operation time | + +Successful code verification counts as an attempt in this contract. Preliminary +rejections are handled separately; preserve their existing precedence and counting +behavior unless a dedicated contract change explicitly alters them. + +### 8.2 Complete-state construction + +Verification determines eligibility and code outcome, constructs a complete valid +next snapshot, and only then assigns the aggregate field. A helper for final success +must set attempts, status, and consumption time before the snapshot is built. + +Do not build an exhausted `ACTIVE` snapshot and try to repair it afterward. Do not +mutate the aggregate early and rely on a later exception to undo it. + +An exception during hashing or next-state construction must leave the aggregate's +snapshot unchanged. A domain rejection such as an expired challenge can deliberately +produce a valid state change and a normal verification result. + +A local snapshot variable is not mandatory: assigning a successfully constructed +final snapshot directly to the field is valid. The requirement is the completed +transition before assignment, not a variable-naming convention. + +### 8.3 Unresolved input and subject contracts + +The following require explicit decisions, not silent refactoring: handling a supplied +time before creation, required command fields, mode-specific hasher requirements, +preliminary rejection precedence, and trusted TOTP-result provenance. + +The current mandatory `CustomerId` supports existing-customer challenges only. +Before pre-customer registration is supported, define the verification subject, +caller authorization, contact binding, and recovery rules. Do not use a fabricated +customer, a nullable collection of unrelated IDs, or an unrestricted generic ID. +The final Java representation is intentionally not selected in this document. + +### 8.4 Implemented Verification contracts + +- `OtpChallengeRepository.replaceActive(OTP)` invalidates ACTIVE challenges for the + same customer and purpose, then saves the replacement in one adapter transaction. + Channel and destination do not narrow this scope. Persistent customer/purpose scope rows + serialize even first issuance; a partial unique index enforces one ACTIVE row. + `save` inserts only. `withLockedChallenge` restores and mutates the current row under + a PostgreSQL lock, committing before the application throws a business rejection. +- `OtpDelivery.submit(OtpDeliveryRequest)` is an application dependency. The request + carries typed challenge/customer IDs and the actual code for adapter access; its + diagnostics omit the code and destination. `destinationRef` remains a caller + reference with no implemented provider-resolution contract. The mock discards + codes; SMS/email/push modes now throw explicitly because their provider integrations + are absent. Completion does not mean confirmed customer receipt. +- `VerificationAudit.record(VerificationAuditEntry)` preserves synchronous audit + persistence and propagated failures. The entry contains a typed challenge and + customer subject, closed action set (`ISSUED`, `VERIFY`), reason code, source IP, + and timestamp. No trusted actor or correlation context is currently supplied; + the subject is not labeled an authenticated actor. Audit commits separately from + challenge state, so an audit failure can follow committed consumption. +- `VerificationQuota.tryAcquire(Request)` consumes quota for ISSUE or VERIFY, + scoped by customer, purpose, and source IP. The existing Redis INCR and first-use + EXPIRE behavior, limits, key format, and propagated storage errors are preserved. + INCR is atomic; the two-command increment/expiry sequence is not atomic. There + is no new gateway rate limiter, and per-challenge attempts are a separate control. +- `TotpVerification` verifies codes server-side via the existing trusted secret + port. The HTTP request cannot supply `authenticatorCodeValid`. Missing secrets + return absence; a denied or unavailable Vault dependency throws before code-check + mutation. The production adapter reads customer-scoped KV v2 seeds; enrollment and + recovery remain absent. LOGIN ownership conflicts with + the intended provider-owned MFA and still requires a decision. +- `OtpCodeHasher` remains the single active hash/match contract. No `CodeHasher` + exists in the inspected tree. Salted HMAC-SHA256 now stores `keyId:salt:digest`; + old `salt:digest` requires an explicitly selected legacy key. Vault key refresh runs + outside persistence locks and preserves the configured OTP compatibility window. + +The current use case supports customer subjects only. Registration/account subjects, +trusted caller authentication/authorization, operation-bound evidence, and retryable +provider delivery are not implemented. A successful response reports `challengeId`, +`purpose`, and `verifiedAt`; it is not a transferable proof for a financial action. + +The domain's existing minimum retention is three calendar years after expiry. +The application/resource defaults now use 1097 days from issuance to accommodate +that invariant for the default challenge lifetimes. Explicit shorter configurations +still fail validation. No purge scheduler is implemented here. + +## 9. Application workflows + +### 9.1 Existing-customer challenge verification + +The inbound adapter validates request shape and parses IDs. The application use case +establishes trusted actor context, loads the challenge, and checks that the caller +may operate on that subject. The aggregate evaluates its own verification rules. +The application persists the resulting state under the chosen concurrency contract, +then returns the committed outcome through the adapter. + +Do not report a successful consumption before its required persistence succeeds. +Do not return the full internal snapshot as a public response: it contains internal +verification material such as hashes. + +### 9.2 Registration + +A candidate product flow is: start a pending registration, request contact +verification, record the matching completed verification, ensure the required +Access account and Customer relationship exist, and mark registration complete. + +The precise order of provider identity creation, internal account creation, and +contact verification is an open product/security decision. Public pre-login endpoints +need an explicit registration-session or equivalent trusted-flow contract; a public +`reg_...` identifier alone must not authorize progression or account linking. + +Registration consumes Verification's result and applies its own completion rules. +It must bind the result to the same registration and contact; changing the contact +invalidates the previous proof for that step. + +Retries must recover the existing logical registration/account/customer relationship. +A committed finalization followed by a lost HTTP response is not permission to create +another customer. For separately committed steps, persist progress and reconcile +completed effects after a crash. + +### 9.3 Login and internal identity resolution + +The client authenticates through Keycloak. Protected Identity endpoints validate the +appropriate credential and Access resolves the trusted provider identity to the +internal account. Customer links and tenant memberships are resolved independently +as required by the use case. + +An unknown provider identity follows an explicit provisioning or rejection policy; +it must not silently become an active customer. Changing a provider link requires +verified account-linking behavior, not email matching alone. + +### 9.4 Verification for a sensitive operation + +The requesting service supplies an authorized, bounded operation context. Verification +issues and completes the relevant challenge and produces or records evidence. The +requesting service validates that evidence and retains the final execution decision. + +Evidence must be bound to its subject, purpose, operation, validity window, and +significant approved terms or an immutable operation version. It must be trusted +and tamper-resistant, and the user must be able to identify the significant terms +being confirmed. Changing those terms invalidates the prior approval. These +requirements follow transaction-authorization guidance. [OWASP transaction authorization][7] + +The evidence representation and consumption protocol are still open decisions. +An identifier alone is not evidence. Challenge consumption and evidence consumption +are separate state changes; neither proves exactly-once execution in another service. + +## 10. Persistence, transactions, and concurrency + +### 10.1 Ownership and transaction scope + +The initial target allows a shared database, with owner-specific tables and migrations. +PostgreSQL/JPA and Vault belong behind infrastructure adapters; domain code must not import generated +records or SQL clients. Repository contracts express required behavior, including +conflict handling, rather than exposing adapter details. + +Local transactions may span selected module operations only through their APIs and +compatible transaction participation. Document those cases. External provider, +delivery, and broker calls are not part of the database transaction. + +Cross-module database constraints require explicit agreement. Prefer owner-supported +queries or dedicated read projections over hidden dependencies on another module's +schema. A shared database must not become unrestricted shared state. + +### 10.2 Competing requests + +Never assume one aggregate instance is safe for concurrent mutation. Loading one +instance per request also does not prevent two requests from updating the same row. + +ADR 0014 selects database row locks for verification and durable scope locks for +issuance. Each verification re-evaluates current state and time after acquiring the +lock. Only one caller receives fresh VERIFIED; competitors receive ALREADY_USED. +Attempts and expiry rejection transitions commit before the HTTP/business exception. +Remote calls remain outside the lock; no quota or external side effect is retried. + +Use concurrency tests with competing requests against the actual persistence adapter. +For one active challenge, only one distinct operation may commit its consumption. +The current API has no stored idempotent verification response; a replay returns +ALREADY_USED rather than a new success. + +### 10.3 Retry and side-effect rules + +An idempotency contract identifies the logical operation, caller scope, payload +compatibility, stored outcome, retention window, and recovery behavior. Database +constraints enforce the uniqueness relationships the contract relies on. + +For durable work that must follow committed state, use a transactional outbox or +another explicitly durable mechanism. Consumers must handle duplicate delivery. +Do not assume an in-memory event or a database commit followed by a best-effort +publish is sufficient. + +Do not hold database locks open while waiting for SMS, email, or Keycloak. Persist +retryable workflow state around external side effects. Expired challenges must not +be revived by a delayed delivery retry. + +## 11. Security, privacy, and operations + +Identity endpoints must enforce resource ownership and tenant membership in addition +to token validity. Apply admission/rate limits to issuing, verifying, and registration +flows; issuing a new challenge must not trivially bypass the intended abuse policy. +Choose rate-limit subjects and storage atomicity in a dedicated implementation ticket. + +Secrets, OTP values, credential hashes, bearer tokens, and step-up tokens must not +appear in ordinary logs. Record only necessary actor references, action, outcome, +correlation context, and policy-relevant audit metadata. Public identifiers remain +potentially linkable information; being non-secret does not justify unrestricted +logging or retention. + +Protect durable delivery data that temporarily needs a code, restrict access, and +remove it when no longer required. Do not persist delivery secrets merely because +an outbox is used. Retention and access rules apply to business data, audit records, +logs, and retry payloads separately. + +Define behavior for missing authorization information, provider outages, unavailable +storage, and exhausted delivery retries. Protected operations must not become +permitted because a dependency failed. Continued use of existing sessions or cached +policy data requires an explicit bounded policy, not an accidental fallback. + +Measure latency, rejection causes, persistence conflicts, provider failures, delivery +retries, and incomplete registration age. Never claim a capacity or availability +level without an appropriate measured workload and failure test. + +## 12. Testing and architectural enforcement + +These are completion requirements, not claims that the tests already exist. + +| Test level | Required evidence | +| --- | --- | +| Identifier unit tests | Valid/invalid prefixes, 21/22/23-character suffixes, null/blank/Unicode inputs, exact case preservation, equality/hash-code contract, distinct ID types. | +| Snapshot and aggregate tests | Final-attempt success/failure, normal failure, expired/terminal behavior, hash/build exceptions preserving old state, restoration rejecting exhausted active state. | +| Application tests | Authorization precedes protected effects; failed persistence cannot return committed success; retry results are consistent. | +| Persistence integration tests | Typed-ID round trips, case-sensitive uniqueness, rollback, competing updates, and idempotency constraints. | +| Boundary tests | Malformed IDs, wrong token issuer/audience/signature/time, spoofed tenant/customer context, no secret-bearing response payloads. | +| Registration integration tests | Duplicate finalization, lost response, crashes between separately committed steps, changed contact, incorrect challenge subject. | +| Architecture tests | Allowed exports only, no cycles, no cross-module repository access, framework-free domain, no provider SDK in public contracts. | + +Spring Modulith's verification can check cycles and internal-package access. Its +allowed-dependency restriction is optional and must be configured. Additional +architecture rules may be needed for the layering and framework constraints above. +[Module verification][5] + +An exception-safe aggregate, a passing unit test, and a passing concurrency test +prove different things. Record test scope and actual results separately. + +## 13. Migration and implementation order + +The following are planning references, not claims that tickets were created in an +external tracker. Keep correctness fixes separate from package moves and new behavior. + +| Order | Work | Completion condition | +| --- | --- | --- | +| 1 | Finish typed issuance (`PG-IDENTITY-109`). | `issue()` and `restore()` accept typed IDs; boundary callers compile; contract tests pass. | +| 2 | Review identifier implementations and tests. | Shared format rules and every ID's equality/validation contract are demonstrated. | +| 3 | Correct ownership imports (`PG-IDENTITY-110`). | `OtpId`/OTP enums belong to Verification; changes preserve behavior. | +| 4 | Decide exported contracts and enforce boundaries. | API/ID exports and allowed dependency graph are explicit and tested. | +| 5 | Complete non-customer subject design (`PG-IDENTITY-111`). | Registration challenges work without fake customers or dependency cycles. | +| 6 | Implement provider integration and account mapping. | Resource-server boundary and trusted identity mapping pass integration tests. | +| 7 | Implement registration/access/customer use cases. | Lifecycle, uniqueness, retry, and membership policies are demonstrated. | +| 8 | Harden persistence and verification evidence. | Concurrent consumption, replay prevention, delivery recovery, and revocation behavior are tested. | + +When replacing UUID challenges or raw external user IDs, choose a compatibility +window or explicit migration. Preserve existing links, outstanding challenges, +Redis keys, and dependent evidence. Never generate a fresh customer identity for +each historical request. Do not deploy incompatible API and persistence formats +without an agreed rollout sequence. + +**Completed refactoring:** typed customer/challenge contracts and Verification +package wiring, PostgreSQL concurrency control and scoped Vault secret adapters. +Keycloak setup, non-customer subjects and operation-bound evidence remain separate work. +See [the implementation guide](../docs/guides/postgresql-vault-migration.md) for exact +versions, role boundaries, migration history, key/pool lifecycle and test evidence. + +## 14. Open decisions and extraction criteria + +| Decision still needed | Must be settled before | +| --- | --- | +| Exported identifier packages and non-customer subject representation | Enforcing module dependencies and shipping registration verification. | +| Account/customer relationship cardinality and tenant lifecycle ownership | Finalizing schemas and uniqueness constraints. | +| Provider provisioning order and registration-session trust | Shipping pre-login registration and account linking. | +| Required verification inputs and pre-creation timestamp handling | Declaring the verification input contract complete. | +| Financial operation idempotency and durable publication | Extending the verified OTP concurrency contract to downstream financial effects. | +| Evidence format, consumer contract, and recovery semantics | Shipping sensitive-operation approval. | +| Token, permission, and cache freshness/revocation limits | Claiming timely account or membership revocation. | +| Outbox/delivery secret handling and retention | Enabling durable asynchronous delivery. | +| Pinned compatible framework/provider versions | Shipping the integration; current documentation is not a version selection. | + +A module becomes a candidate for extraction when measured workload asymmetry, +independent release needs, availability requirements, or security isolation justify +a separate deployment. Extraction must revisit network contracts, authentication, +data ownership, timeouts, retries, and local transaction assumptions. + +Four folders are not evidence that four services are needed. Shared deployment is +not a commitment to remain shared forever. + +Changes to module ownership, public contracts, trust boundaries, and transaction +scope require a reviewed architecture decision and an update to this document. + +## 15. References + +These primary references support the protocol and framework statements. PayGuard's +ownership, packaging, and workflow choices are project design decisions, not claims +that a referenced organization uses this architecture. References checked on +2026-10-10; use documentation compatible with the versions actually pinned in the build. + +1. [Keycloak Server Administration Guide][1] - authentication, sessions, WebAuthn, and passkeys. +2. [Spring Security JWT Resource Server][2] - token validation and audience configuration. +3. [OpenID Connect Core 1.0][3] - identity claims and issuer/subject stability. +4. [Spring Modulith Fundamentals][4] - module APIs, named interfaces, and dependency declarations. +5. [Spring Modulith Structure Verification][5] - architectural verification and its limits. +6. [OWASP Authorization Cheat Sheet][6] - deny-by-default and request-level access checks. +7. [OWASP Transaction Authorization Cheat Sheet][7] - operation binding and confirmation integrity. +8. [Java 21 SecureRandom API][8] - cryptographically strong random generation. + +[1]: https://www.keycloak.org/docs/latest/server_admin/index.html +[2]: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html +[3]: https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability +[4]: https://docs.spring.io/spring-modulith/reference/fundamentals.html +[5]: https://docs.spring.io/spring-modulith/reference/verification.html +[6]: https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html +[7]: https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html +[8]: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/security/SecureRandom.html diff --git a/payguard-identity-service/Dockerfile b/payguard-identity-service/Dockerfile new file mode 100644 index 0000000..fd90f97 --- /dev/null +++ b/payguard-identity-service/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1 +FROM eclipse-temurin:25-jdk-alpine@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a AS build +WORKDIR /workspace +COPY . . +RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-identity-service -am -DskipTests package + +FROM eclipse-temurin:25-jre-alpine@sha256:3c0a9084927a221ccd1d007fcaf614465672c0af37aaa834c5184483afe56d61 +WORKDIR /app +RUN addgroup -S payguard && adduser -S payguard -G payguard +COPY --from=build /workspace/payguard-identity-service/target/payguard-identity-service-*.jar /app/app.jar +USER payguard +EXPOSE 8084 +ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "-jar", "/app/app.jar"] diff --git a/payguard-identity-service/README.md b/payguard-identity-service/README.md new file mode 100644 index 0000000..3a4827b --- /dev/null +++ b/payguard-identity-service/README.md @@ -0,0 +1,32 @@ +# PayGuard Identity Service + +Identity contains access, customer, verification and registration modules. Verification implements existing-customer OTP challenges with PostgreSQL JPA persistence, audit records, Redis quotas and Vault-backed secrets. See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and implementation limits. + +## Existing API + +- `POST /identity/challenges` issues a challenge for a case-sensitive `cus_...` customer in the existing `userId` field. +- `POST /identity/challenges/{challengeId}/verify` verifies the code and returns `challengeId`, `purpose` and `verifiedAt`. This result is not an authorization credential. The retired step-up-token endpoint remains absent. + +Random codes use salted, versioned HMAC-SHA256 hashes. Vault KV v2 supplies the named key ring; an explicitly named legacy key is required for old `salt:digest` records. Rotation retains old keys throughout the configured OTP compatibility window. Device-binding hashes use the same versioned contract. + +Authenticator codes use the existing RFC 6238 verifier and a customer-scoped Vault seed adapter. A missing seed returns absence; denied access or unavailable Vault fails as a dependency error before consuming a code-check attempt. This adapter does not implement enrollment or recovery. Mock delivery discards codes without logging them; actual SMS/email/push providers remain absent and fail explicitly when selected. + +## Persistence and concurrency + +PostgreSQL stores prefixed challenge/customer strings unchanged, UUID audit IDs and microsecond-precision instants. `withLockedChallenge` restores the latest row under a database lock and commits the transition before a business rejection is thrown. Competing callers get one fresh `VERIFIED`, followed by `ALREADY_USED`; failed attempts serialize and cannot overwrite one another. + +Issuance locks a durable customer/purpose scope, including first issuance, and a partial unique index permits one ACTIVE row per scope. Other customers and purposes remain independent. Remote Vault, delivery, quota, audit and Kafka operations do not belong to the challenge transaction. Audit/event/delivery failures can still follow committed state; durable delivery is separate work. + +Runtime credentials come from a service-scoped Vault AppRole and a rotating pool. Flyway runs separately with migration privileges; Hibernate validates the migrated mapping. Oracle V1/V2 histories remain unchanged under `db/legacy/oracle`. Populated legacy UUID records require an approved mapping before migration. + +## Verification and operations + +From the repository root, with JDK 25 and Docker: + +```bash +./mvnw -B -ntp -pl payguard-identity-service -am test +``` + +PostgreSQL/Redis integration tests run normally. The dedicated persistent TLS Vault suite has an explicit local/CI runner, with no permanently unset environment flag. See [the implementation guide](../docs/guides/postgresql-vault-migration.md) for provisioning, migration, credentials, rotation, outages and test results. + +Remaining limits include trusted caller/tenant authorization, provider delivery, seed enrollment, Redis increment/expiry atomicity, durable audit/event publication and six existing typed-ID PMD naming findings. Current domain regression tests pass. Retention remains at least three calendar years after expiry; no purge job is introduced. diff --git a/payguard-identity-service/pom.xml b/payguard-identity-service/pom.xml new file mode 100644 index 0000000..567cec4 --- /dev/null +++ b/payguard-identity-service/pom.xml @@ -0,0 +1,125 @@ + + + 4.0.0 + + + dev.amg.payguard + payguard-parent + 1.0.0-SNAPSHOT + + + payguard-identity-service + PayGuard OTP Service + + 2.1.1 + + + + + org.springframework.modulith + spring-modulith-bom + ${spring-modulith.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jackson + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-kafka + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.testcontainers + testcontainers-postgresql + test + + + com.redis + testcontainers-redis + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + dev.amg.payguard + payguard-shared + 1.0.0-SNAPSHOT + compile + + + org.springframework.modulith + spring-modulith-starter-core + + + org.springframework.modulith + spring-modulith-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + repackage + + repackage + + + + + + + diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java new file mode 100644 index 0000000..db086f4 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/application/OtpApplicationService.java @@ -0,0 +1,235 @@ +package dev.amg.payguard.identity.verification.application; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import dev.amg.payguard.identity.verification.application.port.TotpVerification; +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import dev.amg.payguard.identity.verification.application.port.VerificationQuota; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.event.OtpEvent; +import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.function.Supplier; + +public final class OtpApplicationService { + + private final OtpRepository challenges; + private final OtpCodeHasher hasher; + private final OtpCodeGenerator codeGenerator; + private final TotpVerification authenticatorCodeVerifier; + private final OtpDelivery delivery; + private final VerificationQuota rateLimiter; + private final Supplier ids; + private final OtpEventPublisher events; + private final VerificationAudit audit; + private final Clock clock; + private final Duration randomTtl; + private final Duration totpTtl; + private final Duration auditRetention; + private final int maxAttempts; + + public OtpApplicationService( + OtpRepository challenges, + OtpCodeHasher hasher, + OtpCodeGenerator codeGenerator, + TotpVerification authenticatorCodeVerifier, + OtpDelivery delivery, + VerificationQuota rateLimiter, + Supplier ids, + OtpEventPublisher events, + VerificationAudit audit, + Clock clock, + Duration randomTtl, + Duration totpTtl, + Duration auditRetention, + int maxAttempts) { + this.challenges = challenges; + this.hasher = hasher; + this.codeGenerator = codeGenerator; + this.authenticatorCodeVerifier = authenticatorCodeVerifier; + this.delivery = delivery; + this.rateLimiter = rateLimiter; + this.ids = ids; + this.events = events; + this.audit = audit; + this.clock = clock; + this.randomTtl = randomTtl; + this.totpTtl = totpTtl; + this.auditRetention = auditRetention; + this.maxAttempts = maxAttempts; + } + + public IssuedOtp issue(IssueOtpCommand command) { + Objects.requireNonNull(command.customerId(), "customerId must not be null"); + if (!rateLimiter.tryAcquire( + new VerificationQuota.Request( + command.customerId(), + command.purpose(), + command.sourceIp(), + VerificationQuota.Operation.ISSUE))) { + throw new OtpRateLimitExceededException(); + } + Instant now = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + boolean totp = command.channel() == OtpChannel.AUTHENTICATOR_APP; + Duration ttl = totp ? totpTtl : randomTtl; + OtpId challengeId = ids.get(); + String code = totp ? null : codeGenerator.generate(); + OTP challenge = + OTP.issue( + challengeId, + command.customerId(), + new OtpSnapshot( + command.purpose(), + command.channel(), + totp ? OtpVerificationMode.TOTP : OtpVerificationMode.RANDOM_NUMERIC, + code == null ? null : hasher.hash(code), + command.deviceFingerprint() == null + ? null + : hasher.hash(command.deviceFingerprint()), + maxAttempts, + now, + now.plus(ttl), + now.plus(auditRetention), + 0, + OtpStatus.ACTIVE, + null)); + challenges.replaceActive(challenge); + if (!totp) { + delivery.submit( + new OtpDeliveryRequest( + challengeId, + command.customerId(), + command.purpose(), + command.channel(), + command.destinationRef(), + code)); + } + publish( + new OtpEvent( + "identity.challenge-issued", + challengeId, + command.customerId(), + command.purpose(), + command.channel(), + null, + now, + "challenge-issued")); + audit.record( + new VerificationAuditEntry( + challengeId, + command.customerId(), + VerificationAuditEntry.Action.ISSUED, + "challenge-issued", + command.sourceIp(), + now)); + return new IssuedOtp( + challengeId, command.purpose(), command.channel(), challenge.getSnapshot().expiresAt()); + } + + public VerifiedOtp verify(OtpVerificationCommand command) { + OTP challenge = + challenges + .findById(command.challengeId()) + .orElseThrow( + () -> + new dev.amg.payguard.identity.verification.application + .OtpChallengeNotFoundException(command.challengeId())); + if (!challenge.getCustomerId().equals(command.customerId())) { + throw new dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException( + command.challengeId()); + } + if (!rateLimiter.tryAcquire( + new VerificationQuota.Request( + command.customerId(), + command.purpose(), + command.sourceIp(), + VerificationQuota.Operation.VERIFY))) { + throw new OtpRateLimitExceededException(); + } + Instant now = clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + boolean authenticatorValid = + challenge.getSnapshot().verificationMode() == OtpVerificationMode.TOTP + && challenge.getSnapshot().status() == OtpStatus.ACTIVE + && command.purpose() == challenge.getSnapshot().purpose() + && now.isBefore(challenge.getSnapshot().expiresAt()) + && authenticatorCodeVerifier.verify(command.customerId(), command.code(), now); + VerificationOutcome outcome = + challenges + .withLockedChallenge( + command.challengeId(), + current -> { + if (!current.getCustomerId().equals(command.customerId())) { + throw new OtpChallengeNotFoundException(command.challengeId()); + } + Instant lockedAt = + clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + OtpVerificationResult currentResult = + current.verify( + new VerifyOtpCommand( + command.purpose(), + command.code(), + command.deviceFingerprint(), + lockedAt, + authenticatorValid), + hasher); + return new VerificationOutcome(current, currentResult, lockedAt); + }) + .orElseThrow(() -> new OtpChallengeNotFoundException(command.challengeId())); + challenge = outcome.challenge(); + now = outcome.at(); + OtpVerificationResult result = outcome.result(); + String reason = result.name().toLowerCase(java.util.Locale.ROOT); + audit.record( + new VerificationAuditEntry( + challenge.getId(), + command.customerId(), + VerificationAuditEntry.Action.VERIFY, + reason, + command.sourceIp(), + now)); + if (result != OtpVerificationResult.VERIFIED) { + publish( + new OtpEvent( + result == OtpVerificationResult.LOCKED ? "identity.locked-out" : "identity.failed", + challenge.getId(), + command.customerId(), + command.purpose(), + challenge.getSnapshot().channel(), + result, + now, + reason)); + throw new dev.amg.payguard.identity.verification.application.OtpVerificationException(result); + } + publish( + new OtpEvent( + "identity.verified", + challenge.getId(), + command.customerId(), + command.purpose(), + challenge.getSnapshot().channel(), + result, + now, + "verified")); + return new VerifiedOtp(challenge.getId(), command.purpose(), now); + } + + private record VerificationOutcome(OTP challenge, OtpVerificationResult result, Instant at) {} + + private void publish(OtpEvent event) { + events.publish(event); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java new file mode 100644 index 0000000..0d9452a --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/config/OtpConfiguration.java @@ -0,0 +1,99 @@ +package dev.amg.payguard.identity.verification.infrastructure.config; + +import dev.amg.payguard.identity.verification.application.OtpApplicationService; +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.TotpVerification; +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationQuota; +import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import dev.amg.payguard.identity.verification.infrastructure.security.OtpIdGenerator; +import java.time.Clock; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OtpConfiguration { + + @Bean + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty( + name = "payguard.vault.enabled", + havingValue = "true") + org.springframework.boot.health.contributor.HealthIndicator otpKeysHealthIndicator( + dev.amg.payguard.identity.verification.infrastructure.security.VaultPepperKeys keys) { + return () -> { + try { + keys.get(); + return (keys.refreshHealthy() + ? org.springframework.boot.health.contributor.Health.up() + : org.springframework.boot.health.contributor.Health.down()) + .build(); + } catch (infrastructure.vault.VaultUnavailableException exception) { + return org.springframework.boot.health.contributor.Health.down().build(); + } + }; + } + + @Bean + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty( + name = "payguard.vault.enabled", + havingValue = "true") + dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher vaultOtpHasher( + dev.amg.payguard.identity.verification.infrastructure.security.VaultPepperKeys keys) { + return new dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher( + keys); + } + + @Bean + @org.springframework.context.annotation.Profile("test") + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty( + name = "payguard.vault.enabled", + havingValue = "false") + dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher + syntheticOtpHasher() { + return new dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher( + "synthetic-test-pepper-at-least-32-characters"); + } + + @Bean + Clock identityClock() { + return Clock.systemUTC(); + } + + @Bean + OtpApplicationService identityApplicationService( + OtpRepository challenges, + OtpCodeHasher hasher, + OtpCodeGenerator generator, + TotpVerification authenticatorCodeVerifier, + OtpDelivery delivery, + VerificationQuota rateLimiter, + OtpIdGenerator ids, + OtpEventPublisher events, + VerificationAudit audit, + Clock identityClock, + @Value("${identity.challenge.random-ttl:PT5M}") Duration randomTtl, + @Value("${identity.challenge.totp-ttl:PT30S}") Duration totpTtl, + @Value("${identity.audit.retention:P1097D}") Duration auditRetention, + @Value("${identity.challenge.max-attempts:5}") int maxAttempts) { + return new OtpApplicationService( + challenges, + hasher, + generator, + authenticatorCodeVerifier, + delivery, + rateLimiter, + ids::generate, + events, + audit, + identityClock, + randomTtl, + totpTtl, + auditRetention, + maxAttempts); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java new file mode 100644 index 0000000..58a838f --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/JpaOTPRepositoryAdapter.java @@ -0,0 +1,88 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import java.util.Optional; +import java.util.function.Function; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class JpaOTPRepositoryAdapter implements OtpRepository { + + private final OtpChallengeJpaRepository repository; + private final JdbcTemplate jdbc; + + @jakarta.persistence.PersistenceContext private jakarta.persistence.EntityManager entityManager; + + public JpaOTPRepositoryAdapter(OtpChallengeJpaRepository repository, JdbcTemplate jdbc) { + this.repository = repository; + this.jdbc = jdbc; + } + + @Override + @Transactional + public void replaceActive(OTP replacement) { + String customer = replacement.getCustomerId().getValue(); + String purpose = replacement.getSnapshot().purpose().name(); + jdbc.update( + "INSERT INTO identity_challenge_scope(user_id,purpose) VALUES (?,?) ON CONFLICT DO NOTHING", + customer, + purpose); + jdbc.queryForObject( + "SELECT user_id FROM identity_challenge_scope WHERE user_id=? AND purpose=? FOR UPDATE", + String.class, + customer, + purpose); + repository + .findByUserIdAndPurposeAndStatus( + replacement.getCustomerId().getValue(), + replacement.getSnapshot().purpose(), + OtpStatus.ACTIVE) + .forEach( + entity -> { + OTP challenge = entity.toDomain(); + challenge.invalidate(); + entity.copyFrom(challenge); + repository.save(entity); + }); + repository.flush(); + entityManager.persist(OtpChallengeEntity.from(replacement)); + entityManager.flush(); + } + + @Override + @Transactional + public void save(OTP challenge) { + if (repository.existsById(challenge.getId().value())) { + throw new IllegalStateException("Existing challenges require a locked mutation"); + } + entityManager.persist(OtpChallengeEntity.from(challenge)); + entityManager.flush(); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 10) + public Optional withLockedChallenge(OtpId id, Function mutation) { + return repository + .findLockedById(id.value()) + .map( + entity -> { + OTP current = entity.toDomain(); + T result = mutation.apply(current); + entity.copyFrom(current); + repository.flush(); + return result; + }); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(OtpId challengeId) { + return repository.findById(challengeId.value()).map(OtpChallengeEntity::toDomain); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java new file mode 100644 index 0000000..e617090 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeEntity.java @@ -0,0 +1,131 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "identity_challenge") +public class OtpChallengeEntity { + + @Id + @Column(length = 26) + private String id; + + @Column(name = "user_id", nullable = false, length = 255) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OtpPurpose purpose; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private OtpChannel channel; + + @Enumerated(EnumType.STRING) + @Column(name = "verification_mode", nullable = false, length = 32) + private OtpVerificationMode verificationMode; + + @Column(name = "code_hash", length = 512) + private String codeHash; + + @Column(name = "device_binding_hash", length = 512) + private String deviceBindingHash; + + @Column(name = "attempts", nullable = false) + private int attempts; + + @Column(name = "max_attempts", nullable = false) + private int maxAttempts; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private OtpStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "consumed_at") + private Instant consumedAt; + + @Column(name = "retention_until", nullable = false) + private Instant retentionUntil; + + protected OtpChallengeEntity() {} + + private OtpChallengeEntity(OTP challenge) { + copyFrom(challenge); + } + + static OtpChallengeEntity from(OTP challenge) { + return new OtpChallengeEntity(challenge); + } + + final void copyFrom(OTP challenge) { + id = challenge.getId().value(); + userId = challenge.getCustomerId().getValue(); + purpose = challenge.getSnapshot().purpose(); + channel = challenge.getSnapshot().channel(); + verificationMode = challenge.getSnapshot().verificationMode(); + codeHash = challenge.getSnapshot().codeHash(); + deviceBindingHash = challenge.getSnapshot().deviceBindingHash(); + attempts = challenge.getSnapshot().attempts(); + maxAttempts = challenge.getSnapshot().maxAttempts(); + status = challenge.getSnapshot().status(); + createdAt = challenge.getSnapshot().createdAt(); + expiresAt = challenge.getSnapshot().expiresAt(); + consumedAt = challenge.getSnapshot().consumedAt(); + retentionUntil = challenge.getSnapshot().retentionUntil(); + } + + OTP toDomain() { + return OTP.restore( + OtpId.parse(id), + CustomerId.parse(userId), + OtpSnapshot.create( + purpose, + channel, + verificationMode, + codeHash, + deviceBindingHash, + maxAttempts, + createdAt, + expiresAt, + retentionUntil, + attempts, + status, + consumedAt)); + } + + public String getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public OtpPurpose getPurpose() { + return purpose; + } + + public OtpStatus getStatus() { + return status; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java new file mode 100644 index 0000000..2c0694f --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/persistence/OtpChallengeJpaRepository.java @@ -0,0 +1,22 @@ +package dev.amg.payguard.identity.verification.infrastructure.persistence; + +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import jakarta.persistence.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +interface OtpChallengeJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + List findByUserIdAndPurposeAndStatus( + String userId, OtpPurpose purpose, OtpStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from OtpChallengeEntity c where c.id = :id") + Optional findLockedById(@Param("id") String id); +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java new file mode 100644 index 0000000..fb79f7d --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasher.java @@ -0,0 +1,88 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.verification.domain.port.OtpCodeHasher; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Map; +import java.util.function.Supplier; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class HmacOtpCodeHasher implements OtpCodeHasher { + + private static final String ALGORITHM = "HmacSHA256"; + private static final int VERSIONED_PARTS = 3; + private final SecureRandom secureRandom = new SecureRandom(); + private final Supplier keys; + + public HmacOtpCodeHasher(VaultPepperKeys keys) { + this((Supplier) keys); + } + + public HmacOtpCodeHasher(Supplier keys) { + this.keys = keys; + } + + /** Explicit synthetic/legacy wiring; production obtains a named key ring from Vault. */ + public HmacOtpCodeHasher(String pepper) { + this(() -> new PepperKeyRing("test", Map.of("test", pepper), "test")); + } + + @Override + public String hash(String value) { + byte[] salt = new byte[16]; + secureRandom.nextBytes(salt); + PepperKeyRing ring = keys.get(); + return ring.current() + + ":" + + HexFormat.of().formatHex(salt) + + ":" + + HexFormat.of().formatHex(digest(value, salt, ring.keys().get(ring.current()))); + } + + @Override + public boolean matches(String value, String encodedHash) { + if (value == null || encodedHash == null) { + return false; + } + try { + PepperKeyRing ring = keys.get(); + String[] parts = encodedHash.split(":", -1); + String keyId; + int saltIndex; + if (parts.length == VERSIONED_PARTS) { + keyId = parts[0]; + saltIndex = 1; + } else if (parts.length == 2 && ring.legacy() != null) { + keyId = ring.legacy(); + saltIndex = 0; + } else { + return false; + } + String pepper = ring.keys().get(keyId); + if (pepper == null) { + return false; + } + byte[] salt = HexFormat.of().parseHex(parts[saltIndex]); + byte[] expected = HexFormat.of().parseHex(parts[saltIndex + 1]); + return salt.length == 16 + && expected.length == 32 + && MessageDigest.isEqual(expected, digest(value, salt, pepper)); + } catch (IllegalArgumentException exception) { + return false; + } + } + + private byte[] digest(String value, byte[] salt, String pepper) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(pepper.getBytes(StandardCharsets.UTF_8), ALGORITHM)); + mac.update(salt); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (java.security.GeneralSecurityException exception) { + throw new IllegalStateException("HMAC-SHA256 is unavailable", exception); + } + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java new file mode 100644 index 0000000..4afc5c2 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/PepperKeyRing.java @@ -0,0 +1,25 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Named HMAC keys; a legacy key must be explicitly selected for unversioned stored hashes. */ +public record PepperKeyRing(String current, Map keys, @Nullable String legacy) { + public PepperKeyRing { + keys = Map.copyOf(keys); + if (!keys.containsKey(current) + || (legacy != null && !keys.containsKey(legacy)) + || keys.entrySet().stream() + .anyMatch( + entry -> + !entry.getKey().matches("[A-Za-z0-9_-]{1,32}") + || entry.getValue().length() < 32)) { + throw new IllegalArgumentException("Invalid HMAC key configuration"); + } + } + + @Override + public String toString() { + return "PepperKeyRing[REDACTED]"; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java new file mode 100644 index 0000000..683d753 --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultAuthenticatorSecretAdapter.java @@ -0,0 +1,42 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.verification.application.port.AuthenticatorSecretPort; +import infrastructure.vault.VaultClient; +import infrastructure.vault.VaultUnavailableException; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** Reads a scoped provisioned seed; absence is distinct from a dependency or policy failure. */ +@Component +@ConditionalOnProperty(name = "payguard.vault.enabled", havingValue = "true") +public final class VaultAuthenticatorSecretAdapter implements AuthenticatorSecretPort { + private final VaultClient vault; + private final String prefix; + + public VaultAuthenticatorSecretAdapter( + VaultClient vault, + @org.springframework.beans.factory.annotation.Value("${payguard.vault.secret-prefix}") + String prefix) { + this.vault = vault; + if (!prefix.matches("[a-z][a-z0-9-]*/identity")) { + throw new IllegalArgumentException("Invalid Identity secret scope"); + } + this.prefix = prefix; + } + + @Override + public Optional secretFor(CustomerId customerId) { + return vault + .readOptional("secret/data/" + prefix + "/authenticators/" + customerId.getValue()) + .map( + response -> { + String seed = response.path("data").path("data").path("seed").asText(); + if (!seed.matches("[A-Z2-7]{16,128}={0,6}")) { + throw new VaultUnavailableException(); + } + return seed; + }); + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java new file mode 100644 index 0000000..655eb5f --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultPepperKeys.java @@ -0,0 +1,126 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import infrastructure.vault.VaultClient; +import infrastructure.vault.VaultUnavailableException; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * Refreshes keys outside persistence locks. A removed key is rejected during the configured OTP + * compatibility window. Cached keys have a bounded outage lifetime; no default key is supplied. + */ +@Component +@ConditionalOnProperty(name = "payguard.vault.enabled", havingValue = "true") +public final class VaultPepperKeys implements Supplier, AutoCloseable { + private static final org.slf4j.Logger LOG = + org.slf4j.LoggerFactory.getLogger(VaultPepperKeys.class); + private final VaultClient vault; + private final String prefix; + private final Duration compatibility; + private final Map lastUsed = new HashMap<>(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("otp-key-refresh").factory()); + private volatile PepperKeyRing ring; + private volatile Instant fetchedAt; + private volatile boolean latestRefreshSucceeded = true; + + public VaultPepperKeys( + VaultClient vault, + @Value("${identity.challenge.random-ttl:PT5M}") Duration randomTtl, + @Value("${identity.challenge.totp-ttl:PT30S}") Duration totpTtl, + @Value("${payguard.vault.secret-prefix}") String prefix) { + this.vault = vault; + if (!prefix.matches("[a-z][a-z0-9-]*/identity")) { + throw new IllegalArgumentException("Invalid Identity secret scope"); + } + this.prefix = prefix; + compatibility = randomTtl.compareTo(totpTtl) >= 0 ? randomTtl : totpTtl; + ring = fetch(); + fetchedAt = Instant.now(); + ring.keys().keySet().forEach(key -> lastUsed.put(key, fetchedAt)); + scheduler.scheduleWithFixedDelay(this::refresh, 5, 5, TimeUnit.SECONDS); + } + + private PepperKeyRing fetch() { + try { + var data = vault.read("secret/data/" + prefix + "/otp-keys").path("data").path("data"); + Map keys = new HashMap<>(); + data.path("keys") + .properties() + .forEach(entry -> keys.put(entry.getKey(), entry.getValue().asText())); + String legacy = data.path("legacy").asText(); + return new PepperKeyRing( + data.path("current").asText(), keys, legacy.isBlank() ? null : legacy); + } catch (RuntimeException exception) { + throw new VaultUnavailableException(exception); + } + } + + private synchronized void refresh() { + try { + PepperKeyRing next = fetch(); + Instant now = Instant.now(); + lastUsed.put(ring.current(), now); + for (var entry : ring.keys().entrySet()) { + String nextValue = next.keys().get(entry.getKey()); + if (nextValue != null && !entry.getValue().equals(nextValue)) { + throw new VaultUnavailableException(); + } + Instant last = lastUsed.getOrDefault(entry.getKey(), fetchedAt); + if (nextValue == null && now.isBefore(last.plus(compatibility).plusSeconds(10))) { + throw new VaultUnavailableException(); + } + } + validateLegacySelection(next, now); + if (!ring.current().equals(next.current())) { + lastUsed.put(ring.current(), now); + } + ring = next; + fetchedAt = now; + latestRefreshSucceeded = true; + } catch (VaultUnavailableException exception) { + /* Retain valid cached keys temporarily. A prolonged failure makes get() fail closed. */ + if (latestRefreshSucceeded) + LOG.warn("OTP key refresh unavailable; bounded cached keys retained"); + latestRefreshSucceeded = false; + } + } + + private void validateLegacySelection(PepperKeyRing next, Instant now) { + String legacy = ring.legacy(); + if (legacy != null && !legacy.equals(next.legacy())) { + Instant last = lastUsed.getOrDefault(legacy, fetchedAt); + if (next.legacy() != null || now.isBefore(last.plus(compatibility).plusSeconds(10))) { + /* Unversioned hashes cannot identify another key; never guess a replacement legacy key. */ + throw new VaultUnavailableException(); + } + } + } + + @Override + public PepperKeyRing get() { + if (Instant.now().isAfter(fetchedAt.plus(Duration.ofMinutes(5)))) { + throw new VaultUnavailableException(); + } + return ring; + } + + @Override + public void close() { + scheduler.shutdownNow(); + } + + public boolean refreshHealthy() { + return latestRefreshSucceeded; + } +} diff --git a/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java new file mode 100644 index 0000000..95463cd --- /dev/null +++ b/payguard-identity-service/src/main/java/dev/amg/payguard/identity/verification/interfaces/rest/OtpExceptionHandler.java @@ -0,0 +1,53 @@ +package dev.amg.payguard.identity.verification.interfaces.rest; + +import dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException; +import dev.amg.payguard.identity.verification.application.OtpRateLimitExceededException; +import dev.amg.payguard.identity.verification.application.OtpVerificationException; +import java.time.Instant; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class OtpExceptionHandler { + + @ExceptionHandler(OtpChallengeNotFoundException.class) + ResponseEntity notFound(OtpChallengeNotFoundException exception) { + return response(HttpStatus.NOT_FOUND, "challenge_not_found"); + } + + @ExceptionHandler(OtpRateLimitExceededException.class) + ResponseEntity rateLimited(OtpRateLimitExceededException exception) { + return response(HttpStatus.TOO_MANY_REQUESTS, "rate_limit_exceeded"); + } + + @ExceptionHandler(OtpVerificationException.class) + ResponseEntity verificationFailed(OtpVerificationException exception) { + HttpStatus status = + switch (exception.result()) { + case LOCKED -> HttpStatus.TOO_MANY_REQUESTS; + case ALREADY_USED, INVALIDATED -> HttpStatus.CONFLICT; + case EXPIRED -> HttpStatus.GONE; + default -> HttpStatus.UNAUTHORIZED; + }; + return response(status, exception.result().name().toLowerCase(java.util.Locale.ROOT)); + } + + private static ResponseEntity response(HttpStatus status, String code) { + return ResponseEntity.status(status).body(new ErrorResponse(code, Instant.now())); + } + + @ExceptionHandler(infrastructure.vault.VaultUnavailableException.class) + ResponseEntity secretDependencyUnavailable( + infrastructure.vault.VaultUnavailableException exception) { + return response(HttpStatus.SERVICE_UNAVAILABLE, "secret_dependency_unavailable"); + } + + @ExceptionHandler(IllegalArgumentException.class) + ResponseEntity invalidInput(IllegalArgumentException exception) { + return response(HttpStatus.BAD_REQUEST, "invalid_request"); + } + + record ErrorResponse(String code, Instant timestamp) {} +} diff --git a/payguard-identity-service/src/main/resources/application.yaml b/payguard-identity-service/src/main/resources/application.yaml new file mode 100644 index 0000000..6acf651 --- /dev/null +++ b/payguard-identity-service/src/main/resources/application.yaml @@ -0,0 +1,73 @@ +spring: + application: + name: payguard-identity-service + datasource: + url: ${SPRING_DATASOURCE_URL} + driver-class-name: org.postgresql.Driver + jpa: + properties: + hibernate.default_schema: payguard + open-in-view: false + hibernate: + ddl-auto: validate + flyway: + enabled: false + locations: classpath:db/migration/postgresql + clean-disabled: true + url: ${SPRING_DATASOURCE_URL} + user: ${PAYGUARD_MIGRATION_USERNAME} + password: ${PAYGUARD_MIGRATION_PASSWORD} + default-schema: payguard + schemas: payguard + create-schemas: false + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + +server: + port: ${OTP_SERVICE_PORT:8084} + +identity: + delivery: + mode: ${OTP_DELIVERY_MODE:mock} + challenge: + random-ttl: ${OTP_RANDOM_TTL:PT5M} + totp-ttl: ${OTP_TOTP_TTL:PT30S} + max-attempts: ${OTP_MAX_ATTEMPTS:5} + rate-limit: + max-issues: ${OTP_MAX_ISSUES:3} + max-verifications: ${OTP_MAX_VERIFICATIONS:20} + window: ${OTP_RATE_LIMIT_WINDOW:PT15M} + audit: + retention: ${OTP_AUDIT_RETENTION:P1097D} + kafka: + topic: ${OTP_KAFKA_TOPIC:identity.events.v1} + +payguard: + vault: + enabled: true + address: ${VAULT_ADDR} + ca-file: ${VAULT_CA_FILE} + role-id-file: ${VAULT_ROLE_ID_FILE} + secret-id-file: ${VAULT_SECRET_ID_FILE} + secret-prefix: ${PAYGUARD_ENVIRONMENT:local}/identity + database-role: ${PAYGUARD_ENVIRONMENT:local}-identity-runtime + +management: + endpoint: + health: + probes: + enabled: true + group: + readiness: + include: readinessState,db,vault,otpKeys + endpoints: + web: + exposure: + include: health,info diff --git a/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql b/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql new file mode 100644 index 0000000..8b81e99 --- /dev/null +++ b/payguard-identity-service/src/main/resources/db/legacy/oracle/V1__otp_schema.sql @@ -0,0 +1,41 @@ +CREATE TABLE identity_challenge ( + id RAW(16) NOT NULL, + user_id VARCHAR2(255 CHAR) NOT NULL, + purpose VARCHAR2(32 CHAR) NOT NULL, + channel VARCHAR2(32 CHAR) NOT NULL, + verification_mode VARCHAR2(32 CHAR) NOT NULL, + code_hash VARCHAR2(512 CHAR), + device_binding_hash VARCHAR2(512 CHAR), + attempts NUMBER(3) DEFAULT 0 NOT NULL, + max_attempts NUMBER(3) NOT NULL, + status VARCHAR2(20 CHAR) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + consumed_at TIMESTAMP WITH TIME ZONE, + retention_until TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT pk_identity_challenge PRIMARY KEY (id), + CONSTRAINT ck_identity_challenge_attempts CHECK (attempts >= 0 AND attempts <= max_attempts) +); + +CREATE INDEX ix_identity_challenge_active + ON identity_challenge (user_id, purpose, status); +CREATE INDEX ix_identity_challenge_retention + ON identity_challenge (retention_until); + +CREATE TABLE identity_audit_log ( + id RAW(16) DEFAULT SYS_GUID() NOT NULL, + challenge_id RAW(16) NOT NULL, + user_id VARCHAR2(255 CHAR) NOT NULL, + action VARCHAR2(32 CHAR) NOT NULL, + reason VARCHAR2(128 CHAR) NOT NULL, + source_ip VARCHAR2(64 CHAR), + occurred_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT pk_identity_audit_log PRIMARY KEY (id), + CONSTRAINT fk_identity_audit_challenge FOREIGN KEY (challenge_id) + REFERENCES identity_challenge (id) +); + +CREATE INDEX ix_identity_audit_subject_time + ON identity_audit_log (user_id, occurred_at); +CREATE INDEX ix_identity_audit_challenge + ON identity_audit_log (challenge_id, occurred_at); diff --git a/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql b/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql new file mode 100644 index 0000000..003463e --- /dev/null +++ b/payguard-identity-service/src/main/resources/db/legacy/oracle/V2__prefixed_challenge_ids.sql @@ -0,0 +1,25 @@ +-- UUID-to-prefixed-ID mapping cannot be inferred. Fail explicitly for populated +-- installations instead of deleting rows or inventing customer/challenge IDs. +DECLARE + challenge_count NUMBER; + audit_count NUMBER; +BEGIN + SELECT COUNT(*) INTO challenge_count FROM identity_challenge; + SELECT COUNT(*) INTO audit_count FROM identity_audit_log; + IF challenge_count > 0 OR audit_count > 0 THEN + RAISE_APPLICATION_ERROR(-20001, + 'Prefixed OTP IDs require an approved mapping for existing challenge/audit rows'); + END IF; +END; +/ + +ALTER TABLE identity_audit_log DROP CONSTRAINT fk_identity_audit_challenge; +-- Oracle represents the time-zone component of this composite index as a +-- function-based expression, preventing modification of another indexed column. +DROP INDEX ix_identity_audit_challenge; +ALTER TABLE identity_challenge MODIFY (id VARCHAR2(26 CHAR)); +ALTER TABLE identity_audit_log MODIFY (challenge_id VARCHAR2(26 CHAR)); +ALTER TABLE identity_audit_log ADD CONSTRAINT fk_identity_audit_challenge + FOREIGN KEY (challenge_id) REFERENCES identity_challenge (id); +CREATE INDEX ix_identity_audit_challenge + ON identity_audit_log (challenge_id, occurred_at); diff --git a/payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql b/payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql new file mode 100644 index 0000000..e3b6381 --- /dev/null +++ b/payguard-identity-service/src/main/resources/db/migration/postgresql/V1__identity_schema.sql @@ -0,0 +1,40 @@ +CREATE TABLE identity_challenge ( + id varchar(26) COLLATE "C" PRIMARY KEY, + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + channel varchar(32) NOT NULL, + verification_mode varchar(32) NOT NULL, + code_hash varchar(512), + device_binding_hash varchar(512), + attempts integer DEFAULT 0 NOT NULL, + max_attempts integer NOT NULL, + status varchar(20) NOT NULL, + created_at timestamptz(6) NOT NULL, + expires_at timestamptz(6) NOT NULL, + consumed_at timestamptz(6), + retention_until timestamptz(6) NOT NULL, + CONSTRAINT ck_identity_challenge_id CHECK (id ~ '^otp_[A-Za-z0-9]{22}$'), + CONSTRAINT ck_identity_customer_id CHECK (user_id ~ '^cus_[A-Za-z0-9]{22}$'), + CONSTRAINT ck_identity_attempts CHECK (max_attempts > 0 AND attempts BETWEEN 0 AND max_attempts), + CONSTRAINT ck_identity_active_attempts CHECK (status <> 'ACTIVE' OR attempts < max_attempts), + CONSTRAINT ck_identity_status CHECK (status IN ('ACTIVE', 'USED', 'LOCKED', 'EXPIRED', 'INVALIDATED')), + CONSTRAINT ck_identity_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_identity_consumption CHECK ((status = 'USED') = (consumed_at IS NOT NULL)), + CONSTRAINT ck_identity_consumption_time CHECK (consumed_at IS NULL OR (consumed_at >= created_at AND consumed_at < expires_at)), + CONSTRAINT ck_identity_code_hash CHECK (verification_mode <> 'RANDOM_NUMERIC' OR (code_hash IS NOT NULL AND length(btrim(code_hash)) > 0)), + CONSTRAINT ck_identity_binding_hash CHECK (device_binding_hash IS NULL OR length(btrim(device_binding_hash)) > 0) +); +CREATE INDEX ix_identity_challenge_active ON identity_challenge(user_id, purpose, status); +CREATE INDEX ix_identity_challenge_retention ON identity_challenge(retention_until); + +CREATE TABLE identity_audit_log ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + challenge_id varchar(26) COLLATE "C" NOT NULL REFERENCES identity_challenge(id), + user_id varchar(255) COLLATE "C" NOT NULL, + action varchar(32) NOT NULL, + reason varchar(128) NOT NULL, + source_ip varchar(64), + occurred_at timestamptz(6) NOT NULL +); +CREATE INDEX ix_identity_audit_subject_time ON identity_audit_log(user_id, occurred_at); +CREATE INDEX ix_identity_audit_challenge ON identity_audit_log(challenge_id, occurred_at); diff --git a/payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql b/payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql new file mode 100644 index 0000000..3fa8bf9 --- /dev/null +++ b/payguard-identity-service/src/main/resources/db/migration/postgresql/V2__serialize_active_challenges.sql @@ -0,0 +1,12 @@ +/* Persistent scope rows serialize issuance even before a challenge exists. */ +CREATE TABLE identity_challenge_scope ( + user_id varchar(255) COLLATE "C" NOT NULL, + purpose varchar(32) NOT NULL, + PRIMARY KEY (user_id, purpose) +); +INSERT INTO identity_challenge_scope(user_id, purpose) +SELECT DISTINCT user_id, purpose FROM identity_challenge; + +/* Duplicate active rows are a migration error; this migration never discards data. */ +CREATE UNIQUE INDEX ux_identity_one_active_challenge + ON identity_challenge(user_id, purpose) WHERE status = 'ACTIVE'; diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java new file mode 100644 index 0000000..f9eab22 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/application/OtpApplicationContractsTest.java @@ -0,0 +1,249 @@ +package dev.amg.payguard.identity.verification.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.port.OtpDelivery; +import dev.amg.payguard.identity.verification.application.port.OtpDeliveryRequest; +import dev.amg.payguard.identity.verification.application.port.TotpVerification; +import dev.amg.payguard.identity.verification.application.port.VerificationAudit; +import dev.amg.payguard.identity.verification.application.port.VerificationAuditEntry; +import dev.amg.payguard.identity.verification.application.port.VerificationQuota; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class OtpApplicationContractsTest { + private static final CustomerId CUSTOMER = CustomerId.parse("cus_ABCDEFGHIJKLMNOPQRSTUV"); + private static final OtpId ID = OtpId.parse("otp_ABCDEFGHIJKLMNOPQRSTUV"); + private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); + private final OtpRepository repository = mock(OtpRepository.class); + private final OtpDelivery delivery = mock(OtpDelivery.class); + private final VerificationAudit audit = mock(VerificationAudit.class); + private final VerificationQuota quota = mock(VerificationQuota.class); + private final TotpVerification totp = mock(TotpVerification.class); + private final OtpEventPublisher events = mock(OtpEventPublisher.class); + private OtpApplicationService service; + + @BeforeEach + void setUp() { + when(quota.tryAcquire(any())).thenReturn(true); + service = + new OtpApplicationService( + repository, + new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars"), + () -> "123456", + totp, + delivery, + quota, + () -> ID, + events, + audit, + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ofMinutes(5), + Duration.ofSeconds(30), + Duration.ofDays(1097), + 5); + } + + @Test + void savesBeforeDeliveryAndAuditsWithoutSecrets() { + service.issue(issue(OtpChannel.SMS)); + var order = inOrder(repository, delivery, events, audit); + order.verify(repository).replaceActive(any(OTP.class)); + var payload = ArgumentCaptor.forClass(OtpDeliveryRequest.class); + order.verify(delivery).submit(payload.capture()); + order.verify(events).publish(any()); + var entry = ArgumentCaptor.forClass(VerificationAuditEntry.class); + order.verify(audit).record(entry.capture()); + assertEquals("123456", payload.getValue().code()); + assertEquals("identity:phone:1", payload.getValue().destinationRef()); + assertFalse(payload.getValue().toString().contains("123456")); + assertEquals(CUSTOMER, entry.getValue().customerId()); + assertEquals(ID, entry.getValue().challengeId()); + assertEquals(VerificationAuditEntry.Action.ISSUED, entry.getValue().action()); + assertFalse(entry.getValue().toString().contains("123456")); + } + + @Test + void deliveryFailurePropagatesAfterPersistenceWithoutAuditOrEvent() { + var failure = new IllegalStateException("provider unavailable"); + doThrow(failure).when(delivery).submit(any()); + assertSame( + failure, + assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS)))); + verify(repository).replaceActive(any()); + verifyNoInteractions(audit, events); + } + + @Test + void issuanceAuditFailurePropagatesAfterDelivery() { + var failure = new IllegalStateException("audit unavailable"); + doThrow(failure).when(audit).record(any()); + assertSame( + failure, + assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS)))); + verify(delivery).submit(any()); + verify(repository).replaceActive(any()); + } + + @Test + void persistenceFailureStopsDeliveryAndAudit() { + doThrow(new IllegalStateException("database unavailable")) + .when(repository) + .replaceActive(any()); + assertThrows(IllegalStateException.class, () -> service.issue(issue(OtpChannel.SMS))); + verifyNoInteractions(delivery, audit, events); + } + + @Test + void quotaDenialStopsIssuance() { + when(quota.tryAcquire(any())).thenReturn(false); + assertThrows(OtpRateLimitExceededException.class, () -> service.issue(issue(OtpChannel.SMS))); + verifyNoInteractions(repository, delivery, audit, events); + } + + @Test + void wrongCustomerCannotReachQuotaOrVerification() { + OTP otp = issuedChallenge(OtpChannel.SMS); + clearInvocations(quota, repository, audit, delivery, events); + assertThrows( + OtpChallengeNotFoundException.class, + () -> + service.verify( + new OtpVerificationCommand( + ID, + CustomerId.parse("cus_ZBCDEFGHIJKLMNOPQRSTUV"), + OtpPurpose.LOGIN, + "123456", + null, + "127.0.0.1"))); + verifyNoInteractions(quota, totp, audit); + assertEquals(0, otp.getSnapshot().attempts()); + } + + @Test + void verificationAuditFailureDoesNotReturnSuccessOrPublishVerifiedEvent() { + OTP otp = issuedChallenge(OtpChannel.SMS); + clearInvocations(events); + doThrow(new IllegalStateException("audit unavailable")).when(audit).record(any()); + assertThrows(IllegalStateException.class, () -> service.verify(verification("123456"))); + verify(repository).withLockedChallenge(eq(ID), any()); + assertEquals(OtpStatus.USED, otp.getSnapshot().status()); + verifyNoInteractions(events); + } + + @Test + void failedVerificationPersistenceStopsAuditAndSuccessResponse() { + issuedChallenge(OtpChannel.SMS); + clearInvocations(audit, events); + doThrow(new IllegalStateException("database unavailable")) + .when(repository) + .withLockedChallenge(eq(ID), any()); + assertThrows(IllegalStateException.class, () -> service.verify(verification("123456"))); + verifyNoInteractions(audit, events); + } + + @Test + void totpUsesTrustedVerifierAndNeverTreatsAnArbitraryCodeAsSuccess() { + OTP otp = issuedChallenge(OtpChannel.AUTHENTICATOR_APP); + verifyNoInteractions(delivery); + assertEquals( + OtpVerificationResult.INVALID_CODE, + assertThrows(OtpVerificationException.class, () -> service.verify(verification("123456"))) + .result()); + verify(totp).verify(CUSTOMER, "123456", NOW); + assertEquals(1, otp.getSnapshot().attempts()); + when(totp.verify(CUSTOMER, "123456", NOW)).thenReturn(true); + assertEquals(ID, service.verify(verification("123456")).challengeId()); + } + + @Test + void failedAttemptIsSavedAndAuditedBeforeRejection() { + OTP otp = issuedChallenge(OtpChannel.SMS); + clearInvocations(repository, audit, events); + assertEquals( + OtpVerificationResult.INVALID_CODE, + assertThrows(OtpVerificationException.class, () -> service.verify(verification("000000"))) + .result()); + var order = inOrder(repository, audit, events); + order.verify(repository).findById(ID); + order.verify(repository).withLockedChallenge(eq(ID), any()); + order + .verify(audit) + .record( + new VerificationAuditEntry( + ID, + CUSTOMER, + VerificationAuditEntry.Action.VERIFY, + "invalid_code", + "127.0.0.1", + NOW)); + order.verify(events).publish(any()); + } + + @Test + void authenticatorDependencyFailureDoesNotConsumeAttemptOrPersistInvalidCode() { + OTP otp = issuedChallenge(OtpChannel.AUTHENTICATOR_APP); + clearInvocations(repository, audit, events); + when(totp.verify(CUSTOMER, "123456", NOW)) + .thenThrow(new infrastructure.vault.VaultUnavailableException()); + assertThrows( + infrastructure.vault.VaultUnavailableException.class, + () -> service.verify(verification("123456"))); + assertEquals(0, otp.getSnapshot().attempts()); + assertEquals(OtpStatus.ACTIVE, otp.getSnapshot().status()); + org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()) + .withLockedChallenge(eq(ID), any()); + verifyNoInteractions(audit, events); + } + + private OTP issuedChallenge(OtpChannel channel) { + service.issue(issue(channel)); + var capture = ArgumentCaptor.forClass(OTP.class); + verify(repository).replaceActive(capture.capture()); + when(repository.findById(ID)).thenReturn(Optional.of(capture.getValue())); + when(repository.withLockedChallenge(eq(ID), any())) + .thenAnswer( + invocation -> { + java.util.function.Function mutation = invocation.getArgument(1); + return Optional.of(mutation.apply(capture.getValue())); + }); + return capture.getValue(); + } + + private static IssueOtpCommand issue(OtpChannel channel) { + return new IssueOtpCommand( + CUSTOMER, OtpPurpose.LOGIN, channel, "identity:phone:1", null, "127.0.0.1"); + } + + private static OtpVerificationCommand verification(String code) { + return new OtpVerificationCommand(ID, CUSTOMER, OtpPurpose.LOGIN, code, null, "127.0.0.1"); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java new file mode 100644 index 0000000..9c0712f --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/OtpInfrastructureIntegrationTest.java @@ -0,0 +1,544 @@ +package dev.amg.payguard.identity.verification.infrastructure; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import dev.amg.payguard.identity.IdentityApp; +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import dev.amg.payguard.identity.customer.domain.value_object.OtpId; +import dev.amg.payguard.identity.verification.application.IssueOtpCommand; +import dev.amg.payguard.identity.verification.application.OtpApplicationService; +import dev.amg.payguard.identity.verification.application.OtpVerificationCommand; +import dev.amg.payguard.identity.verification.application.OtpVerificationException; +import dev.amg.payguard.identity.verification.domain.enums.OtpChannel; +import dev.amg.payguard.identity.verification.domain.enums.OtpPurpose; +import dev.amg.payguard.identity.verification.domain.enums.OtpStatus; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationMode; +import dev.amg.payguard.identity.verification.domain.enums.OtpVerificationResult; +import dev.amg.payguard.identity.verification.domain.event.OtpEventPublisher; +import dev.amg.payguard.identity.verification.domain.model.OTP; +import dev.amg.payguard.identity.verification.domain.model.OtpSnapshot; +import dev.amg.payguard.identity.verification.domain.port.OtpCodeGenerator; +import dev.amg.payguard.identity.verification.domain.port.OtpRepository; +import dev.amg.payguard.identity.verification.domain.value_object.VerifyOtpCommand; +import dev.amg.payguard.identity.verification.infrastructure.security.HmacOtpCodeHasher; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers +@org.springframework.test.context.ActiveProfiles("test") +@SpringBootTest( + classes = IdentityApp.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = {"spring.flyway.enabled=true", "spring.jpa.hibernate.ddl-auto=validate"}) +class OtpInfrastructureIntegrationTest { + @Container + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer("postgres:17.11-bookworm").withInitScript("postgres-test-init.sql"); + + @Container + static final GenericContainer REDIS = + new GenericContainer<>("redis:8.0.3-alpine").withExposedPorts(6379); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> POSTGRES.getJdbcUrl() + "¤tSchema=payguard"); + registry.add("spring.flyway.url", POSTGRES::getJdbcUrl); + registry.add("spring.flyway.user", POSTGRES::getUsername); + registry.add("spring.flyway.password", POSTGRES::getPassword); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.data.redis.host", REDIS::getHost); + registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + } + + @Autowired OtpRepository repository; + @Autowired OtpApplicationService service; + @Autowired HmacOtpCodeHasher hasher; + @Autowired JdbcTemplate jdbc; + @Autowired StringRedisTemplate redis; + @Autowired dev.amg.payguard.identity.verification.application.port.VerificationQuota quota; + @Autowired dev.amg.payguard.identity.verification.application.port.VerificationAudit audit; + @MockitoBean OtpEventPublisher events; + @MockitoBean OtpCodeGenerator codes; + + @BeforeEach + void reset() { + jdbc.update("DELETE FROM identity_audit_log"); + jdbc.update("DELETE FROM identity_challenge"); + try (var connection = redis.getConnectionFactory().getConnection()) { + connection.serverCommands().flushDb(); + } + when(codes.generate()).thenReturn("123456"); + } + + @Test + void postgresAndRedisAreReachable() { + assertTrue(POSTGRES.isRunning()); + assertTrue(REDIS.isRunning()); + assertTrue(POSTGRES.getJdbcUrl().startsWith("jdbc:postgresql:")); + } + + @Test + void actualApplicationWiringIssuesVerifiesPersistsAndRejectsReplay() { + var customer = customer("A"); + var issued = + service.issue( + new IssueOtpCommand( + customer, OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); + var verified = + service.verify( + new OtpVerificationCommand( + issued.challengeId(), customer, OtpPurpose.LOGIN, "123456", null, "127.0.0.1")); + assertEquals(issued.challengeId(), verified.challengeId()); + OTP stored = repository.findById(issued.challengeId()).orElseThrow(); + assertEquals(customer, stored.getCustomerId()); + assertEquals(OtpStatus.USED, stored.getSnapshot().status()); + assertEquals(1, stored.getSnapshot().attempts()); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM identity_audit_log", Integer.class)); + assertEquals( + OtpVerificationResult.ALREADY_USED, + assertThrows( + OtpVerificationException.class, + () -> + service.verify( + new OtpVerificationCommand( + issued.challengeId(), + customer, + OtpPurpose.LOGIN, + "123456", + null, + "127.0.0.1"))) + .result()); + } + + @Test + void replacementInvalidatesOnlySameCustomerAndPurposeAndPreservesCase() { + OTP first = challenge("A", customer("A"), OtpPurpose.LOGIN, "hash"); + OTP otherPurpose = challenge("B", customer("A"), OtpPurpose.PROFILE_CHANGE, "hash"); + OTP otherCustomer = challenge("C", customer("B"), OtpPurpose.LOGIN, "hash"); + repository.save(first); + repository.save(otherPurpose); + repository.save(otherCustomer); + OTP replacement = challenge("a", customer("A"), OtpPurpose.LOGIN, "hash"); + repository.replaceActive(replacement); + assertEquals( + OtpStatus.INVALIDATED, + repository.findById(first.getId()).orElseThrow().getSnapshot().status()); + for (OTP active : new OTP[] {otherPurpose, otherCustomer, replacement}) { + assertEquals( + OtpStatus.ACTIVE, + repository.findById(active.getId()).orElseThrow().getSnapshot().status()); + } + assertEquals(4, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); + } + + @Test + void replacementFailureRollsBackEarlierInvalidation() { + OTP original = challenge("A", customer("A"), OtpPurpose.LOGIN, "hash"); + repository.save(original); + OTP oversized = challenge("B", customer("A"), OtpPurpose.LOGIN, "x".repeat(513)); + assertThrows(RuntimeException.class, () -> repository.replaceActive(oversized)); + assertEquals( + OtpStatus.ACTIVE, + repository.findById(original.getId()).orElseThrow().getSnapshot().status()); + assertTrue(repository.findById(oversized.getId()).isEmpty()); + } + + @Test + void finalSuccessAndFinalFailureRoundTripWithoutExhaustedActiveState() { + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + for (boolean valid : new boolean[] {false, true}) { + OtpId id = OtpId.parse("otp_" + (valid ? "A" : "B").repeat(22)); + OTP otp = + OTP.issue( + id, + customer("A"), + new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + hasher.hash("123456"), + null, + 1, + now, + now.plusSeconds(300), + now.plusSeconds(1097L * 86_400), + 0, + OtpStatus.ACTIVE, + null)); + repository.save(otp); + repository.withLockedChallenge( + id, + current -> + current.verify( + new VerifyOtpCommand( + OtpPurpose.LOGIN, valid ? "123456" : "000000", null, now, false), + hasher)); + OtpSnapshot stored = repository.findById(id).orElseThrow().getSnapshot(); + assertEquals(valid ? OtpStatus.USED : OtpStatus.LOCKED, stored.status()); + assertEquals(1, stored.attempts()); + } + } + + @Test + void redisActuallyEnforcesIssuanceQuotaAcrossRequests() { + var command = + new IssueOtpCommand( + customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1"); + for (int index = 0; index < 3; index++) service.issue(command); + assertThrows( + dev.amg.payguard.identity.verification.application.OtpRateLimitExceededException.class, + () -> service.issue(command)); + assertEquals(3, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); + assertEquals( + "4", + redis + .opsForValue() + .get("identity:rate:issue:" + customer("A").getValue() + ":LOGIN:127.0.0.1")); + } + + @Test + void archivedOracleMigrationRetainsPopulatedDataGuard() throws Exception { + repository.save(challenge("A", customer("A"), OtpPurpose.LOGIN, "hash")); + String migration; + try (var stream = + getClass().getResourceAsStream("/db/legacy/oracle/V2__prefixed_challenge_ids.sql")) { + migration = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + String guard = migration.substring(migration.indexOf("DECLARE"), migration.indexOf("\n/")); + assertTrue(guard.contains("RAISE_APPLICATION_ERROR(-20001")); + assertTrue(guard.contains("approved mapping")); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM identity_challenge", Integer.class)); + } + + @Test + void concurrentRedisQuotaAcquisitionAllowsOnlyConfiguredNumber() throws Exception { + var request = + new dev.amg.payguard.identity.verification.application.port.VerificationQuota.Request( + customer("A"), + OtpPurpose.LOGIN, + "127.0.0.1", + dev.amg.payguard.identity.verification.application.port.VerificationQuota.Operation + .ISSUE); + try (var executor = java.util.concurrent.Executors.newFixedThreadPool(8)) { + var tasks = + java.util.stream.IntStream.range(0, 32) + .mapToObj( + index -> (java.util.concurrent.Callable) () -> quota.tryAcquire(request)) + .toList(); + int accepted = 0; + for (var result : executor.invokeAll(tasks)) if (result.get()) accepted++; + assertEquals(3, accepted); + } + String key = "identity:rate:issue:" + customer("A").getValue() + ":LOGIN:127.0.0.1"; + assertEquals("32", redis.opsForValue().get(key)); + assertTrue(redis.getExpire(key) > 0); + } + + @Test + void competingSuccessfulVerificationsCommitOnlyOneFreshSuccess() throws Exception { + var issued = + service.issue( + new IssueOtpCommand( + customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); + var outcomes = verifyTogether(issued.challengeId(), "123456", 2); + assertEquals( + 1, outcomes.stream().filter(result -> result == OtpVerificationResult.VERIFIED).count()); + assertEquals( + 1, + outcomes.stream().filter(result -> result == OtpVerificationResult.ALREADY_USED).count()); + assertEquals( + 1, repository.findById(issued.challengeId()).orElseThrow().getSnapshot().attempts()); + } + + @Test + void competingFailedAttemptsNeverLoseIncrementsOrExceedLimit() throws Exception { + var issued = + service.issue( + new IssueOtpCommand( + customer("A"), OtpPurpose.LOGIN, OtpChannel.SMS, "phone-ref", null, "127.0.0.1")); + var outcomes = verifyTogether(issued.challengeId(), "000000", 8); + assertEquals( + 4, + outcomes.stream().filter(result -> result == OtpVerificationResult.INVALID_CODE).count()); + assertEquals( + 4, outcomes.stream().filter(result -> result == OtpVerificationResult.LOCKED).count()); + OtpSnapshot stored = repository.findById(issued.challengeId()).orElseThrow().getSnapshot(); + assertEquals(5, stored.attempts()); + assertEquals(OtpStatus.LOCKED, stored.status()); + } + + @Test + void firstIssuanceAndReplacementAreSerializedWithoutAnExistingActiveRow() throws Exception { + for (boolean existing : new boolean[] {false, true}) { + var subject = customer(existing ? "B" : "A"); + if (existing) repository.save(challenge("C", subject, OtpPurpose.LOGIN, "hash")); + OTP first = challenge(existing ? "D" : "A", subject, OtpPurpose.LOGIN, "hash"); + OTP second = challenge(existing ? "E" : "B", subject, OtpPurpose.LOGIN, "hash"); + var barrier = new java.util.concurrent.CyclicBarrier(2); + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var one = + executor.submit( + () -> { + await(barrier); + repository.replaceActive(first); + }); + var two = + executor.submit( + () -> { + await(barrier); + repository.replaceActive(second); + }); + one.get(15, java.util.concurrent.TimeUnit.SECONDS); + two.get(15, java.util.concurrent.TimeUnit.SECONDS); + } + assertEquals( + 1, + jdbc.queryForObject( + "SELECT count(*) FROM identity_challenge WHERE user_id=? AND purpose='LOGIN' AND status='ACTIVE'", + Integer.class, + subject.getValue())); + assertEquals( + existing ? 3 : 2, + jdbc.queryForObject( + "SELECT count(*) FROM identity_challenge WHERE user_id=?", + Integer.class, + subject.getValue())); + } + } + + @Test + void expiredAndWrongOwnerRejectionsPreserveTheirPersistencePolicy() { + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + OtpId id = OtpId.parse("otp_" + "A".repeat(22)); + repository.save( + OTP.issue( + id, + customer("A"), + new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + hasher.hash("123456"), + null, + 5, + now.minusSeconds(600), + now.minusSeconds(300), + now.plusSeconds(1097L * 86_400), + 0, + OtpStatus.ACTIVE, + null))); + var wrongOwner = + new OtpVerificationCommand( + id, customer("B"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); + assertThrows( + dev.amg.payguard.identity.verification.application.OtpChallengeNotFoundException.class, + () -> service.verify(wrongOwner)); + assertEquals(OtpStatus.ACTIVE, repository.findById(id).orElseThrow().getSnapshot().status()); + var expired = + new OtpVerificationCommand( + id, customer("A"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); + assertEquals( + OtpVerificationResult.EXPIRED, + assertThrows(OtpVerificationException.class, () -> service.verify(expired)).result()); + assertEquals(OtpStatus.EXPIRED, repository.findById(id).orElseThrow().getSnapshot().status()); + assertEquals(0, repository.findById(id).orElseThrow().getSnapshot().attempts()); + } + + private java.util.List verifyTogether(OtpId id, String code, int callers) + throws Exception { + var barrier = new java.util.concurrent.CyclicBarrier(callers); + OtpRepository racing = + new OtpRepository() { + @Override + public void save(OTP otp) { + repository.save(otp); + } + + @Override + public void replaceActive(OTP otp) { + repository.replaceActive(otp); + } + + @Override + public java.util.Optional findById(OtpId key) { + return repository.findById(key); + } + + @Override + public java.util.Optional withLockedChallenge( + OtpId key, java.util.function.Function mutation) { + /* All callers have read the original snapshot before any transaction acquires its row lock. */ + await(barrier); + return repository.withLockedChallenge(key, mutation); + } + }; + OtpApplicationService competing = + new OtpApplicationService( + racing, + hasher, + codes, + (subject, value, at) -> false, + message -> {}, + quota, + () -> id, + events, + audit, + java.time.Clock.systemUTC(), + java.time.Duration.ofMinutes(5), + java.time.Duration.ofSeconds(30), + java.time.Duration.ofDays(1097), + 5); + try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new java.util.ArrayList>(); + for (int caller = 0; caller < callers; caller++) + futures.add( + executor.submit( + () -> { + try { + competing.verify( + new OtpVerificationCommand( + id, customer("A"), OtpPurpose.LOGIN, code, null, "127.0.0.1")); + return OtpVerificationResult.VERIFIED; + } catch (OtpVerificationException exception) { + return exception.result(); + } + })); + var results = new java.util.ArrayList(); + for (var future : futures) results.add(future.get(15, java.util.concurrent.TimeUnit.SECONDS)); + return results; + } + } + + @Test + void restoredStatesNullableTotpHashesAndCaseDistinctIdentifiersRoundTrip() { + Instant created = + Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS).minusSeconds(600); + OtpStatus[] statuses = {OtpStatus.USED, OtpStatus.LOCKED, OtpStatus.EXPIRED, OtpStatus.ACTIVE}; + String[] suffixes = {"A", "a", "B", "b"}; + for (int index = 0; index < statuses.length; index++) { + OtpStatus status = statuses[index]; + var snapshot = + new OtpSnapshot( + OtpPurpose.LOGIN, + OtpChannel.AUTHENTICATOR_APP, + OtpVerificationMode.TOTP, + null, + null, + 5, + created, + created.plusSeconds(300), + created.plusSeconds(1097L * 86_400), + status == OtpStatus.LOCKED ? 5 : 1, + status, + status == OtpStatus.USED ? created.plusSeconds(1) : null); + OtpId id = OtpId.parse("otp_" + suffixes[index].repeat(22)); + CustomerId subject = customer(suffixes[index]); + repository.save(OTP.restore(id, subject, snapshot)); + var restored = repository.findById(id).orElseThrow(); + assertEquals(subject, restored.getCustomerId()); + assertEquals(snapshot, restored.getSnapshot()); + } + assertEquals(4, jdbc.queryForObject("SELECT count(*) FROM identity_challenge", Integer.class)); + } + + @Test + void challengeExpiringBetweenInitialReadAndLockedMutationIsRejectedAtExactBoundary() { + OTP otp = challenge("A", customer("A"), OtpPurpose.LOGIN, hasher.hash("123456")); + repository.save(otp); + Instant expires = otp.getSnapshot().expiresAt(); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + java.time.Clock advancing = + new java.time.Clock() { + @Override + public java.time.ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public java.time.Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return reads.getAndIncrement() == 0 ? expires.minusSeconds(1) : expires; + } + }; + var boundary = + new OtpApplicationService( + repository, + hasher, + codes, + (subject, code, at) -> false, + message -> {}, + quota, + otp::getId, + events, + audit, + advancing, + java.time.Duration.ofMinutes(5), + java.time.Duration.ofSeconds(30), + java.time.Duration.ofDays(1097), + 5); + var command = + new OtpVerificationCommand( + otp.getId(), customer("A"), OtpPurpose.LOGIN, "123456", null, "127.0.0.1"); + assertEquals( + OtpVerificationResult.EXPIRED, + assertThrows(OtpVerificationException.class, () -> boundary.verify(command)).result()); + var restored = repository.findById(otp.getId()).orElseThrow().getSnapshot(); + assertEquals(OtpStatus.EXPIRED, restored.status()); + assertEquals(0, restored.attempts()); + } + + private static void await(java.util.concurrent.CyclicBarrier barrier) { + try { + barrier.await(10, java.util.concurrent.TimeUnit.SECONDS); + } catch (Exception exception) { + throw new IllegalStateException( + "Competing request did not reach its test barrier", exception); + } + } + + private static CustomerId customer(String suffix) { + return CustomerId.parse("cus_" + suffix.repeat(22)); + } + + private static OTP challenge( + String suffix, CustomerId customer, OtpPurpose purpose, String hash) { + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS); + return OTP.issue( + OtpId.parse("otp_" + suffix.repeat(22)), + customer, + new OtpSnapshot( + purpose, + OtpChannel.SMS, + OtpVerificationMode.RANDOM_NUMERIC, + hash, + null, + 5, + now, + now.plusSeconds(300), + now.plusSeconds(1097L * 86_400), + 0, + OtpStatus.ACTIVE, + null)); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java new file mode 100644 index 0000000..32a5763 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/HmacOtpCodeHasherTest.java @@ -0,0 +1,23 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HmacOtpCodeHasherTest { + + private final HmacOtpCodeHasher hasher = + new HmacOtpCodeHasher("a-secure-test-pepper-with-at-least-32-chars"); + + @Test + void storesSaltedHashAndMatchesOnlyOriginalValue() { + String encoded = hasher.hash("123456"); + + assertNotEquals("123456", encoded); + assertTrue(hasher.matches("123456", encoded)); + assertFalse(hasher.matches("123457", encoded)); + assertFalse(hasher.matches("123456", "not-a-hash")); + } +} diff --git a/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java new file mode 100644 index 0000000..2130342 --- /dev/null +++ b/payguard-identity-service/src/test/java/dev/amg/payguard/identity/verification/infrastructure/security/VaultSecretsIT.java @@ -0,0 +1,147 @@ +package dev.amg.payguard.identity.verification.infrastructure.security; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.amg.payguard.identity.customer.domain.value_object.CustomerId; +import infrastructure.vault.VaultClient; +import infrastructure.vault.VaultUnavailableException; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Real scoped KV reads and key compatibility against the isolated TLS rehearsal Vault. */ +class VaultSecretsIT { + private static final Path LOCAL = Path.of("/workspace/.local/payguard"); + private static final String KEYS = "secret/data/local/identity/otp-keys"; + private static final String FIRST = "synthetic-first-pepper-marker-123456789"; + private static final String SECOND = "synthetic-second-pepper-marker-123456789"; + + @Test + void provisionedSeedIsCustomerScopedAndMissingIsDistinctFromDenied() { + assertEquals("true", System.getenv("PAYGUARD_REHEARSAL")); + CustomerId customer = + CustomerId.parse( + "cus_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 22)); + String path = "secret/data/local/identity/authenticators/" + customer.getValue(); + try (VaultClient operator = client("operator/approle"); + VaultClient identity = client("apps/identity"); + VaultClient wallet = client("apps/wallet")) { + operator.write(path, Map.of("data", Map.of("seed", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"))); + try { + var adapter = new VaultAuthenticatorSecretAdapter(identity, "local/identity"); + assertTrue(adapter.secretFor(customer).isPresent()); + assertTrue(adapter.secretFor(CustomerId.parse("cus_" + "R".repeat(22))).isEmpty()); + var totp = new TotpAuthenticatorCodeVerifier(adapter); + assertTrue(totp.verify(customer, "287082", Instant.ofEpochSecond(59))); + assertFalse(totp.verify(customer, "287083", Instant.ofEpochSecond(59))); + var denied = new VaultAuthenticatorSecretAdapter(wallet, "local/identity"); + var failure = + assertThrows(VaultUnavailableException.class, () -> denied.secretFor(customer)); + assertFalse(failure.getMessage().contains("GEZDGNBV")); + } finally { + operator.write( + "secret/destroy/local/identity/authenticators/" + customer.getValue(), + Map.of("versions", new int[] {1})); + } + } + } + + @Test + void actualKvRotationRetainsActiveHashesRejectsEarlyRetirementAndEventuallyRetiresOldKey() { + try (VaultClient operator = client("operator/approle"); + VaultClient identity = client("apps/identity")) { + var original = operator.read(KEYS).path("data").path("data"); + operator.write( + KEYS, + Map.of( + "data", + Map.of("current", "probeA", "keys", Map.of("probeA", FIRST), "legacy", "probeA"))); + try (var keys = + new VaultPepperKeys( + identity, Duration.ofSeconds(1), Duration.ofSeconds(1), "local/identity")) { + var hasher = new HmacOtpCodeHasher(keys); + String old = hasher.hash("synthetic-code"); + String legacy = old.substring(old.indexOf(':') + 1); + assertTrue(hasher.matches("synthetic-code", legacy)); + operator.write( + KEYS, + Map.of( + "data", + Map.of( + "current", + "probeB", + "keys", + Map.of("probeA", FIRST, "probeB", SECOND), + "legacy", + "probeA"))); + await().atMost(Duration.ofSeconds(8)).until(() -> keys.get().current().equals("probeB")); + String newer = hasher.hash("synthetic-code"); + assertTrue(newer.startsWith("probeB:")); + assertTrue(hasher.matches("synthetic-code", old)); + assertTrue(hasher.matches("synthetic-code", legacy)); + operator.write( + KEYS, + Map.of( + "data", + Map.of( + "current", + "probeB", + "keys", + Map.of("probeA", FIRST, "probeB", SECOND), + "legacy", + "probeB"))); + await().atMost(Duration.ofSeconds(8)).until(() -> !keys.refreshHealthy()); + assertTrue(hasher.matches("synthetic-code", legacy)); + operator.write( + KEYS, + Map.of( + "data", + Map.of("current", "probeB", "keys", Map.of("probeA", FIRST, "probeB", SECOND)))); + Instant removal = Instant.now(); + await() + .atMost(Duration.ofSeconds(8)) + .until(() -> Instant.now().isAfter(removal.plusSeconds(6))); + assertTrue(hasher.matches("synthetic-code", old)); + assertTrue(hasher.matches("synthetic-code", legacy)); + operator.write( + KEYS, Map.of("data", Map.of("current", "probeB", "keys", Map.of("probeB", SECOND)))); + await() + .atMost(Duration.ofSeconds(14)) + .until(() -> !keys.get().keys().containsKey("probeA")); + assertFalse(hasher.matches("synthetic-code", old)); + assertFalse(hasher.matches("synthetic-code", legacy)); + assertTrue(hasher.matches("synthetic-code", newer)); + assertFalse(keys.get().toString().contains(SECOND)); + } finally { + operator.write(KEYS, Map.of("data", original)); + } + } + } + + @Test + void invalidMissingKeyConfigurationFailsStartupWithoutDefault() { + try (VaultClient wallet = client("apps/wallet")) { + assertThrows( + VaultUnavailableException.class, + () -> + new VaultPepperKeys( + wallet, Duration.ofMinutes(5), Duration.ofSeconds(30), "local/identity")); + } + } + + private static VaultClient client(String folder) { + Path scope = LOCAL.resolve(folder); + return new VaultClient( + URI.create("https://vault:8200"), + LOCAL.resolve("ca.crt"), + scope.resolve("role-id"), + scope.resolve("secret-id")); + } +} diff --git a/payguard-identity-service/src/test/resources/application-test.yaml b/payguard-identity-service/src/test/resources/application-test.yaml new file mode 100644 index 0000000..56269a9 --- /dev/null +++ b/payguard-identity-service/src/test/resources/application-test.yaml @@ -0,0 +1,15 @@ +payguard: + vault: + enabled: false +spring: + flyway: + enabled: true + jpa: + open-in-view: false + +management: + endpoint: + health: + group: + readiness: + include: readinessState,db diff --git a/payguard-identity-service/src/test/resources/postgres-test-init.sql b/payguard-identity-service/src/test/resources/postgres-test-init.sql new file mode 100644 index 0000000..d1ce84d --- /dev/null +++ b/payguard-identity-service/src/test/resources/postgres-test-init.sql @@ -0,0 +1 @@ +CREATE SCHEMA payguard; diff --git a/payguard-loan-service/Dockerfile b/payguard-loan-service/Dockerfile index fe78d23..e9db8bf 100644 --- a/payguard-loan-service/Dockerfile +++ b/payguard-loan-service/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1 -FROM eclipse-temurin:25-jdk-alpine AS build +FROM eclipse-temurin:25-jdk-alpine@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a AS build WORKDIR /workspace COPY . . RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-loan-service -am -DskipTests package -FROM eclipse-temurin:25-jre-alpine +FROM eclipse-temurin:25-jre-alpine@sha256:3c0a9084927a221ccd1d007fcaf614465672c0af37aaa834c5184483afe56d61 WORKDIR /app RUN addgroup -S payguard && adduser -S payguard -G payguard COPY --from=build /workspace/payguard-loan-service/target/payguard-loan-service-*.jar /app/app.jar diff --git a/payguard-loan-service/README.md b/payguard-loan-service/README.md index 6636c11..f4400ef 100644 --- a/payguard-loan-service/README.md +++ b/payguard-loan-service/README.md @@ -1,5 +1,13 @@ # Loan service -The service owns the `Loan` aggregate, schedules, Actual/365 accrual, repayment waterfall, retention metadata, immutable audit records, outbox publication, and idempotent Kafka consumers. It references opaque borrower and collateral identifiers and requests wallet settlement through an event/port boundary. +The domain/application layer models the `Loan` aggregate, schedules, Actual/365 accrual, repayment waterfall, retention metadata, immutable audit records, outbox publication, and idempotent Kafka consumers. It references opaque borrower and collateral identifiers and requests wallet settlement through an event/port boundary. -Build independently from the repository root with `docker build -f payguard-loan-service/Dockerfile -t payguard/loan-service .`. Run it with `SPRING_DATASOURCE_URL="$LOAN_ORACLE_JDBC_URL"`, `SPRING_DATASOURCE_USERNAME="$LOAN_ORACLE_APP_USER"`, and `SPRING_DATASOURCE_PASSWORD="$LOAN_ORACLE_APP_PASSWORD"` from `.env`; its Flyway schema is applied only to the loan-owned Oracle instance. +The supplied SQL schema and infrastructure now use the service-owned PostgreSQL database. Concrete repository adapters/entities are still absent, so schema tests do not prove the planned persisted business workflows. Flyway runs separately with a Vault migration identity; runtime obtains scoped DML credentials through the rotating Vault DataSource. + +From the repository root with JDK 25 and Docker: + +```bash +./mvnw -B -ntp -pl payguard-loan-service -am test +``` + +The integration suite applies PostgreSQL migrations and checks exact amounts, identifiers and important constraints. Follow [the implementation guide](../docs/guides/postgresql-vault-migration.md) for TLS infrastructure, provisioning, migration commands, startup and remaining limitations. Original Oracle migrations remain unchanged under `src/main/resources/db/legacy/oracle` for authorized historical migration only. Runtime database passwords do not belong in `.env`. diff --git a/payguard-loan-service/pom.xml b/payguard-loan-service/pom.xml index 76e699c..a5c3101 100644 --- a/payguard-loan-service/pom.xml +++ b/payguard-loan-service/pom.xml @@ -13,6 +13,14 @@ payguard-loan-service + + org.springframework.boot + spring-boot-starter-actuator + + + org.flywaydb + flyway-database-postgresql + dev.amg.payguard payguard-shared @@ -31,12 +39,12 @@ spring-boot-starter-data-jpa - org.flywaydb - flyway-core + org.springframework.boot + spring-boot-starter-flyway - com.oracle.database.jdbc - ojdbc17 + org.postgresql + postgresql runtime @@ -46,7 +54,7 @@ org.testcontainers - testcontainers-oracle-xe + testcontainers-postgresql test diff --git a/payguard-loan-service/src/main/resources/application.yaml b/payguard-loan-service/src/main/resources/application.yaml index 667f1df..b99e8c7 100644 --- a/payguard-loan-service/src/main/resources/application.yaml +++ b/payguard-loan-service/src/main/resources/application.yaml @@ -3,10 +3,42 @@ spring: name: payguard-loan-service datasource: url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: oracle.jdbc.OracleDriver + driver-class-name: org.postgresql.Driver jpa: + properties: + hibernate.default_schema: payguard open-in-view: false + hibernate: + ddl-auto: validate flyway: + enabled: false + locations: classpath:db/migration/postgresql + clean-disabled: true + url: ${SPRING_DATASOURCE_URL} + user: ${PAYGUARD_MIGRATION_USERNAME} + password: ${PAYGUARD_MIGRATION_PASSWORD} + default-schema: payguard + schemas: payguard + create-schemas: false + +payguard: + vault: enabled: true + address: ${VAULT_ADDR} + ca-file: ${VAULT_CA_FILE} + role-id-file: ${VAULT_ROLE_ID_FILE} + secret-id-file: ${VAULT_SECRET_ID_FILE} + database-role: ${PAYGUARD_ENVIRONMENT:local}-loan-runtime + +management: + endpoint: + health: + probes: + enabled: true + group: + readiness: + include: readinessState,db,vault + endpoints: + web: + exposure: + include: health,info diff --git a/payguard-loan-service/src/main/resources/db/migration/V1__loan_schema.sql b/payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql similarity index 100% rename from payguard-loan-service/src/main/resources/db/migration/V1__loan_schema.sql rename to payguard-loan-service/src/main/resources/db/legacy/oracle/V1__loan_schema.sql diff --git a/payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql b/payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql new file mode 100644 index 0000000..9c314de --- /dev/null +++ b/payguard-loan-service/src/main/resources/db/migration/postgresql/V1__loan_schema.sql @@ -0,0 +1,36 @@ +CREATE TABLE loan_account ( + id uuid PRIMARY KEY, + borrower_token varchar(128) NOT NULL, + collateral_id uuid NOT NULL, + principal numeric(28,8) NOT NULL, + annual_rate numeric(20,12) NOT NULL, + term_months integer NOT NULL, + status varchar(24) NOT NULL, + outstanding_principal numeric(28,8) NOT NULL, + accrued_interest numeric(28,8) NOT NULL, + last_accrual_date date NOT NULL, + version bigint DEFAULT 0 NOT NULL, + retention_until timestamptz(6), + created_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL +); +CREATE INDEX ix_loan_due ON loan_account(status, last_accrual_date); +CREATE TABLE loan_audit_log ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + loan_id uuid NOT NULL, + subject_token varchar(128), actor_token varchar(128), + action varchar(64) NOT NULL, reason varchar(256), + occurred_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + previous_hash varchar(128), record_hash varchar(128) NOT NULL +); +CREATE INDEX ix_loan_audit_subject_time ON loan_audit_log(subject_token, occurred_at); +CREATE TABLE loan_processed_event ( + event_id uuid NOT NULL, consumer_name varchar(128) NOT NULL, + processed_at timestamptz(6) DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY(event_id, consumer_name) +); +CREATE TABLE loan_outbox ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id uuid UNIQUE NOT NULL, event_type varchar(128) NOT NULL, + aggregate_id varchar(128) NOT NULL, payload text NOT NULL, + occurred_at timestamptz(6) NOT NULL, published_at timestamptz(6) +); diff --git a/payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java b/payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java index 04605f0..6dbf264 100644 --- a/payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java +++ b/payguard-loan-service/src/test/java/dev/amg/payguard/loan/LoanServiceApplicationTests.java @@ -5,30 +5,96 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.OracleContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; @SpringBootTest @ActiveProfiles("test") @Testcontainers class LoanServiceApplicationTests { + @org.springframework.beans.factory.annotation.Autowired + org.springframework.jdbc.core.JdbcTemplate jdbc; @Container - static final OracleContainer ORACLE = - new OracleContainer("gvenzl/oracle-xe:21-slim-faststart") - .withUsername("PAYGUARD_TEST") + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer("postgres:17.11-bookworm") + .withInitScript("postgres-test-init.sql") + .withUsername("payguard_test") .withPassword("payguard_test"); @DynamicPropertySource - static void oracleProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", ORACLE::getJdbcUrl); - registry.add("spring.datasource.username", ORACLE::getUsername); - registry.add("spring.datasource.password", ORACLE::getPassword); - registry.add("spring.datasource.driver-class-name", ORACLE::getDriverClassName); - registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.OracleDialect"); + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> POSTGRES.getJdbcUrl() + "¤tSchema=payguard"); + registry.add("spring.flyway.url", POSTGRES::getJdbcUrl); + registry.add("spring.flyway.user", POSTGRES::getUsername); + registry.add("spring.flyway.password", POSTGRES::getPassword); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); } @Test - void contextLoads() {} + void contextLoads() { + org.junit.jupiter.api.Assertions.assertEquals( + 1, + jdbc.queryForObject( + "SELECT count(*) FROM flyway_schema_history WHERE success", Integer.class)); + } + + @Test + void uuidExactAmountsLocalDateAndNullableRetentionRoundTrip() { + var id = java.util.UUID.randomUUID(); + var collateral = java.util.UUID.randomUUID(); + var principal = new java.math.BigDecimal("12345678901234567890.12345678"); + var rate = new java.math.BigDecimal("0.123456789012"); + var date = java.time.LocalDate.of(2026, 1, 1); + jdbc.update( + "INSERT INTO loan_account(id,borrower_token,collateral_id,principal,annual_rate,term_months,status,outstanding_principal,accrued_interest,last_accrual_date) VALUES (?,?,?,?,?,12,'ACTIVE',?,0,?)", + id, + "synthetic-subject", + collateral, + principal, + rate, + principal, + date); + jdbc.query( + "SELECT id,collateral_id,principal,annual_rate,last_accrual_date,retention_until FROM loan_account WHERE id=?", + rows -> { + org.junit.jupiter.api.Assertions.assertEquals( + id, rows.getObject("id", java.util.UUID.class)); + org.junit.jupiter.api.Assertions.assertEquals( + collateral, rows.getObject("collateral_id", java.util.UUID.class)); + org.junit.jupiter.api.Assertions.assertEquals(principal, rows.getBigDecimal("principal")); + org.junit.jupiter.api.Assertions.assertEquals(rate, rows.getBigDecimal("annual_rate")); + org.junit.jupiter.api.Assertions.assertEquals( + date, rows.getObject("last_accrual_date", java.time.LocalDate.class)); + org.junit.jupiter.api.Assertions.assertNull(rows.getObject("retention_until")); + }, + id); + } + + @Test + void duplicateProcessedEventAndOutboxIdentifiersAreRejected() { + var event = java.util.UUID.randomUUID(); + jdbc.update( + "INSERT INTO loan_processed_event(event_id,consumer_name) VALUES (?,'synthetic-consumer')", + event); + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.dao.DataIntegrityViolationException.class, + () -> + jdbc.update( + "INSERT INTO loan_processed_event(event_id,consumer_name) VALUES (?,'synthetic-consumer')", + event)); + jdbc.update( + "INSERT INTO loan_outbox(event_id,event_type,aggregate_id,payload,occurred_at) VALUES (?,'synthetic-event','synthetic-id','{}',CURRENT_TIMESTAMP)", + event); + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.dao.DataIntegrityViolationException.class, + () -> + jdbc.update( + "INSERT INTO loan_outbox(event_id,event_type,aggregate_id,payload,occurred_at) VALUES (?,'synthetic-event','synthetic-id','{}',CURRENT_TIMESTAMP)", + event)); + } } diff --git a/payguard-loan-service/src/test/resources/application-test.yaml b/payguard-loan-service/src/test/resources/application-test.yaml index 291eb64..3f74d54 100644 --- a/payguard-loan-service/src/test/resources/application-test.yaml +++ b/payguard-loan-service/src/test/resources/application-test.yaml @@ -1,16 +1,13 @@ +payguard: + vault: + enabled: false spring: - datasource: - # Replaced by LoanServiceApplicationTests.oracleProperties at runtime. - url: ${TEST_ORACLE_JDBC_URL:jdbc:oracle:thin:@//localhost:1521/XEPDB1} - username: ${TEST_ORACLE_USERNAME:PAYGUARD_TEST} - password: ${TEST_ORACLE_PASSWORD:payguard_test} - driver-class-name: oracle.jdbc.OracleDriver - jpa: - database-platform: org.hibernate.dialect.OracleDialect - hibernate: - ddl-auto: none - open-in-view: false flyway: - # Schema migrations are exercised independently; context tests only need - # the Oracle-backed JPA infrastructure to start. - enabled: false + enabled: true + +management: + endpoint: + health: + group: + readiness: + include: readinessState,db diff --git a/payguard-loan-service/src/test/resources/application.yaml b/payguard-loan-service/src/test/resources/application.yaml deleted file mode 100644 index f9b727c..0000000 --- a/payguard-loan-service/src/test/resources/application.yaml +++ /dev/null @@ -1,5 +0,0 @@ -spring: - autoconfigure: - exclude: - - org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration - - org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration diff --git a/payguard-loan-service/src/test/resources/postgres-test-init.sql b/payguard-loan-service/src/test/resources/postgres-test-init.sql new file mode 100644 index 0000000..d1ce84d --- /dev/null +++ b/payguard-loan-service/src/test/resources/postgres-test-init.sql @@ -0,0 +1 @@ +CREATE SCHEMA payguard; diff --git a/payguard-shared/pom.xml b/payguard-shared/pom.xml index 21f65ed..ee0691f 100644 --- a/payguard-shared/pom.xml +++ b/payguard-shared/pom.xml @@ -12,6 +12,51 @@ payguard-shared + + org.flywaydb + flyway-core + true + + + org.flywaydb + flyway-database-postgresql + true + + + org.junit.platform + junit-platform-console-standalone + 6.0.3 + test + + + org.springframework.boot + spring-boot-starter-jdbc + true + + + tools.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-starter-actuator + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.postgresql + postgresql + test + org.springframework.kafka spring-kafka diff --git a/payguard-shared/src/main/java/infrastructure/vault/IdentityDataImporter.java b/payguard-shared/src/main/java/infrastructure/vault/IdentityDataImporter.java new file mode 100644 index 0000000..c5de9cb --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/IdentityDataImporter.java @@ -0,0 +1,194 @@ +package infrastructure.vault; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** Offline parent-before-child import into an empty, separately migrated destination. */ +public final class IdentityDataImporter { + private static final org.slf4j.Logger LOG = + org.slf4j.LoggerFactory.getLogger(IdentityDataImporter.class); + private static final String MANIFEST = "manifest"; + private static final String AUDIT_TABLE = "identity_audit_log"; + private static final List CHALLENGE = + List.of( + "id", + "user_id", + "purpose", + "channel", + "verification_mode", + "code_hash", + "device_binding_hash", + "attempts", + "max_attempts", + "status", + "created_at", + "expires_at", + "consumed_at", + "retention_until"); + private static final List AUDIT = + List.of("id", "challenge_id", "user_id", "action", "reason", "source_ip", "occurred_at"); + + private IdentityDataImporter() {} + + public static void main(String[] arguments) { + try { + String url = System.getenv("PAYGUARD_MIGRATION_JDBC_URL"); + if (arguments.length != 1 || url == null) { + throw new IllegalArgumentException( + "Explicit protected input and verified PostgreSQL TLS required"); + } + PostgreSqlTls.requireVerified(url); + JsonNode input = + JsonMapper.builder().build().readTree(Files.readString(Path.of(arguments[0]))); + try (Connection connection = + DriverManager.getConnection( + url, + System.getenv("PAYGUARD_MIGRATION_USERNAME"), + System.getenv("PAYGUARD_MIGRATION_PASSWORD"))) { + try (var statement = connection.createStatement()) { + statement.execute("SET ROLE identity_owner"); + statement.execute("SET search_path TO payguard"); + } + importData(connection, input); + } + LOG.info( + "Identity import committed: row counts, references and representative fields reconciled"); + } catch (Exception exception) { + LOG.error("Identity import refused; protected diagnostics suppressed"); + System.exit(1); + } + } + + public static void importData(Connection connection, JsonNode input) throws SQLException { + boolean originalAutoCommit = connection.getAutoCommit(); + if (!originalAutoCommit + || input.path(MANIFEST).path("approval_reference").asText().isBlank() + || !input.path(MANIFEST).path("mapping_sha256").asText().matches("[a-f0-9]{64}")) { + throw new SQLException("Separate import connection and approved mapping manifest required"); + } + connection.setAutoCommit(false); + try { + try (var statement = connection.createStatement()) { + statement.execute( + "LOCK TABLE identity_challenge, identity_audit_log IN ACCESS EXCLUSIVE MODE"); + for (String table : List.of("identity_challenge", AUDIT_TABLE)) { + try (var rows = statement.executeQuery("SELECT count(*) FROM " + table)) { + if (!rows.next()) throw new SQLException("Reconciliation query returned no result"); + if (rows.getLong(1) != 0) throw new SQLException("Import destination is populated"); + } + } + } + insert(connection, "identity_challenge", CHALLENGE, input.path("challenges")); + insert(connection, AUDIT_TABLE, AUDIT, input.path("audit")); + try (var statement = connection.createStatement()) { + statement.executeUpdate( + "INSERT INTO identity_challenge_scope(user_id,purpose) SELECT DISTINCT user_id,purpose FROM identity_challenge ON CONFLICT DO NOTHING"); + try (var rows = + statement.executeQuery( + "SELECT count(*) FROM identity_audit_log a JOIN identity_challenge c ON c.id=a.challenge_id WHERE c.user_id<>a.user_id")) { + if (!rows.next()) throw new SQLException("Reconciliation query returned no result"); + if (rows.getLong(1) != 0) throw new SQLException("Audit subject mismatch"); + } + } + reconcile( + connection, + "identity_challenge", + CHALLENGE, + input.path("challenges"), + input.path(MANIFEST).path("challenge_count").asInt(-1)); + reconcile( + connection, + AUDIT_TABLE, + AUDIT, + input.path("audit"), + input.path(MANIFEST).path("audit_count").asInt(-1)); + connection.commit(); + } catch (SQLException | RuntimeException exception) { + connection.rollback(); + throw exception; + } finally { + connection.setAutoCommit(originalAutoCommit); + } + } + + private static void insert( + Connection connection, String table, List columns, JsonNode data) + throws SQLException { + if (!data.isArray()) throw new SQLException("Expected export rows"); + String placeholders = String.join(",", java.util.Collections.nCopies(columns.size(), "?")); + try (var statement = + connection.prepareStatement( + "INSERT INTO " + + table + + "(" + + String.join(",", columns) + + ") VALUES (" + + placeholders + + ")")) { + for (JsonNode row : data) { + for (int i = 0; i < columns.size(); i++) + statement.setObject(i + 1, value(table, columns.get(i), row)); + statement.executeUpdate(); + } + } + } + + private static Object value(String table, String column, JsonNode row) throws SQLException { + JsonNode node = row.get(column); + if (node == null) throw new SQLException("Export column missing"); + if (node.isNull()) return null; + if (column.endsWith("_at") || "retention_until".equals(column)) + return OffsetDateTime.parse(node.asText()); + if ("attempts".equals(column) || "max_attempts".equals(column)) { + if (!node.isIntegralNumber() || !node.canConvertToInt()) + throw new SQLException("Invalid exported integer"); + return node.asInt(); + } + if (!node.isTextual()) throw new SQLException("Invalid exported text"); + if (AUDIT_TABLE.equals(table) && "id".equals(column)) return UUID.fromString(node.asText()); + return node.asText(); + } + + private static void reconcile( + Connection connection, String table, List columns, JsonNode data, int expected) + throws SQLException { + try (var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT count(*) FROM " + table)) { + if (!rows.next()) throw new SQLException("Reconciliation query returned no result"); + if (rows.getInt(1) != expected || expected != data.size()) + throw new SQLException("Export count mismatch"); + } + try (var statement = + connection.prepareStatement( + "SELECT " + String.join(",", columns) + " FROM " + table + " WHERE id=?")) { + for (JsonNode row : data) { + statement.setObject(1, value(table, "id", row)); + try (var rows = statement.executeQuery()) { + if (!rows.next()) throw new SQLException("Export identity missing"); + for (String column : columns) { + Object expectedValue = value(table, column, row); + Object stored = + expectedValue instanceof OffsetDateTime + ? rows.getObject(column, OffsetDateTime.class) + : rows.getObject(column); + if (stored instanceof OffsetDateTime timestamp + && expectedValue instanceof OffsetDateTime source) { + if (!timestamp.toInstant().equals(source.toInstant())) + throw new SQLException("Export instant mismatch"); + } else if (!java.util.Objects.equals(expectedValue, stored)) { + throw new SQLException("Export field mismatch"); + } + } + } + } + } + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/LeasedDataSource.java b/payguard-shared/src/main/java/infrastructure/vault/LeasedDataSource.java new file mode 100644 index 0000000..9e682ca --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/LeasedDataSource.java @@ -0,0 +1,221 @@ +package infrastructure.vault; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.Nullable; +import org.springframework.jdbc.datasource.AbstractDataSource; + +/** + * Replaces validated pool generations before lease expiry. Borrowers already in a transaction keep + * their generation during a bounded drain. This class never retries application transactions. + */ +public final class LeasedDataSource extends AbstractDataSource implements AutoCloseable { + private static final int MAX_RETIRED_GENERATIONS = 8; + private static final Duration RETRY_DELAY = Duration.ofSeconds(1); + private final VaultClient vault; + private final String role; + private final String jdbcUrl; + private final Duration drain; + private final List retired = new ArrayList<>(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("database-lease-maintenance").factory()); + private @Nullable Generation current; + private boolean closed; + private long replacements; + private Instant nextAttempt = Instant.EPOCH; + private boolean rotationRequested; + + public LeasedDataSource(VaultClient vault, String role, String jdbcUrl, Duration drain) { + PostgreSqlTls.requireVerified(jdbcUrl); + if (!role.matches("[a-z][a-z0-9-]+-runtime")) { + throw new IllegalArgumentException( + "Runtime database requires a scoped role and verified PostgreSQL TLS"); + } + this.vault = vault; + this.role = role; + this.jdbcUrl = jdbcUrl; + this.drain = drain; + replace(); + scheduler.scheduleWithFixedDelay(this::maintain, 1, 1, TimeUnit.SECONDS); + } + + private synchronized void replace() { + if (retired.size() >= MAX_RETIRED_GENERATIONS) { + throw new VaultUnavailableException(); + } + Instant issued = Instant.now(); + var secret = vault.read("database/creds/" + role); + String lease = secret.path("lease_id").asText(); + long ttl = secret.path("lease_duration").asLong(); + if (ttl < 20 || !lease.startsWith("database/creds/" + role + "/")) { + throw new VaultUnavailableException(); + } + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(jdbcUrl); + config.setUsername(secret.path("data").path("username").asText()); + config.setPassword(secret.path("data").path("password").asText()); + config.setPoolName("payguard-runtime-" + replacements); + config.setMaximumPoolSize(4); + config.setMinimumIdle(0); + config.setConnectionTimeout(1000); + config.setValidationTimeout(1000); + config.setInitializationFailTimeout(1000); + config.setConnectionInitSql("SET search_path TO payguard"); + HikariDataSource candidate; + try { + candidate = new HikariDataSource(config); + } catch (RuntimeException exception) { + revoke(lease); + throw new VaultUnavailableException(exception); + } + try (var connection = candidate.getConnection()) { + if (!connection.isValid(1) || !Instant.now().isBefore(issued.plusSeconds(ttl - 2))) { + throw new SQLException("Database validation failed"); + } + validateRuntime(connection); + } catch (SQLException exception) { + candidate.close(); + revoke(lease); + throw new VaultUnavailableException(exception); + } + Generation previous = current; + current = + new Generation(candidate, lease, issued.plusSeconds(ttl / 2), issued.plusSeconds(ttl - 2)); + replacements++; + nextAttempt = Instant.EPOCH; + rotationRequested = false; + if (previous != null) { + previous.retireAt = Instant.now().plus(drain); + previous.pool.getHikariPoolMXBean().softEvictConnections(); + retired.add(previous); + } + } + + private synchronized void maintain() { + if (closed) { + return; + } + Instant now = Instant.now(); + Generation generation = current; + if (generation != null + && (rotationRequested || !now.isBefore(generation.replaceAt)) + && !now.isBefore(nextAttempt)) { + try { + replace(); + } catch (VaultUnavailableException exception) { + /* Retry once per scheduled interval while the prior lease remains valid. */ + nextAttempt = Instant.now().plus(RETRY_DELAY); + } + } + retired.removeIf( + old -> { + if (old.pool.isClosed() + || old.pool.getHikariPoolMXBean().getActiveConnections() == 0 + || !now.isBefore(old.retireAt) + || !now.isBefore(old.deadline)) { + old.pool.close(); + return revoke(old.lease); + } + return false; + }); + if (generation != null && !now.isBefore(generation.deadline)) { + generation.pool.close(); + } + } + + private boolean revoke(String lease) { + try { + vault.write("sys/leases/revoke", Map.of("lease_id", lease)); + return true; + } catch (VaultUnavailableException exception) { + /* The engine also revokes expired leases. Retry graceful revocation during maintenance. */ + return false; + } + } + + @Override + public synchronized Connection getConnection() throws SQLException { + Generation generation = current; + if (closed || generation == null || !Instant.now().isBefore(generation.deadline)) { + throw new SQLException("Runtime database credential is unavailable"); + } + try { + Connection connection = generation.pool.getConnection(); + try { + validateRuntime(connection); + return connection; + } catch (SQLException exception) { + connection.close(); + throw exception; + } + } catch (SQLException exception) { + rotationRequested = true; + throw new SQLException( + "Runtime database connection is unavailable", + VaultUnavailableException.sanitizedCause(exception)); + } + } + + private static void validateRuntime(Connection connection) throws SQLException { + try (var statement = connection.createStatement()) { + statement.setQueryTimeout(1); + try (var rows = + statement.executeQuery( + "SELECT has_schema_privilege(current_user,'payguard','USAGE') AND has_database_privilege(current_user,current_database(),'CONNECT')")) { + if (!rows.next() || !rows.getBoolean(1)) + throw new SQLException("Runtime database privileges unavailable"); + } + } + } + + @Override + public Connection getConnection(String username, String password) throws SQLException { + throw new SQLException("Caller-supplied database credentials are not supported"); + } + + /** Non-sensitive count used to verify continuous replacement without exposing credentials. */ + public synchronized long replacementCount() { + return replacements; + } + + @Override + public synchronized void close() { + closed = true; + scheduler.shutdownNow(); + if (current != null) { + current.pool.close(); + revoke(current.lease); + } + for (Generation old : retired) { + old.pool.close(); + revoke(old.lease); + } + retired.clear(); + } + + private static final class Generation { + private final HikariDataSource pool; + private final String lease; + private final Instant replaceAt; + private final Instant deadline; + private Instant retireAt = Instant.MAX; + + private Generation(HikariDataSource pool, String lease, Instant replaceAt, Instant deadline) { + this.pool = pool; + this.lease = lease; + this.replaceAt = replaceAt; + this.deadline = deadline; + } + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/PostgreSqlTls.java b/payguard-shared/src/main/java/infrastructure/vault/PostgreSqlTls.java new file mode 100644 index 0000000..9e6adfc --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/PostgreSqlTls.java @@ -0,0 +1,44 @@ +package infrastructure.vault; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Rejects JDBC parameter overrides that could bypass the selected certificate/credential policy. + */ +final class PostgreSqlTls { + private static final String PREFIX = "jdbc:postgresql://"; + private static final int PARAMETER_PARTS = 2; + private static final Set FORBIDDEN = + Set.of("user", "password", "sslfactory", "sslhostnameverifier"); + + private PostgreSqlTls() {} + + static void requireVerified(String url) { + if (!url.startsWith(PREFIX)) + throw new IllegalArgumentException("Verified PostgreSQL TLS required"); + URI address = URI.create(url.substring("jdbc:".length())); + if (address.getHost() == null + || address.getUserInfo() != null + || address.getFragment() != null + || address.getRawQuery() == null) + throw new IllegalArgumentException("Invalid PostgreSQL endpoint"); + Map parameters = new HashMap<>(); + for (String entry : address.getRawQuery().split("&")) { + String[] pair = entry.split("=", 2); + if (pair.length != PARAMETER_PARTS) + throw new IllegalArgumentException("Invalid PostgreSQL parameter"); + String name = URLDecoder.decode(pair[0], StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair[1], StandardCharsets.UTF_8); + if (FORBIDDEN.contains(name) || parameters.putIfAbsent(name, value) != null) + throw new IllegalArgumentException("PostgreSQL credential/TLS override rejected"); + } + if (!"verify-full".equals(parameters.get("sslmode")) + || !parameters.getOrDefault("sslrootcert", "").startsWith("/")) + throw new IllegalArgumentException("Verified PostgreSQL TLS and explicit CA required"); + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/SchemaMigrator.java b/payguard-shared/src/main/java/infrastructure/vault/SchemaMigrator.java new file mode 100644 index 0000000..c6f3630 --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/SchemaMigrator.java @@ -0,0 +1,71 @@ +package infrastructure.vault; + +import java.sql.DriverManager; +import java.util.Set; +import org.flywaydb.core.Flyway; + +/** Separate operator entry point. Migration logins SET ROLE to the no-login owner before DDL. */ +public final class SchemaMigrator { + private static final String IDENTITY = "identity"; + private static final org.slf4j.Logger LOG = + org.slf4j.LoggerFactory.getLogger(SchemaMigrator.class); + + private SchemaMigrator() {} + + public static void main(String[] arguments) { + try { + String service = required("PAYGUARD_MIGRATION_SERVICE"); + if (!Set.of("identity", "loan", "collateral").contains(service)) { + throw new IllegalArgumentException("No supplied schema for this service"); + } + String url = required("PAYGUARD_MIGRATION_JDBC_URL"); + PostgreSqlTls.requireVerified(url); + String user = required("PAYGUARD_MIGRATION_USERNAME"); + String password = required("PAYGUARD_MIGRATION_PASSWORD"); + Flyway.configure() + .dataSource(url, user, password) + .initSql("SET ROLE " + service + "_owner") + .schemas("payguard") + .defaultSchema("payguard") + .createSchemas(false) + .cleanDisabled(true) + .locations( + "filesystem:/workspace/payguard-" + + service + + "-service/src/main/resources/db/migration/postgresql") + .load() + .migrate(); + try (var connection = DriverManager.getConnection(url, user, password); + var statement = connection.createStatement()) { + statement.execute("SET ROLE " + service + "_owner"); + statement.execute("ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC"); + statement.execute( + "REVOKE ALL ON payguard.flyway_schema_history FROM " + service + "_runtime"); + if (IDENTITY.equals(service)) { + statement.execute( + "REVOKE UPDATE, DELETE ON payguard.identity_audit_log FROM identity_runtime"); + statement.execute( + "REVOKE DELETE ON payguard.identity_challenge_scope FROM identity_runtime"); + } else { + statement.execute( + "REVOKE UPDATE, DELETE ON payguard." + + service + + "_audit_log FROM " + + service + + "_runtime"); + } + } + } catch (RuntimeException | java.sql.SQLException exception) { + LOG.error("Migration failed; secret-bearing connection diagnostics suppressed"); + System.exit(1); + } + } + + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Missing protected migration configuration"); + } + return value; + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/VaultClient.java b/payguard-shared/src/main/java/infrastructure/vault/VaultClient.java new file mode 100644 index 0000000..037a30c --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/VaultClient.java @@ -0,0 +1,193 @@ +package infrastructure.vault; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * Scoped AppRole client with TLS verification, bounded calls and sanitized failures. Authentication + * material lives in provisioned files. A missing KV item is distinct from denial or an outage. + */ +public final class VaultClient implements AutoCloseable { + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(VaultClient.class); + private static final String POST = "POST"; + private final URI address; + private final Path roleIdFile; + private final Path secretIdFile; + private final HttpClient http; + private final JsonMapper json = JsonMapper.builder().build(); + private String token = ""; + private Instant renewAt = Instant.EPOCH; + private Instant expiresAt = Instant.EPOCH; + + public VaultClient(URI address, Path caFile, Path roleIdFile, Path secretIdFile) { + if (!"https".equals(address.getScheme()) || address.getUserInfo() != null) { + throw new IllegalArgumentException("Vault requires an HTTPS endpoint without credentials"); + } + this.address = address; + this.roleIdFile = roleIdFile; + this.secretIdFile = secretIdFile; + try { + KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType()); + trust.load(null); + try (var input = Files.newInputStream(caFile)) { + int index = 0; + for (var certificate : + CertificateFactory.getInstance("X.509").generateCertificates(input)) { + trust.setCertificateEntry("ca-" + index, certificate); + index++; + } + } + TrustManagerFactory managers = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + managers.init(trust); + SSLContext tls = SSLContext.getInstance("TLS"); + tls.init(null, managers.getTrustManagers(), null); + http = HttpClient.newBuilder().sslContext(tls).connectTimeout(Duration.ofSeconds(3)).build(); + } catch (Exception exception) { + throw new VaultUnavailableException(exception); + } + } + + public JsonNode read(String path) { + return request("GET", path, null, false).orElseThrow(VaultUnavailableException::new); + } + + public Optional readOptional(String path) { + return request("GET", path, null, true); + } + + public JsonNode write(String path, Map body) { + return request(POST, path, body, false).orElseThrow(VaultUnavailableException::new); + } + + private synchronized Optional request( + String method, String path, @Nullable Map body, boolean missingAllowed) { + authenticate(); + return exchange(method, path, body, token, missingAllowed); + } + + private void authenticate() { + Instant now = Instant.now(); + if (!token.isEmpty() && now.isBefore(renewAt)) { + return; + } + if (!token.isEmpty() && now.isBefore(expiresAt)) { + try { + acceptAuthentication( + exchange(POST, "auth/token/renew-self", Map.of(), token, false).orElseThrow()); + return; + } catch (VaultUnavailableException exception) { + /* A still-valid token can be used briefly; expired tokens require reauthentication. */ + renewAt = now.plusSeconds(1); + return; + } + } + try { + acceptAuthentication( + exchange( + POST, + "auth/approle/login", + Map.of( + "role_id", Files.readString(roleIdFile).strip(), + "secret_id", Files.readString(secretIdFile).strip()), + "", + false) + .orElseThrow()); + } catch (IOException exception) { + throw new VaultUnavailableException(exception); + } + } + + private void acceptAuthentication(JsonNode response) { + JsonNode auth = response.path("auth"); + String nextToken = auth.path("client_token").asText(); + long ttl = auth.path("lease_duration").asLong(); + if (nextToken.isEmpty() || ttl < 2 || auth.path("policies").toString().contains("\"root\"")) { + throw new VaultUnavailableException(); + } + token = nextToken; + expiresAt = Instant.now().plusSeconds(ttl); + renewAt = Instant.now().plusSeconds(Math.max(1, ttl / 2)); + } + + private Optional exchange( + String method, + String path, + @Nullable Map body, + String authentication, + boolean missingAllowed) { + if (!path.matches("[A-Za-z0-9_./-]+") || path.contains("..") || path.startsWith("/")) { + throw new IllegalArgumentException("Invalid secret path"); + } + try { + HttpRequest.Builder request = + HttpRequest.newBuilder(address.resolve("/v1/" + path)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json"); + if (!authentication.isEmpty()) { + request.header("X-Vault-Token", authentication); + } + request.method( + method, + body == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))); + var response = http.send(request.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 404 && missingAllowed) { + JsonNode missing = json.readTree(response.body()); + JsonNode errors = missing.path("errors"); + boolean absentKv = + (errors.isArray() && errors.isEmpty()) + || missing.path("data").path("metadata").path("version").asInt() > 0; + if (path.startsWith("secret/data/") && absentKv) return Optional.empty(); + throw new VaultUnavailableException(); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + if ((response.statusCode() == 401 || response.statusCode() == 403) + && authentication.equals(token)) { + token = ""; + renewAt = Instant.EPOCH; + expiresAt = Instant.EPOCH; + } + throw new VaultUnavailableException(); + } + return Optional.of( + response.body().isBlank() ? json.createObjectNode() : json.readTree(response.body())); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new VaultUnavailableException(exception); + } catch (IOException | tools.jackson.core.JacksonException exception) { + throw new VaultUnavailableException(exception); + } + } + + @Override + public synchronized void close() { + if (!token.isEmpty()) { + try { + exchange(POST, "auth/token/revoke-self", Map.of(), token, false); + } catch (VaultUnavailableException exception) { + /* Vault expires unreachable tokens and their remaining leases independently. */ + LOG.warn("Vault token cleanup deferred to its expiry"); + } + } + http.close(); + token = ""; + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/VaultDatabaseAutoConfiguration.java b/payguard-shared/src/main/java/infrastructure/vault/VaultDatabaseAutoConfiguration.java new file mode 100644 index 0000000..d5dcce1 --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/VaultDatabaseAutoConfiguration.java @@ -0,0 +1,47 @@ +package infrastructure.vault; + +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; + +/** Infrastructure-only composition shared by the four database-owning services. */ +@AutoConfiguration(before = DataSourceAutoConfiguration.class) +@ConditionalOnProperty(name = "payguard.vault.enabled", havingValue = "true") +public class VaultDatabaseAutoConfiguration { + @Bean(destroyMethod = "close") + VaultClient vaultClient(Environment environment) { + return new VaultClient( + URI.create(environment.getRequiredProperty("payguard.vault.address")), + Path.of(environment.getRequiredProperty("payguard.vault.ca-file")), + Path.of(environment.getRequiredProperty("payguard.vault.role-id-file")), + Path.of(environment.getRequiredProperty("payguard.vault.secret-id-file"))); + } + + @Bean(destroyMethod = "close") + LeasedDataSource dataSource(VaultClient vault, Environment environment) { + return new LeasedDataSource( + vault, + environment.getRequiredProperty("payguard.vault.database-role"), + environment.getRequiredProperty("spring.datasource.url"), + Duration.ofSeconds(10)); + } + + @Bean + HealthIndicator vaultHealthIndicator(VaultClient vault) { + return () -> { + try { + vault.read("auth/token/lookup-self"); + return Health.up().build(); + } catch (VaultUnavailableException exception) { + return Health.down().build(); + } + }; + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/VaultUnavailableException.java b/payguard-shared/src/main/java/infrastructure/vault/VaultUnavailableException.java new file mode 100644 index 0000000..e85dc89 --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/VaultUnavailableException.java @@ -0,0 +1,23 @@ +package infrastructure.vault; + +/** A dependency failure whose message deliberately excludes secret-bearing responses. */ +public final class VaultUnavailableException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public VaultUnavailableException() { + super("Required secret service is unavailable or access is denied"); + } + + public VaultUnavailableException(Throwable failure) { + super("Required secret service is unavailable or access is denied", sanitizedCause(failure)); + } + + /** + * Keep diagnostic stack frames while discarding messages and nested credential-bearing causes. + */ + static Throwable sanitizedCause(Throwable failure) { + var sanitized = new IllegalStateException("Dependency details redacted"); + sanitized.setStackTrace(failure.getStackTrace()); + return sanitized; + } +} diff --git a/payguard-shared/src/main/java/infrastructure/vault/package-info.java b/payguard-shared/src/main/java/infrastructure/vault/package-info.java new file mode 100644 index 0000000..7deca3b --- /dev/null +++ b/payguard-shared/src/main/java/infrastructure/vault/package-info.java @@ -0,0 +1,2 @@ +@org.jspecify.annotations.NullMarked +package infrastructure.vault; diff --git a/payguard-shared/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/payguard-shared/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..7820e61 --- /dev/null +++ b/payguard-shared/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +infrastructure.vault.VaultDatabaseAutoConfiguration diff --git a/payguard-shared/src/test/java/infrastructure/vault/PostgreSqlTlsTest.java b/payguard-shared/src/test/java/infrastructure/vault/PostgreSqlTlsTest.java new file mode 100644 index 0000000..1eb5bd9 --- /dev/null +++ b/payguard-shared/src/test/java/infrastructure/vault/PostgreSqlTlsTest.java @@ -0,0 +1,29 @@ +package infrastructure.vault; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class PostgreSqlTlsTest { + private static final String URL = + "jdbc:postgresql://postgres-identity:5432/payguard_identity?sslmode=verify-full&sslrootcert=/run/ca.crt"; + + @Test + void duplicateOrEncodedOverridesAndCustomVerifiersCannotDisableCertificateChecks() { + assertDoesNotThrow(() -> PostgreSqlTls.requireVerified(URL)); + for (String override : + new String[] { + "sslmode=disable", + "ssl%6dode=disable", + "password=synthetic-marker", + "sslfactory=org.postgresql.ssl.NonValidatingFactory", + "sslhostnameverifier=custom.Verifier" + }) { + String configured = URL + "&" + override; + assertThrows(IllegalArgumentException.class, () -> PostgreSqlTls.requireVerified(configured)); + } + String disabled = URL.replace("verify-full", "require"); + assertThrows(IllegalArgumentException.class, () -> PostgreSqlTls.requireVerified(disabled)); + } +} diff --git a/payguard-shared/src/test/java/infrastructure/vault/PostgresqlMigrationRehearsalTest.java b/payguard-shared/src/test/java/infrastructure/vault/PostgresqlMigrationRehearsalTest.java new file mode 100644 index 0000000..485680c --- /dev/null +++ b/payguard-shared/src/test/java/infrastructure/vault/PostgresqlMigrationRehearsalTest.java @@ -0,0 +1,151 @@ +package infrastructure.vault; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.json.JsonMapper; + +/** Synthetic offline import and populated PostgreSQL upgrade, without live Oracle access. */ +class PostgresqlMigrationRehearsalTest { + private static final String LOCATION = + "filesystem:../payguard-identity-service/src/main/resources/db/migration/postgresql"; + + @Test + void v1ToV2UpgradePreservesRecordsAndRejectsTwoHistoricalActiveRows() throws Exception { + try (var database = new PostgreSQLContainer("postgres:17.11-bookworm")) { + database.start(); + Flyway.configure() + .dataSource(database.getJdbcUrl(), database.getUsername(), database.getPassword()) + .schemas("payguard") + .defaultSchema("payguard") + .locations(LOCATION) + .target("1") + .load() + .migrate(); + try (Connection connection = connect(database); + var statement = connection.createStatement()) { + statement.executeUpdate(insert("A", "ACTIVE")); + statement.executeUpdate(insert("B", "ACTIVE")); + var next = + Flyway.configure() + .dataSource(database.getJdbcUrl(), database.getUsername(), database.getPassword()) + .schemas("payguard") + .defaultSchema("payguard") + .locations(LOCATION) + .load(); + assertThrows(org.flywaydb.core.api.FlywayException.class, next::migrate); + assertEquals(2, count(connection, "identity_challenge")); + /* This fixture explicitly resolves the synthetic duplicate; the migration never deletes it. */ + statement.executeUpdate( + "UPDATE identity_challenge SET status='INVALIDATED' WHERE id='otp_" + + "B".repeat(22) + + "'"); + next.migrate(); + assertEquals(2, count(connection, "identity_challenge")); + assertEquals(1, count(connection, "identity_challenge_scope")); + assertEquals(2, count(connection, "flyway_schema_history")); + } + } + } + + @Test + void importerPreservesUuidReferencesNullableFieldsAndInstantsAndRefusesPopulatedDestination() + throws Exception { + try (var database = new PostgreSQLContainer("postgres:17.11-bookworm")) { + database.start(); + Flyway.configure() + .dataSource(database.getJdbcUrl(), database.getUsername(), database.getPassword()) + .schemas("payguard") + .defaultSchema("payguard") + .locations(LOCATION) + .load() + .migrate(); + var json = JsonMapper.builder().build(); + var input = + json.readTree( + """ + {"challenges":[{"id":"otp_AAAAAAAAAAAAAAAAAAAAAA","user_id":"cus_aaaaaaaaaaaaaaaaaaaaaa", + "purpose":"LOGIN","channel":"SMS","verification_mode":"RANDOM_NUMERIC","code_hash":"synthetic-hash", + "device_binding_hash":null,"attempts":1,"max_attempts":5,"status":"USED", + "created_at":"2026-01-01T03:30:00.123456+03:30","expires_at":"2026-01-01T00:05:00Z", + "consumed_at":"2026-01-01T00:00:01.123456Z","retention_until":"2029-01-02T00:00:00Z"}], + "audit":[{"id":"00112233-4455-6677-8899-aabbccddeeff","challenge_id":"otp_AAAAAAAAAAAAAAAAAAAAAA", + "user_id":"cus_aaaaaaaaaaaaaaaaaaaaaa","action":"VERIFY","reason":"verified","source_ip":null, + "occurred_at":"2026-01-01T00:00:01.123456Z"}], + "manifest":{"challenge_count":1,"audit_count":1,"approval_reference":"synthetic-only", + "mapping_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} + """); + try (Connection connection = connect(database)) { + var orphan = input.deepCopy(); + ((tools.jackson.databind.node.ObjectNode) orphan.path("audit").get(0)) + .put("challenge_id", "otp_" + "B".repeat(22)); + assertThrows( + java.sql.SQLException.class, () -> IdentityDataImporter.importData(connection, orphan)); + assertEquals(0, count(connection, "identity_challenge")); + assertEquals(0, count(connection, "identity_audit_log")); + var badInteger = input.deepCopy(); + ((tools.jackson.databind.node.ObjectNode) badInteger.path("challenges").get(0)) + .put("attempts", "not-an-integer"); + assertThrows( + java.sql.SQLException.class, + () -> IdentityDataImporter.importData(connection, badInteger)); + assertEquals(0, count(connection, "identity_challenge")); + IdentityDataImporter.importData(connection, input); + assertEquals(1, count(connection, "identity_challenge")); + assertEquals(1, count(connection, "identity_audit_log")); + assertThrows( + java.sql.SQLException.class, () -> IdentityDataImporter.importData(connection, input)); + try (var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT id,created_at,consumed_at,device_binding_hash,user_id FROM identity_challenge")) { + assertTrue(rows.next()); + assertEquals("cus_" + "a".repeat(22), rows.getString("user_id")); + assertEquals( + java.time.Instant.parse("2026-01-01T00:00:00.123456Z"), + rows.getObject("created_at", java.time.OffsetDateTime.class).toInstant()); + assertTrue(rows.getObject("consumed_at") != null); + assertTrue(rows.getObject("device_binding_hash") == null); + } + } + } + } + + private static Connection connect(PostgreSQLContainer database) throws java.sql.SQLException { + return DriverManager.getConnection( + database.getJdbcUrl() + "¤tSchema=payguard", + database.getUsername(), + database.getPassword()); + } + + private static int count(Connection connection, String table) throws java.sql.SQLException { + try (var statement = connection.createStatement(); + var rows = + statement.executeQuery( + "SELECT count(*) FROM " + + table + + " WHERE " + + (table.equals("flyway_schema_history") + ? "success AND version IS NOT NULL" + : "true"))) { + rows.next(); + return rows.getInt(1); + } + } + + private static String insert(String suffix, String status) { + return "INSERT INTO identity_challenge(id,user_id,purpose,channel,verification_mode,code_hash,max_attempts,status,created_at,expires_at,retention_until) VALUES ('otp_" + + suffix.repeat(22) + + "','cus_" + + "A".repeat(22) + + "','LOGIN','SMS','RANDOM_NUMERIC','synthetic',5,'" + + status + + "','2026-01-01T00:00:00Z','2026-01-01T00:05:00Z','2029-01-02T00:00:00Z')"; + } +} diff --git a/payguard-shared/src/test/java/infrastructure/vault/SanitizedDependencyErrorTest.java b/payguard-shared/src/test/java/infrastructure/vault/SanitizedDependencyErrorTest.java new file mode 100644 index 0000000..3aba156 --- /dev/null +++ b/payguard-shared/src/test/java/infrastructure/vault/SanitizedDependencyErrorTest.java @@ -0,0 +1,25 @@ +package infrastructure.vault; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +class SanitizedDependencyErrorTest { + @Test + void renderedStackTraceKeepsFramesWithoutOriginalSecretMessagesOrNestedCauses() { + var original = + new SQLException( + "synthetic-password-error-marker", + new IllegalArgumentException("synthetic-token-error-marker")); + var safe = new VaultUnavailableException(original); + var rendered = new StringWriter(); + safe.printStackTrace(new PrintWriter(rendered)); + assertFalse(rendered.toString().contains("synthetic-password-error-marker")); + assertFalse(rendered.toString().contains("synthetic-token-error-marker")); + assertTrue(rendered.toString().contains("SanitizedDependencyErrorTest")); + } +} diff --git a/payguard-shared/src/test/java/infrastructure/vault/VaultDatabaseIT.java b/payguard-shared/src/test/java/infrastructure/vault/VaultDatabaseIT.java new file mode 100644 index 0000000..40e9105 --- /dev/null +++ b/payguard-shared/src/test/java/infrastructure/vault/VaultDatabaseIT.java @@ -0,0 +1,471 @@ +package infrastructure.vault; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * Runs only through the explicit local/CI Vault runner against provisioned, isolated TLS engines. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class VaultDatabaseIT { + private static final Path LOCAL = Path.of("/workspace/.local/payguard"); + private static final String PROBE = + "vault_probe_" + java.util.UUID.randomUUID().toString().replace("-", ""); + private static final String ROLE = "local-identity-runtime"; + private static VaultClient operator; + private static JsonNode originalRole; + + @BeforeAll + static void prepare() throws Exception { + assertEquals("true", System.getenv("PAYGUARD_REHEARSAL")); + operator = client(LOCAL.resolve("operator/approle")); + originalRole = operator.read("database/roles/" + ROLE).path("data"); + operator.write( + "database/roles/" + ROLE, + Map.of( + "db_name", + "local-identity", + "creation_statements", + originalRole.path("creation_statements"), + "revocation_statements", + originalRole.path("revocation_statements"), + "renew_statements", + originalRole.path("renew_statements"), + "default_ttl", + "30s", + "max_ttl", + "45s")); + try (Connection connection = migration("identity"); + var statement = connection.createStatement()) { + statement.execute( + "CREATE TABLE payguard." + + PROBE + + " (id integer PRIMARY KEY, marker varchar(64) NOT NULL)"); + } + } + + @AfterAll + static void restore() throws Exception { + if (operator != null) { + try { + if (originalRole != null) { + operator.write( + "database/roles/" + ROLE, + Map.of( + "db_name", + "local-identity", + "creation_statements", + originalRole.path("creation_statements"), + "revocation_statements", + originalRole.path("revocation_statements"), + "renew_statements", + originalRole.path("renew_statements"), + "default_ttl", + "1h", + "max_ttl", + "4h")); + } + try (Connection connection = migration("identity"); + var statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS payguard." + PROBE); + } + } finally { + operator.close(); + } + } + } + + @Test + @Order(1) + void independentlyScopedRuntimeRolesAreUsableAndDeniedDdlAndOtherServicePaths() throws Exception { + for (String service : new String[] {"identity", "wallet", "loan", "collateral"}) { + try (VaultClient vault = client(LOCAL.resolve("apps/" + service))) { + try (Connection owner = migration(service); + var statement = owner.createStatement()) { + statement.execute("CREATE TABLE payguard." + PROBE + "_dml(id integer PRIMARY KEY)"); + } + var credential = vault.read("database/creds/local-" + service + "-runtime"); + try (Connection connection = login(service, credential); + var statement = connection.createStatement()) { + assertTrue(connection.isValid(1)); + try (var rows = statement.executeQuery("SHOW statement_timeout")) { + assertTrue(rows.next()); + assertEquals("10s", rows.getString(1)); + } + assertEquals( + 1, statement.executeUpdate("INSERT INTO payguard." + PROBE + "_dml VALUES (1)")); + assertEquals( + 1, statement.executeUpdate("UPDATE payguard." + PROBE + "_dml SET id=2 WHERE id=1")); + try (var rows = statement.executeQuery("SELECT id FROM payguard." + PROBE + "_dml")) { + assertTrue(rows.next()); + assertEquals(2, rows.getInt(1)); + } + assertEquals( + 1, statement.executeUpdate("DELETE FROM payguard." + PROBE + "_dml WHERE id=2")); + assertThrows( + SQLException.class, + () -> statement.execute("CREATE TABLE payguard.unauthorized_probe(id integer)")); + assertThrows( + SQLException.class, + () -> statement.execute("CREATE TABLE public.unauthorized_probe(id integer)")); + assertThrows( + SQLException.class, + () -> statement.executeQuery("SELECT rolpassword FROM pg_authid")); + } finally { + try (Connection owner = migration(service); + var statement = owner.createStatement()) { + statement.execute("DROP TABLE payguard." + PROBE + "_dml"); + } + } + String other = service.equals("identity") ? "wallet" : "identity"; + assertThrows(SQLException.class, () -> login(other, credential)); + assertThrows( + VaultUnavailableException.class, + () -> vault.read("database/creds/local-" + service + "-migration")); + assertThrows( + VaultUnavailableException.class, + () -> vault.read("database/creds/local-" + other + "-runtime")); + assertThrows( + VaultUnavailableException.class, + () -> vault.readOptional("secret/data/local/" + other + "/otp-keys")); + } + } + } + + @Test + @Order(2) + void issuedCredentialCanRenewAndRevokeWithoutClaimingExistingSessionsAreDisconnected() + throws Exception { + try (VaultClient vault = client(LOCAL.resolve("apps/identity"))) { + var credential = vault.read("database/creds/" + ROLE); + String lease = credential.path("lease_id").asText(); + try (Connection existing = login("identity", credential)) { + assertTrue( + vault + .write("sys/leases/renew", Map.of("lease_id", lease)) + .path("lease_duration") + .asLong() + > 0); + vault.write("sys/leases/revoke", Map.of("lease_id", lease)); + assertThrows(SQLException.class, () -> login("identity", credential)); + /* PostgreSQL role removal prevents new login; an established session can still complete work. */ + assertTrue(existing.isValid(1)); + } + } + } + + @Test + @Order(3) + void leasePolicyCannotRevokeAnotherServiceCredential() { + try (VaultClient wallet = client(LOCAL.resolve("apps/wallet")); + VaultClient identity = client(LOCAL.resolve("apps/identity"))) { + var credential = wallet.read("database/creds/local-wallet-runtime"); + assertThrows( + VaultUnavailableException.class, + () -> + identity.write( + "sys/leases/revoke", Map.of("lease_id", credential.path("lease_id").asText()))); + } + } + + @Test + @Order(4) + void actualPoolContinuesBeyondInitialMaxTtlAndDrainsAnInFlightTransaction() throws Exception { + try (VaultClient vault = client(LOCAL.resolve("apps/identity")); + LeasedDataSource pool = + new LeasedDataSource(vault, ROLE, url("identity"), Duration.ofSeconds(10))) { + Instant began = Instant.now(); + await() + .atMost(Duration.ofSeconds(12)) + .until(() -> Instant.now().isAfter(began.plusSeconds(10))); + try (Connection held = pool.getConnection(); + var statement = held.createStatement()) { + held.setAutoCommit(false); + statement.executeUpdate( + "INSERT INTO " + PROBE + "(id,marker) VALUES (1,'synthetic-lease-probe')"); + await().atMost(Duration.ofSeconds(10)).until(() -> pool.replacementCount() >= 2); + held.commit(); + } + await() + .atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofMillis(200)) + .until( + () -> { + try (Connection connection = pool.getConnection(); + var statement = connection.createStatement()) { + var rows = + statement.executeQuery("SELECT count(*) FROM " + PROBE + " WHERE id=1"); + return rows.next() + && rows.getInt(1) == 1 + && pool.replacementCount() >= 4 + && Instant.now().isAfter(began.plusSeconds(45)); + } + }); + } + } + + @Test + @Order(5) + void tokenRevocationReauthenticatesAndAProcessRestartUsesItsProvisionedAppRole() { + try (VaultClient vault = client(LOCAL.resolve("apps/identity"))) { + vault.read("secret/data/local/identity/otp-keys"); + vault.write("auth/token/revoke-self", Map.of()); + assertThrows( + VaultUnavailableException.class, () -> vault.read("secret/data/local/identity/otp-keys")); + assertTrue(vault.read("secret/data/local/identity/otp-keys").has("data")); + } + try (VaultClient restarted = client(LOCAL.resolve("apps/identity"))) { + assertTrue(restarted.read("secret/data/local/identity/otp-keys").has("data")); + } + } + + @Test + @Order(6) + void managerCredentialRotationInvalidatesBootstrapPasswordAndPreservesIssuance() + throws Exception { + var data = + JsonMapper.builder() + .build() + .readTree(Files.readString(LOCAL.resolve("identity/credentials.json"))); + assertThrows( + SQLException.class, + () -> + DriverManager.getConnection( + url("identity"), "vault_manager", data.path("manager").asText())); + operator.write("database/rotate-root/local-identity", Map.of()); + try (VaultClient vault = client(LOCAL.resolve("apps/identity")); + Connection connection = login("identity", vault.read("database/creds/" + ROLE))) { + assertTrue(connection.isValid(1)); + } + } + + @Test + @Order(7) + void vaultOutageUsesOnlyValidCachedCredentialsThenFailsClosedAndRecovers() throws Exception { + try (VaultClient vault = client(LOCAL.resolve("apps/identity")); + LeasedDataSource pool = + new LeasedDataSource(vault, ROLE, url("identity"), Duration.ofSeconds(10))) { + operator.write("sys/seal", Map.of()); + try { + assertThrows( + VaultUnavailableException.class, + () -> + vault.readOptional( + "secret/data/local/identity/authenticators/cus_" + "A".repeat(22))); + try (Connection connection = pool.getConnection()) { + assertTrue(connection.isValid(1)); + } + await() + .atMost(Duration.ofSeconds(35)) + .pollInterval(Duration.ofMillis(200)) + .until( + () -> { + try (Connection ignored = pool.getConnection()) { + return false; + } catch (SQLException expected) { + return true; + } + }); + } finally { + var initialization = + JsonMapper.builder() + .build() + .readTree(Files.readString(LOCAL.resolve("operator/initialization.json"))); + for (int key = 0; key < 2; key++) + operator.write( + "sys/unseal", Map.of("key", initialization.path("keys").get(key).asText())); + } + await() + .atMost(Duration.ofSeconds(20)) + .until( + () -> { + try (Connection connection = pool.getConnection()) { + return connection.isValid(1); + } catch (SQLException expected) { + return false; + } + }); + } + } + + @Test + @Order(8) + void failuresAndDiagnosticsExcludeSecretValues() { + assertThrows( + VaultUnavailableException.class, + () -> operator.readOptional("missing-vault-mount/data/no-secret")); + try (VaultClient vault = client(LOCAL.resolve("apps/wallet"))) { + var failure = + assertThrows( + VaultUnavailableException.class, + () -> vault.read("secret/data/local/identity/otp-keys")); + assertEquals( + "Required secret service is unavailable or access is denied", failure.getMessage()); + assertFalse(failure.getMessage().contains("hvs.")); + } + assertThrows( + VaultUnavailableException.class, + () -> + new VaultClient( + URI.create("https://vault:8200"), + LOCAL.resolve("ca.crt"), + LOCAL.resolve("missing-role"), + LOCAL.resolve("missing-secret")) + .read("secret/data/local/identity/otp-keys")); + } + + private static VaultClient client(Path folder) { + return new VaultClient( + URI.create("https://vault:8200"), + LOCAL.resolve("ca.crt"), + folder.resolve("role-id"), + folder.resolve("secret-id")); + } + + @Test + @Order(9) + void shortLivedAppRoleTokenRenewsWithoutReplacingItsIdentity() { + operator.write( + "auth/approle/role/local-identity", Map.of("token_ttl", "8s", "token_max_ttl", "30s")); + try (VaultClient vault = client(LOCAL.resolve("apps/identity"))) { + String accessor = vault.read("auth/token/lookup-self").path("data").path("accessor").asText(); + Instant started = Instant.now(); + await() + .atMost(Duration.ofSeconds(7)) + .until(() -> Instant.now().isAfter(started.plusSeconds(5))); + var renewed = vault.read("auth/token/lookup-self").path("data"); + assertTrue(accessor.equals(renewed.path("accessor").asText())); + assertTrue(renewed.path("ttl").asLong() >= 5); + } finally { + operator.write( + "auth/approle/role/local-identity", Map.of("token_ttl", "20m", "token_max_ttl", "4h")); + } + } + + @Test + @Order(10) + void springAutoConfigurationSuppliesTheActualVaultPoolAndReadinessContributor() { + Path scope = LOCAL.resolve("apps/identity"); + new org.springframework.boot.test.context.runner.ApplicationContextRunner() + .withConfiguration( + org.springframework.boot.autoconfigure.AutoConfigurations.of( + VaultDatabaseAutoConfiguration.class)) + .withPropertyValues( + "payguard.vault.enabled=true", + "payguard.vault.address=https://vault:8200", + "payguard.vault.ca-file=" + LOCAL.resolve("ca.crt"), + "payguard.vault.role-id-file=" + scope.resolve("role-id"), + "payguard.vault.secret-id-file=" + scope.resolve("secret-id"), + "payguard.vault.database-role=" + ROLE, + "spring.datasource.url=" + url("identity")) + .run( + context -> { + assertTrue(context.getStartupFailure() == null); + var source = context.getBean(javax.sql.DataSource.class); + assertTrue(source instanceof LeasedDataSource); + try (Connection connection = source.getConnection()) { + assertTrue(connection.isValid(1)); + } + var health = + context.getBean( + "vaultHealthIndicator", + org.springframework.boot.health.contributor.HealthIndicator.class); + assertEquals("UP", health.health().getStatus().getCode()); + }); + } + + private static String url(String service) { + return "jdbc:postgresql://postgres-" + + service + + ":5432/payguard_" + + service + + "?sslmode=verify-full&sslrootcert=/workspace/.local/payguard/ca.crt¤tSchema=payguard"; + } + + private static Connection login(String service, JsonNode credential) throws SQLException { + return DriverManager.getConnection( + url(service), + credential.path("data").path("username").asText(), + credential.path("data").path("password").asText()); + } + + private static Connection migration(String service) throws SQLException { + var credential = operator.read("database/creds/local-" + service + "-migration"); + Connection connection = login(service, credential); + try (var statement = connection.createStatement()) { + statement.execute("SET ROLE " + service + "_owner"); + } + return connection; + } + + @Test + @Order(11) + void databaseOutageDeniesConnectionsAndRecoveryDoesNotReplayATransaction() throws Exception { + Path control = Path.of("target/vault-test-reports/control"); + try (VaultClient vault = client(LOCAL.resolve("apps/identity")); + LeasedDataSource pool = + new LeasedDataSource(vault, ROLE, url("identity"), Duration.ofSeconds(10))) { + Files.writeString(control.resolve("stop.request"), "requested"); + try { + await() + .atMost(Duration.ofSeconds(15)) + .until(() -> Files.exists(control.resolve("stop.ack"))); + assertThrows(SQLException.class, pool::getConnection); + } finally { + Files.writeString(control.resolve("start.request"), "requested"); + await() + .atMost(Duration.ofSeconds(15)) + .until(() -> Files.exists(control.resolve("start.ack"))); + } + await() + .atMost(Duration.ofSeconds(20)) + .until( + () -> { + try (Connection connection = pool.getConnection()) { + return connection.isValid(1); + } catch (SQLException expected) { + return false; + } + }); + } + } + + @Test + @Order(12) + void forcedRevocationFailsBorrowingUntilAReplacementRestoresRuntimePrivileges() throws Exception { + try (VaultClient vault = client(LOCAL.resolve("apps/identity")); + LeasedDataSource pool = + new LeasedDataSource(vault, ROLE, url("identity"), Duration.ofSeconds(10))) { + try (Connection connection = pool.getConnection()) { + assertTrue(connection.isValid(1)); + } + operator.write("sys/leases/revoke-prefix/database/creds/" + ROLE, Map.of()); + assertThrows(SQLException.class, pool::getConnection); + await().atMost(Duration.ofSeconds(15)).until(() -> pool.replacementCount() >= 2); + try (Connection connection = pool.getConnection()) { + assertTrue(connection.isValid(1)); + } + } + } +} diff --git a/payguard-wallet-service/Dockerfile b/payguard-wallet-service/Dockerfile index daa3720..25a064c 100644 --- a/payguard-wallet-service/Dockerfile +++ b/payguard-wallet-service/Dockerfile @@ -1,10 +1,10 @@ # syntax=docker/dockerfile:1 -FROM eclipse-temurin:25-jdk-alpine AS build +FROM eclipse-temurin:25-jdk-alpine@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a AS build WORKDIR /workspace COPY . . RUN chmod +x mvnw && ./mvnw --batch-mode --no-transfer-progress -pl :payguard-wallet-service -am -DskipTests package -FROM eclipse-temurin:25-jre-alpine +FROM eclipse-temurin:25-jre-alpine@sha256:3c0a9084927a221ccd1d007fcaf614465672c0af37aaa834c5184483afe56d61 WORKDIR /app RUN addgroup -S payguard && adduser -S payguard -G payguard COPY --from=build /workspace/payguard-wallet-service/target/payguard-wallet-service-*.jar /app/app.jar diff --git a/payguard-wallet-service/pom.xml b/payguard-wallet-service/pom.xml index 01fe374..72d1bf0 100644 --- a/payguard-wallet-service/pom.xml +++ b/payguard-wallet-service/pom.xml @@ -25,6 +25,10 @@ + + org.springframework.boot + spring-boot-starter-actuator + org.aspectj aspectjweaver @@ -41,8 +45,8 @@ test - com.oracle.database.jdbc - ojdbc17 + org.postgresql + postgresql runtime diff --git a/payguard-wallet-service/src/main/resources/application.yaml b/payguard-wallet-service/src/main/resources/application.yaml index d71ae8d..f0db46a 100644 --- a/payguard-wallet-service/src/main/resources/application.yaml +++ b/payguard-wallet-service/src/main/resources/application.yaml @@ -3,8 +3,28 @@ spring: name: wallet-service datasource: url: ${SPRING_DATASOURCE_URL} - username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: oracle.jdbc.OracleDriver + driver-class-name: org.postgresql.Driver jooq: - sql-dialect: default + sql-dialect: POSTGRES + +payguard: + vault: + enabled: true + address: ${VAULT_ADDR} + ca-file: ${VAULT_CA_FILE} + role-id-file: ${VAULT_ROLE_ID_FILE} + secret-id-file: ${VAULT_SECRET_ID_FILE} + database-role: ${PAYGUARD_ENVIRONMENT:local}-wallet-runtime + +management: + endpoint: + health: + probes: + enabled: true + group: + readiness: + include: readinessState,db,vault + endpoints: + web: + exposure: + include: health,info diff --git a/pom.xml b/pom.xml index 032bde3..4af7e03 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,6 @@ 1.68.0 3.25.5 3.19.15 - 23.6.0.24.10 0.6.1 1.7.1 @@ -60,11 +59,6 @@ protobuf-java-util ${protobuf.version} - - com.oracle.database.jdbc - ojdbc11 - ${oracle.driver.version} - org.jooq jooq @@ -81,6 +75,26 @@ + + org.flywaydb + flyway-maven-plugin + 12.4.0 + + ${env.PAYGUARD_MIGRATION_JDBC_URL} + ${env.PAYGUARD_MIGRATION_USERNAME} + ${env.PAYGUARD_MIGRATION_PASSWORD} + SET ROLE ${env.PAYGUARD_MIGRATION_OWNER} + payguard + payguard + false + true + filesystem:${project.basedir}/src/main/resources/db/migration/postgresql + + + org.flywaydbflyway-database-postgresql12.4.0 + org.postgresqlpostgresql42.7.13 + + org.springframework.boot spring-boot-maven-plugin diff --git a/scripts/local/common.py b/scripts/local/common.py new file mode 100644 index 0000000..48aaef2 --- /dev/null +++ b/scripts/local/common.py @@ -0,0 +1,99 @@ +"""Protected local-rehearsal I/O; never include dependency responses in diagnostics.""" +import json +import os +from pathlib import Path +import ssl +import subprocess +import tempfile +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +LOCAL = ROOT / ".local/payguard" +SERVICES = ("wallet", "loan", "collateral", "identity") +JAVA_IMAGE = "eclipse-temurin:25-jdk-alpine@sha256:3fd2d245c4e0eba615fe366a71b8bd25f5db7104f53e4026b24bf508b880bd2a" + + +class DependencyError(RuntimeError): + def __init__(self, status=0): + self.status = status + super().__init__("Local dependency unavailable or access denied; details suppressed") + + +def write(path, value): + """Atomic replacement avoids partially read SecretIDs during reauthentication.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary = tempfile.mkstemp(dir=path.parent) + try: + with os.fdopen(descriptor, "w") as output: + output.write(value) + os.chmod(temporary, 0o600) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def read(path): + return Path(path).read_text().strip() + + +def run(arguments, **kwargs): + result = subprocess.run(arguments, cwd=ROOT, capture_output=True, **kwargs) + if result.returncode: + raise DependencyError() + return result.stdout + + +class Vault: + def __init__(self, token=""): + self.token = token + port = int(os.environ.get("VAULT_PORT", "8200")) + self.address = f"https://127.0.0.1:{port}/v1/" + self.http = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=ssl.create_default_context(cafile=LOCAL / "ca.crt")), + ) + + def request(self, method, path, body=None, wrap=False): + headers = {"Content-Type": "application/json"} + if self.token: + headers["X-Vault-Token"] = self.token + if wrap: + headers["X-Vault-Wrap-TTL"] = "60s" + payload = None if body is None else json.dumps(body).encode() + request = urllib.request.Request(self.address + path, payload, headers, method=method) + try: + with self.http.open(request, timeout=10) as response: + data = response.read() + return json.loads(data) if data else {} + except urllib.error.HTTPError as error: + raise DependencyError(error.code) from None + except (OSError, ValueError): + raise DependencyError() from None + + def get(self, path): + return self.request("GET", path) + + def post(self, path, body=None): + return self.request("POST", path, {} if body is None else body) + + def provision(self, role, folder): + role_id = self.get(f"auth/approle/role/{role}/role-id")["data"]["role_id"] + wrapped = self.request("POST", f"auth/approle/role/{role}/secret-id", {}, wrap=True) + one_use = Vault(wrapped["wrap_info"]["token"]) + secret_id = one_use.post("sys/wrapping/unwrap")["data"]["secret_id"] + write(folder / "role-id", role_id) + write(folder / "secret-id", secret_id) + + +def operator(): + return Vault(read(LOCAL / "operator/token")) + + +def main(action): + try: + action() + except (DependencyError, OSError, ValueError, KeyError): + raise SystemExit("Local rehearsal failed; protected dependency details suppressed") from None diff --git a/scripts/local/migrate.py b/scripts/local/migrate.py new file mode 100644 index 0000000..706ac44 --- /dev/null +++ b/scripts/local/migrate.py @@ -0,0 +1,46 @@ +"""Run supplied Flyway schemas with separate migration leases, never runtime privileges.""" +import argparse +import os +from pathlib import Path +from common import JAVA_IMAGE, LOCAL, ROOT, Vault, main, read, run + + +def migrate(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("service", choices=("identity", "loan", "collateral")) + parser.add_argument("--import-identity", type=Path) + arguments = parser.parse_args() + service = arguments.service + imported = arguments.import_identity + if imported is not None: + imported = imported.resolve() + if service != "identity" or not imported.is_file() or not imported.is_relative_to(LOCAL): + raise ValueError("Explicit protected Identity export required") + scope = LOCAL / "operator" / service + vault = Vault() + auth = vault.post("auth/approle/login", { + "role_id": read(scope / "role-id"), "secret_id": read(scope / "secret-id")}) + vault.token = auth["auth"]["client_token"] + try: + credentials = vault.get("database/creds/local-" + service + "-migration")["data"] + environment = os.environ.copy() + environment.update({ + "PAYGUARD_MIGRATION_SERVICE": service, + "PAYGUARD_MIGRATION_JDBC_URL": f"jdbc:postgresql://postgres-{service}:5432/payguard_{service}?sslmode=verify-full&sslrootcert=/workspace/.local/payguard/ca.crt¤tSchema=payguard", + "PAYGUARD_MIGRATION_USERNAME": credentials["username"], + "PAYGUARD_MIGRATION_PASSWORD": credentials["password"]}) + command = ["docker", "run", "--rm", "--network", "payguard_database", + "--user", f"{os.getuid()}:{os.getgid()}", "-v", f"{ROOT}:/workspace:ro", "-w", "/workspace"] + for name in ("PAYGUARD_MIGRATION_SERVICE", "PAYGUARD_MIGRATION_JDBC_URL", "PAYGUARD_MIGRATION_USERNAME", "PAYGUARD_MIGRATION_PASSWORD"): + command += ["-e", name] + command += [JAVA_IMAGE, "java", "-cp", "payguard-shared/target/classes:payguard-shared/target/dependency/*"] + entry = ["infrastructure.vault.SchemaMigrator"] if imported is None else [ + "infrastructure.vault.IdentityDataImporter", "/workspace/" + str(imported.relative_to(ROOT))] + run(command + entry, env=environment) + print(f"{service}: separately privileged migration/import completed") + finally: + vault.post("auth/token/revoke-self") + + +if __name__ == "__main__": + main(migrate) diff --git a/scripts/local/prepare.py b/scripts/local/prepare.py new file mode 100644 index 0000000..4563255 --- /dev/null +++ b/scripts/local/prepare.py @@ -0,0 +1,68 @@ +"""Create new protected artifacts. Existing data and artifacts are never overwritten.""" +import json +import os +import secrets +from common import LOCAL, SERVICES, main, run, write + + +def certificate(folder, name, alternatives): + folder.mkdir(mode=0o700, parents=True) + run(["openssl", "req", "-new", "-newkey", "rsa:3072", "-nodes", + "-keyout", str(folder / "server.key"), "-out", str(folder / "server.csr"), + "-subj", f"/CN={name}"]) + write(folder / "extensions.cnf", "basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=" + alternatives + "\n") + run(["openssl", "x509", "-req", "-in", str(folder / "server.csr"), + "-CA", str(LOCAL / "ca.crt"), "-CAkey", str(LOCAL / "ca.key"), + "-CAcreateserial", "-days", "30", "-sha256", "-extfile", str(folder / "extensions.cnf"), + "-out", str(folder / "server.crt")]) + for path in folder.iterdir(): + path.chmod(0o600) + + +def sql(service, credentials): + owner, runtime = f"{service}_owner", f"{service}_runtime" + return f"""REVOKE ALL ON DATABASE payguard_{service} FROM PUBLIC; +REVOKE ALL ON SCHEMA public FROM PUBLIC; +CREATE ROLE {owner} NOLOGIN; +CREATE ROLE {runtime} NOLOGIN; +CREATE ROLE vault_manager LOGIN CREATEROLE PASSWORD '{credentials['manager']}'; +CREATE ROLE bootstrap_migrator LOGIN PASSWORD '{credentials['migration']}'; +GRANT {owner} TO bootstrap_migrator; +GRANT {owner}, {runtime} TO vault_manager WITH ADMIN TRUE, INHERIT FALSE, SET FALSE; +GRANT CONNECT ON DATABASE payguard_{service} TO {owner}, {runtime}, vault_manager; +CREATE SCHEMA payguard AUTHORIZATION {owner}; +GRANT USAGE ON SCHEMA payguard TO {runtime}, vault_manager; +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA payguard + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {runtime}; +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} IN SCHEMA payguard + GRANT USAGE, SELECT ON SEQUENCES TO {runtime}; +ALTER DEFAULT PRIVILEGES FOR ROLE {owner} REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; +""" + + +def prepare(): + if LOCAL.exists(): + raise ValueError("Refuse existing protected artifacts") + os.umask(0o077) + LOCAL.mkdir(parents=True, mode=0o700) + run(["openssl", "req", "-x509", "-newkey", "rsa:3072", "-nodes", "-sha256", "-days", "30", + "-subj", "/CN=PayGuard disposable rehearsal CA", "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", "-keyout", str(LOCAL / "ca.key"), + "-out", str(LOCAL / "ca.crt")]) + (LOCAL / "ca.key").chmod(0o600) + (LOCAL / "ca.crt").chmod(0o600) + for service in SERVICES: + folder = LOCAL / service + certificate(folder, "postgres-" + service, "DNS:postgres-" + service) + credentials = {kind: secrets.token_hex(32) for kind in ("admin", "manager", "migration")} + write(folder / "credentials.json", json.dumps(credentials)) + write(folder / "admin-password", credentials["admin"]) + write(folder / "init.sql", sql(service, credentials)) + (LOCAL / "apps" / service).mkdir(parents=True, mode=0o700) + certificate(LOCAL / "vault", "vault", "DNS:vault,DNS:localhost,IP:127.0.0.1") + write(LOCAL / "vault/ca.crt", (LOCAL / "ca.crt").read_text()) + print("Created protected local TLS and bootstrap artifacts; no secret values displayed") + + +if __name__ == "__main__": + main(prepare) diff --git a/scripts/local/refresh_approle.py b/scripts/local/refresh_approle.py new file mode 100644 index 0000000..d403829 --- /dev/null +++ b/scripts/local/refresh_approle.py @@ -0,0 +1,29 @@ +"""Operator-only SecretID replacement; supervise --watch for extended local rehearsal.""" +import argparse +import time +from common import LOCAL, SERVICES, main, operator + + +def refresh(): + vault = operator() + vault.post("auth/token/renew-self") + for service in SERVICES: + vault.provision("local-" + service, LOCAL / "apps" / service) + vault.provision("local-" + service + "-migration", LOCAL / "operator" / service) + vault.provision("local-operator", LOCAL / "operator/approle") + print("Renewed operator token and atomically replaced scoped SecretID files") + + +def action(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--watch", action="store_true") + watch = parser.parse_args().watch + while True: + refresh() + if not watch: + return + time.sleep(20 * 60) + + +if __name__ == "__main__": + main(action) diff --git a/scripts/local/vault_bootstrap.py b/scripts/local/vault_bootstrap.py new file mode 100644 index 0000000..f6b2361 --- /dev/null +++ b/scripts/local/vault_bootstrap.py @@ -0,0 +1,194 @@ +"""Explicit local operator bootstrap/recovery, verified HTTPS and protected artifacts.""" +import argparse +import base64 +import json +import secrets +import time +from common import DependencyError, LOCAL, SERVICES, Vault, main, read, write + + +def rule(path, capabilities): + return f'path "{path}" {{ capabilities = {json.dumps(capabilities)} }}\n' + + +def own_token_policy(): + return (rule("auth/token/lookup-self", ["read"]) + + rule("auth/token/renew-self", ["update"]) + + rule("auth/token/revoke-self", ["update"])) + + +def runtime_policy(service): + role = "local-" + service + "-runtime" + policy = own_token_policy() + rule("database/creds/" + role, ["read"]) + for operation in ("renew", "revoke"): + policy += f'''path "sys/leases/{operation}" {{ + capabilities = ["update"] + allowed_parameters = {{ "lease_id" = ["database/creds/{role}/*"], "increment" = ["*"] }} +}}\n''' + if service == "identity": + policy += rule("secret/data/local/identity/otp-keys", ["read"]) + policy += rule("secret/data/local/identity/authenticators/*", ["read"]) + return policy + + +def operator_policy(): + policy = own_token_policy() + for path in ("sys/mounts", "sys/auth", "sys/audit"): + policy += rule(path, ["read", "sudo"]) + for path in ("sys/mounts/secret", "sys/mounts/database", "sys/auth/approle", + "sys/audit/file", "sys/seal", "sys/policies/acl/local-*", + "auth/token/create-orphan", "sys/leases/revoke-prefix/database/creds/local-*"): + policy += rule(path, ["create", "read", "update", "sudo"]) + for path in ("database/config/local-*", "database/roles/local-*", "database/rotate-root/local-*", + "auth/approle/role/local-*", "secret/data/local/*", + "secret/destroy/local/*"): + policy += rule(path, ["create", "read", "update", "delete", "list"]) + for service in SERVICES: + policy += rule("database/creds/local-" + service + "-migration", ["read"]) + for operation in ("renew", "revoke"): + policy += rule("sys/leases/" + operation, ["update"]) + return policy + + +def recover(vault, initialization): + progress = vault.get("sys/generate-root/attempt") + if progress.get("started"): + raise ValueError("An operator recovery is already in progress") + started = vault.post("sys/generate-root/attempt") + try: + result = {} + for key in initialization["keys"][:2]: + result = vault.post("sys/generate-root/update", {"key": key, "nonce": started["nonce"]}) + if not result.get("complete"): + raise ValueError("Recovery threshold not met") + encoded = result.get("encoded_token") or result.get("encoded_root_token") + raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4)) + otp = started["otp"].encode() + return Vault(bytes(left ^ right for left, right in zip(raw, otp)).decode()) + except Exception: + vault.request("DELETE", "sys/generate-root/attempt") + raise + + +def configure(vault): + vault.post("sys/policies/acl/local-operator", {"policy": operator_policy()}) + for path, body in (("sys/mounts/secret", {"type": "kv", "options": {"version": "2"}}), + ("sys/mounts/database", {"type": "database"}), + ("sys/auth/approle", {"type": "approle"}), + ("sys/audit/file", {"type": "file", "options": {"file_path": "/vault/audit/audit.json"}})): + listing = "sys/auth" if path.startswith("sys/auth") else "sys/audit" if path.startswith("sys/audit") else "sys/mounts" + name = path.rsplit("/", 1)[1] + "/" + existing = vault.get(listing) + if name not in existing.get("data", existing): + vault.post(path, body) + vault.post("sys/policies/acl/local-operator", {"policy": operator_policy()}) + for service in SERVICES: + name = "local-" + service + credentials = json.loads(read(LOCAL / service / "credentials.json")) + try: + vault.get("database/config/" + name) + except DependencyError as failure: + if failure.status != 404: + raise + vault.post("database/config/" + name, { + "plugin_name": "postgresql-database-plugin", "allowed_roles": [name + "-runtime", name + "-migration"], + "connection_url": "postgresql://{{username}}:{{password}}@postgres-" + service + ":5432/payguard_" + service + "?sslmode=verify-full&sslrootcert=/vault/tls/ca.crt", + "username": "vault_manager", "password": credentials["manager"], "verify_connection": True}) + vault.post("database/rotate-root/" + name) + for kind, ttl, maximum in (("runtime", "1h", "4h"), ("migration", "15m", "30m")): + group = service + ("_runtime" if kind == "runtime" else "_owner") + role = name + "-" + kind + timeouts = (''' ALTER ROLE "{{name}}" SET statement_timeout='10s'; + ALTER ROLE "{{name}}" SET lock_timeout='10s'; + ALTER ROLE "{{name}}" SET idle_in_transaction_session_timeout='10s';''' if kind == "runtime" else "") + vault.post("database/roles/" + role, { + "db_name": name, + "creation_statements": [f'''CREATE ROLE "{{{{name}}}}" WITH LOGIN PASSWORD '{{{{password}}}}' VALID UNTIL '{{{{expiration}}}}'; GRANT {group} TO "{{{{name}}}}";''' + timeouts], + "revocation_statements": [f'REVOKE {group} FROM "{{{{name}}}}"; DROP ROLE IF EXISTS "{{{{name}}}}";'], + "renew_statements": ['ALTER ROLE "{{name}}" VALID UNTIL \'{{expiration}}\';'], + "default_ttl": ttl, "max_ttl": maximum}) + policy = runtime_policy(service) if kind == "runtime" else own_token_policy() + rule("database/creds/" + role, ["read"]) + policy_name = name if kind == "runtime" else name + "-migration" + vault.post("sys/policies/acl/" + policy_name, {"policy": policy}) + vault.post("auth/approle/role/" + policy_name, { + "token_policies": [policy_name], "secret_id_ttl": "1h", "secret_id_num_uses": 0, + "token_ttl": "20m", "token_max_ttl": "4h", "token_no_default_policy": True}) + destination = LOCAL / "apps" / service if kind == "runtime" else LOCAL / "operator" / service + vault.provision(policy_name, destination) + vault.post("auth/approle/role/local-operator", { + "token_policies": ["local-operator"], "secret_id_ttl": "1h", "secret_id_num_uses": 0, + "token_ttl": "20m", "token_max_ttl": "4h", "token_no_default_policy": True}) + vault.provision("local-operator", LOCAL / "operator/approle") + try: + vault.get("secret/data/local/identity/otp-keys") + except DependencyError as failure: + if failure.status != 404: + raise + vault.post("secret/data/local/identity/otp-keys", { + "data": {"current": "localInitial", "keys": {"localInitial": secrets.token_urlsafe(48)}}}) + + +def bootstrap(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--recover-operator", action="store_true") + parser.add_argument("--diagnostics", action="store_true", help="Display only failed API path/status; never response bodies or credentials") + arguments = parser.parse_args() + if arguments.diagnostics: + request = Vault.request + + def diagnosed(self, method, path, *args, **kwargs): + try: + return request(self, method, path, *args, **kwargs) + except DependencyError as failure: + print(f"Failed bootstrap API: {method} {path}, HTTP {failure.status}", flush=True) + raise + + Vault.request = diagnosed + vault = Vault() + deadline = time.monotonic() + 45 + while True: + try: + status = vault.get("sys/init") + break + except DependencyError: + if time.monotonic() >= deadline: + raise + time.sleep(0.2) + temporary_root = LOCAL / "operator/bootstrap-root" + if not status["initialized"]: + initialized = vault.post("sys/init", {"secret_shares": 3, "secret_threshold": 2}) + write(temporary_root, initialized.pop("root_token")) + write(LOCAL / "operator/initialization.json", json.dumps(initialized)) + initialization = json.loads(read(LOCAL / "operator/initialization.json")) + if vault.get("sys/seal-status")["sealed"]: + for key in initialization["keys"][:2]: + vault.post("sys/unseal", {"key": key}) + while not vault.get("sys/leader").get("is_self"): + if time.monotonic() >= deadline: + raise DependencyError() + time.sleep(0.2) + root = False + if temporary_root.exists(): + vault = Vault(read(temporary_root)) + root = True + elif arguments.recover_operator: + vault = recover(vault, initialization) + root = True + else: + vault = Vault(read(LOCAL / "operator/token")) + vault.post("auth/token/renew-self") + try: + if root: + vault.post("sys/policies/acl/local-operator", {"policy": operator_policy()}) + issued = vault.post("auth/token/create-orphan", {"policies": ["local-operator"], "ttl": "8h", "renewable": True}) + write(LOCAL / "operator/token", issued["auth"]["client_token"]) + configure(vault) + finally: + if root: + vault.post("auth/token/revoke-self") + temporary_root.unlink(missing_ok=True) + print("Vault initialized/unsealed and scoped local identities provisioned; no root token retained") + + +if __name__ == "__main__": + main(bootstrap) diff --git a/scripts/local/verify_applications.py b/scripts/local/verify_applications.py new file mode 100644 index 0000000..542a9ff --- /dev/null +++ b/scripts/local/verify_applications.py @@ -0,0 +1,66 @@ +"""Boot actual packaged applications with only their own AppRole files and verified CA.""" +import argparse +import os +import subprocess +import time +import uuid +from common import DependencyError, LOCAL, ROOT, SERVICES, main, run + +JRE_IMAGE = "eclipse-temurin:25-jre-alpine@sha256:3c0a9084927a221ccd1d007fcaf614465672c0af37aaa834c5184483afe56d61" + + +def verify(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--service", choices=SERVICES) + selected = parser.parse_args().service + for service in SERVICES if selected is None else (selected,): + module = ROOT / f"payguard-{service}-service" + jars = list((module / "target").glob(f"payguard-{service}-service-*.jar")) + if len(jars) != 1: + raise ValueError("Build actual service JARs before runtime verification") + name = "payguard-application-probe-" + service + "-" + uuid.uuid4().hex[:8] + run(["docker", "run", "-d", "--name", name, "--network", "payguard_database", + "--user", f"{os.getuid()}:{os.getgid()}", "-v", f"{jars[0]}:/app.jar:ro", + "-v", f"{LOCAL / 'apps' / service}:/run/payguard/approle:ro", + "-v", f"{LOCAL / 'ca.crt'}:/run/payguard/ca.crt:ro", + "-e", f"SPRING_DATASOURCE_URL=jdbc:postgresql://postgres-{service}:5432/payguard_{service}?sslmode=verify-full&sslrootcert=/run/payguard/ca.crt¤tSchema=payguard", + "-e", "VAULT_ADDR=https://vault:8200", "-e", "VAULT_CA_FILE=/run/payguard/ca.crt", + "-e", "VAULT_ROLE_ID_FILE=/run/payguard/approle/role-id", "-e", "VAULT_SECRET_ID_FILE=/run/payguard/approle/secret-id", + "-e", "REDIS_HOST=redis", "-e", "KAFKA_BOOTSTRAP_SERVERS=kafka:29092", + JRE_IMAGE, "java", "-jar", "/app.jar"]) + try: + port = 8084 if service == "identity" else 8080 + deadline = time.monotonic() + 60 + ready = False + while time.monotonic() < deadline: + status = run(["docker", "inspect", "-f", "{{.State.Running}}", name]).strip() + if status != b"true": + break + try: + health = run(["docker", "exec", name, "wget", "-qO-", f"http://127.0.0.1:{port}/actuator/health/readiness"]) + if b'"status":"UP"' in health: + ready = True + break + except DependencyError: + pass + time.sleep(0.2) + captured = subprocess.run(["docker", "logs", name], capture_output=True, check=True) + logs = captured.stdout + captured.stderr + markers = [(LOCAL / "apps" / service / "secret-id").read_bytes().strip(), + (LOCAL / "operator/token").read_bytes().strip()] + if any(marker in logs for marker in markers): + raise ValueError("Secret-bearing application logs detected") + if not ready: + # Only exception class names are emitted, never arbitrary application messages. + import re + classes = sorted(set(re.findall(rb"(?:[a-z]+\.)+[A-Za-z]+Exception", logs))) + print(service + ": readiness failed; exception types " + ", ".join(value.decode() for value in classes), flush=True) + raise DependencyError() + print(service + ": actual packaged application readiness UP; scoped AppRole and PostgreSQL TLS active", flush=True) + finally: + run(["docker", "stop", "-t", "10", name]) + run(["docker", "rm", name]) + + +if __name__ == "__main__": + main(verify) diff --git a/scripts/local/verify_vault.py b/scripts/local/verify_vault.py new file mode 100644 index 0000000..618a0de --- /dev/null +++ b/scripts/local/verify_vault.py @@ -0,0 +1,80 @@ +"""Real isolated TLS dependency tests. Controls only the local rehearsal database/Vault.""" +import json +import os +import subprocess +import time +import uuid +import xml.etree.ElementTree as ET +from common import DependencyError, JAVA_IMAGE, LOCAL, ROOT, main, run + + +def verify(): + reports = ROOT / "payguard-shared/target/vault-test-reports" + control = reports / "control" + control.mkdir(parents=True, exist_ok=True) + for name in ("stop.request", "stop.ack", "start.request", "start.ack"): + (control / name).unlink(missing_ok=True) + compose = ["docker", "compose", "--env-file", ".env.example"] + name = "payguard-vault-verification-" + uuid.uuid4().hex[:12] + command = ["docker", "run", "--rm", "--name", name, "--network", "payguard_database", + "--user", f"{os.getuid()}:{os.getgid()}", "-v", f"{ROOT}:/workspace", + "-w", "/workspace/payguard-shared", "-e", "PAYGUARD_REHEARSAL=true", JAVA_IMAGE, + "java", "-cp", "target/classes:target/test-classes:target/dependency/*:/workspace/payguard-identity-service/target/classes:/workspace/payguard-identity-service/target/test-classes:/workspace/payguard-identity-service/target/dependency/*", + "org.junit.platform.console.ConsoleLauncher", "execute", "--disable-ansi-colors", + "--select-class", "infrastructure.vault.VaultDatabaseIT", + "--select-class", "dev.amg.payguard.identity.verification.infrastructure.security.VaultSecretsIT", + "--reports-dir", "target/vault-test-reports"] + began = time.monotonic() + stopped = False + with open(os.devnull, "wb") as null: + process = subprocess.Popen(command, cwd=ROOT, stdout=null, stderr=null) + try: + while process.poll() is None: + if time.monotonic() - began > 240: + run(["docker", "stop", "-t", "5", name]) + raise DependencyError() + if (control / "stop.request").exists() and not (control / "stop.ack").exists(): + run(compose + ["stop", "postgres-identity"]) + stopped = True + (control / "stop.ack").write_text("stopped") + if (control / "start.request").exists() and not (control / "start.ack").exists(): + run(compose + ["up", "-d", "--wait", "postgres-identity"]) + stopped = False + (control / "start.ack").write_text("healthy") + time.sleep(0.2) + finally: + if stopped: + run(compose + ["up", "-d", "--wait", "postgres-identity"]) + if process.poll() is None: + run(["docker", "stop", "-t", "5", name]) + process.wait(timeout=15) + counts = {key: 0 for key in ("tests", "failures", "errors", "skipped")} + for report in reports.glob("TEST-*.xml"): + suite = ET.parse(report).getroot() + for key in counts: + counts[key] += int(suite.get(key, "0")) + print("Vault/PostgreSQL tests:", json.dumps(counts), flush=True) + if process.returncode or counts != {"tests": 15, "failures": 0, "errors": 0, "skipped": 0}: + raise DependencyError() + audit = run(compose + ["exec", "-T", "vault", "cat", "/vault/audit/audit.json"]) + records = [json.loads(line) for line in audit.splitlines() if line.strip()] + if not any(record.get("error") and record.get("request", {}).get("path", "").startswith("secret/data/local/") for record in records): + raise ValueError("Denied audit evidence missing") + if not any(record.get("type") == "response" and not record.get("error") and record.get("request", {}).get("path") == "database/creds/local-identity-runtime" for record in records): + raise ValueError("Allowed audit evidence missing") + markers = [b"synthetic-first-pepper-marker-123456789", b"synthetic-second-pepper-marker-123456789", + b"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"] + for path in (LOCAL / "operator/token", LOCAL / "apps/identity/secret-id"): + markers.append(path.read_bytes().strip()) + if any(marker in audit for marker in markers): + raise ValueError("Audit sensitive-value protection failed") + if not any(str(record.get("auth", {}).get("client_token", "")).startswith("hmac-sha256:") for record in records): + raise ValueError("Audit token protection evidence missing") + for report in reports.glob("TEST-*.xml"): + if any(marker in report.read_bytes() for marker in markers): + raise ValueError("Secret-bearing diagnostics found") + print("Allowed/denied accesses traceable; audit and report secret-marker checks passed") + + +if __name__ == "__main__": + main(verify) diff --git a/scripts/migration/convert_identity_export.py b/scripts/migration/convert_identity_export.py new file mode 100644 index 0000000..913bbac --- /dev/null +++ b/scripts/migration/convert_identity_export.py @@ -0,0 +1,87 @@ +"""Convert authorized offline Identity JSON using a persistent approved ID mapping.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import uuid + + +def identifier(value, prefix, mapping): + if not isinstance(value, str): + raise ValueError("Identifier must be an exact string") + if re.fullmatch(prefix + r"_[A-Za-z0-9]{22}", value): + return value + converted = mapping.get(value) + if not isinstance(converted, str) or not re.fullmatch(prefix + r"_[A-Za-z0-9]{22}", converted): + raise ValueError("Approved historical identifier mapping required") + return converted + + +def timestamp(value): + if value is None: + return None + if not isinstance(value, str) or not re.search(r"(?:Z|[+-]\d{2}:\d{2})$", value): + raise ValueError("Timestamp offset required") + value = re.sub(r"(\.\d{6})\d+", r"\1", value) + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="microseconds") + + +def convert(source, mapping, mapping_bytes): + if not mapping.get("approved_by") or not mapping.get("approval_reference"): + raise ValueError("Approved provenance required") + customers, challenges = mapping.get("customers", {}), mapping.get("challenges", {}) + result = {"challenges": [], "audit": []} + identities = {} + for original in source["challenges"]: + row = dict(original) + row["id"] = identifier(row["id"], "otp", challenges) + row["user_id"] = identifier(row["user_id"], "cus", customers) + if row["id"] in identities: + raise ValueError("Duplicate converted challenge") + identities[row["id"]] = row["user_id"] + for field in ("created_at", "expires_at", "consumed_at", "retention_until"): + row[field] = timestamp(row.get(field)) + for field in ("attempts", "max_attempts"): + if type(row.get(field)) is not int: + raise ValueError("Integer attempts required") + result["challenges"].append(row) + audit_ids = set() + for original in source["audit"]: + row = dict(original) + row["id"] = str(uuid.UUID(row["id"])) + row["challenge_id"] = identifier(row["challenge_id"], "otp", challenges) + row["user_id"] = identifier(row["user_id"], "cus", customers) + if row["id"] in audit_ids or identities.get(row["challenge_id"]) != row["user_id"]: + raise ValueError("Duplicate or orphan/mismatched audit reference") + audit_ids.add(row["id"]) + row["occurred_at"] = timestamp(row["occurred_at"]) + result["audit"].append(row) + result["manifest"] = {"approved_by": mapping["approved_by"], "approval_reference": mapping["approval_reference"], + "mapping_sha256": hashlib.sha256(mapping_bytes).hexdigest(), + "challenge_count": len(result["challenges"]), "audit_count": len(result["audit"]), + "timestamp_policy": "UTC microseconds; truncate sub-microsecond precision"} + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("mapping", type=Path) + parser.add_argument("destination", type=Path) + arguments = parser.parse_args() + try: + mapping_bytes = arguments.mapping.read_bytes() + converted = convert(json.loads(arguments.source.read_text()), json.loads(mapping_bytes), mapping_bytes) + descriptor = os.open(arguments.destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "w") as output: + json.dump(converted, output, indent=2) + print("Protected export converted with approved mapping and reconciliation manifest") + except (OSError, ValueError, KeyError, TypeError): + raise SystemExit("Conversion refused; protected source details suppressed") from None + + +if __name__ == "__main__": + main() diff --git a/scripts/migration/test_convert_identity_export.py b/scripts/migration/test_convert_identity_export.py new file mode 100644 index 0000000..09bee40 --- /dev/null +++ b/scripts/migration/test_convert_identity_export.py @@ -0,0 +1,52 @@ +"""Synthetic conversion contract; no live source or invented customer mappings.""" +import unittest +from convert_identity_export import convert + + +class IdentityExportTest(unittest.TestCase): + def fixture(self): + self.customer = "cus_" + "A" * 22 + self.challenge = "otp_" + "b" * 22 + source = {"challenges": [{"id": "old-challenge", "user_id": "old-customer", "attempts": 0, "max_attempts": 5, + "created_at": "2026-01-01T03:30:00.123456789+03:30", "expires_at": "2026-01-01T00:35:00Z"}], + "audit": [{"id": "00112233445566778899aabbccddeeff", "challenge_id": "old-challenge", "user_id": "old-customer", "occurred_at": "2026-01-01T00:00:00Z"}]} + mapping = {"approved_by": "synthetic operator", "approval_reference": "fixture approval", + "customers": {"old-customer": self.customer}, "challenges": {"old-challenge": self.challenge}} + return source, mapping + + def test_approved_mapping_preserves_uuid_byte_order_and_truncates_microseconds(self): + source, mapping = self.fixture() + result = convert(source, mapping, b"synthetic approved mapping bytes") + self.assertEqual(self.challenge, result["challenges"][0]["id"]) + self.assertEqual("2026-01-01T00:00:00.123456+00:00", result["challenges"][0]["created_at"]) + self.assertEqual("00112233-4455-6677-8899-aabbccddeeff", result["audit"][0]["id"]) + self.assertEqual(1, result["manifest"]["audit_count"]) + + def test_current_case_sensitive_ids_pass_through_and_missing_mapping_is_refused(self): + source, mapping = self.fixture() + source["challenges"][0]["id"] = self.challenge + result = convert(source, mapping, b"mapping") + self.assertEqual(self.challenge, result["challenges"][0]["id"]) + mapping["customers"] = {} + with self.assertRaises(ValueError): + convert(source, mapping, b"mapping") + + def test_orphans_duplicates_unapproved_mapping_and_offsetless_time_are_refused(self): + for defect in ("orphan", "duplicate", "approval", "offset", "integer"): + source, mapping = self.fixture() + if defect == "orphan": + source["audit"][0]["challenge_id"] = "otp_" + "Q" * 22 + elif defect == "duplicate": + source["challenges"].append(dict(source["challenges"][0])) + elif defect == "approval": + mapping.pop("approval_reference") + elif defect == "offset": + source["challenges"][0]["created_at"] = "2026-01-01T00:00:00" + else: + source["challenges"][0]["attempts"] = "0" + with self.assertRaises(ValueError): + convert(source, mapping, b"mapping") + + +if __name__ == "__main__": + unittest.main()