Files
clean-architecture-backend-…/docs/jpa/security.md
T
DongHyeonkaandClaude Opus 5 0e61f86eb5 feat(jpa): implement the JPA relational persistence platform
Implements the Stable and Experimental JPA persistence platform designs
against real PostgreSQL, adapted to this repository's fail-closed 19-leaf
registry.

The design models the platform as 25 Gradle projects. `src/settings.gradle`
throws unless the registry holds exactly 19 leaves, so the plan's modules
become packages inside `:adapter:outbound:persistence-jpa` (starter in
`:app-bootstrap`, testkit in its own source set). The full mapping, the
renames this repository's naming gate required, and every deliberate
substitution are recorded in `docs/jpa/repository-adaptation.md`.

Seven Docker-backed lanes replace the plan's seven JVM test suites. Each
fails closed: a lane that discovers nothing, or a container that cannot
start, is an error rather than a skip.

Three defects the contracts found against a real server:

- `CommitFailureClassifier` treated only SQLSTATE 40003, class 08, and
  transport breaks as completion-unknown. A backend terminated mid-commit
  reports 57P01, and the commit record may already be in the WAL — so a
  possibly-committed transaction could be re-run. 57P01/57P02/57P03 now
  classify as completion-unknown.
- `SchemaTenantMigrationOrchestrator` recorded `MigrateResult`'s target
  version, which is empty for a tenant already current, reporting migrated
  tenants as unmigrated during a partial rollout. It now reads the applied
  version back from the tenant's schema history.
- `JpaStreamExecutor` checked only the declared return type for reactive
  publishers, and `RegisteredPostgreSqlCopyLoader` passed the COPY timeout
  to `SET`, which is parsed before parameter binding.

`JpaModuleBoundaryTest` enforces the plan's module map as package rules;
`verifyCleanArchitectureDependencies` governs edges between leaves and
cannot see these. Its first assertion is that the import is non-empty,
because every rule under it is a `noClasses()` rule and would pass
vacuously on an empty import.

Verified: 128 container tests across all seven lanes, 1183 unit tests,
`:adapter:outbound:persistence-jpa:check`, `:app-bootstrap:check`,
`verifyCleanArchitectureDependencies`, `verifyOneTypePerFile`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 14:06:18 +09:00

67 lines
2.9 KiB
Markdown

# Security
Design §36. Credential separation, privilege verification, and what never leaves the process.
## Three credentials
| Role | May |
|---|---|
| `app_migration` | own the schema, apply migrations (DDL) |
| `app_runtime` | select, insert, update, delete (DML only) |
| `app_admin` | J4 operations — COPY, backfill, maintenance |
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
the application's own credential cannot execute DDL, then no code path, no library, and no injected
statement can alter the schema at runtime, regardless of what the application intended.
`infra/jpa/roles/runtime-roles.sql` provisions them.
## Startup verification
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
```sql
select current_user,
current_setting('search_path'),
has_schema_privilege(current_user, current_schema(), 'CREATE'),
has_database_privilege(current_user, current_database(), 'CREATE')
```
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
reconstructs that combination reliably.
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
database.
## search_path
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
application executes it without noticing. `$user` is exempt: only the connected role owns it.
Refusing the runtime role `CREATE` closes the same route from the other side.
## What never leaves the process
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
name maps to a bounded unknown code rather than being passed through.
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
- The actuator report carries no JDBC URL, username, or SQL.
## Injection surfaces, and how each is closed
| Surface | Why it cannot be a parameter | Closed by |
|---|---|---|
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
| JSON path | part of the statement | registered `JsonPathName` |
| schema name | an identifier | registered `SchemaTenantRegistry` |
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
| COPY table | an identifier | registered `RegisteredCopyStatement` |
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
Values are always bound. Identifiers are always registered.