# 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.