Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@

public final class DatabaseAccessPolicy {
public static final String RUNTIME_GRANTS_FILE = "R__database_runtime_grants.sql";
public static final String BACKTEST_OBJECT_CLEANUP_FUNCTION =
"\"storage\".\"prepare_backtest_object_cleanup\"(jsonb)";
public static final String BACKTEST_OBJECT_CLEANUP_REISSUE_FUNCTION =
"\"storage\".\"reissue_backtest_object_cleanup\"(jsonb, text)";
public static final String BACKTEST_ATTEMPT_CLAIM_FUNCTION =
"\"backtest\".\"claim_run_attempt\"(uuid, text, text, bigint)";
public static final String BACKTEST_ATTEMPT_HEARTBEAT_FUNCTION =
"\"backtest\".\"heartbeat_run_attempt\"(uuid, uuid, bigint)";
public static final String BACKTEST_ATTEMPT_CLOSE_FUNCTION =
"\"backtest\".\"close_run_attempt\"(uuid, uuid, text, text, text, boolean)";
public static final String BACKTEST_ATTEMPT_RECOVERY_FUNCTION =
"\"backtest\".\"recover_expired_run_attempt\"(uuid, text, text)";
public static final String BACKTEST_OBJECT_REGISTER_FUNCTION =
"\"storage\".\"register_backtest_object\"(jsonb)";
public static final String BACKTEST_OBJECT_TRANSITION_FUNCTION =
"\"storage\".\"transition_backtest_object\"(uuid, text, timestamp with time zone)";
private static final String ROLE_PREFIX = "idea2strategy_";
private static final Pattern CREATE_TABLE = Pattern.compile(
"(?i)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?"
Expand Down Expand Up @@ -231,6 +247,35 @@ public static String runtimeGrantSql(List<String> migrationSql) {
.append(" TO ").append(roleName).append(";\n");
}
}
// Protected attempt and storage-object state is never mutated through a
// table-wide grant. Claim, heartbeat, close, recovery, staged registration,
// verification, reissue and compensation are narrow SECURITY DEFINER
// capabilities installed by forward migrations, so only EXECUTE is exposed.
for (var function : List.of(
BACKTEST_OBJECT_CLEANUP_FUNCTION,
BACKTEST_OBJECT_CLEANUP_REISSUE_FUNCTION,
BACKTEST_ATTEMPT_CLAIM_FUNCTION,
BACKTEST_ATTEMPT_HEARTBEAT_FUNCTION,
BACKTEST_ATTEMPT_CLOSE_FUNCTION,
BACKTEST_ATTEMPT_RECOVERY_FUNCTION,
BACKTEST_OBJECT_REGISTER_FUNCTION,
BACKTEST_OBJECT_TRANSITION_FUNCTION)) {
sql.append("REVOKE ALL ON FUNCTION ")
.append(function)
.append(" FROM PUBLIC;\n");
for (var role : ApplicationRole.values()) {
sql.append("REVOKE ALL ON FUNCTION ")
.append(function)
.append(" FROM ")
.append(databaseRole(role))
.append(";\n");
}
sql.append("GRANT EXECUTE ON FUNCTION ")
.append(function)
.append(" TO ")
.append(databaseRole(ApplicationRole.BACKTEST))
.append(";\n");
}
return sql.toString();
}

Expand Down Expand Up @@ -360,6 +405,9 @@ private static MigrationOwner ownerFor(QualifiedTable table) {

private static boolean allowsBacktest(Access access, String schema, String table) {
if ("backtest".equals(schema)) {
if ("run_attempts".equals(table)) {
return access == Access.READ;
}
return access == Access.READ || access == Access.INSERT || access == Access.UPDATE;
}
if (("strategy".equals(schema) || "market_data".equals(schema)) && access == Access.READ) {
Expand All @@ -386,19 +434,12 @@ private static boolean allowsBacktest(Access access, String schema, String table
return "outbox_consumer_receipts".equals(table)
&& (access == Access.READ || access == Access.INSERT || access == Access.UPDATE);
}
// The worker registers its detail objects in two steps, because an object may not claim to be
// published before its bytes have been re-read: `register` inserts the row as STAGED, and
// `mark_available` promotes that same row to AVAILABLE once the checksum verifies. `quarantine`
// is the third statement, recording a verification failure against the row that already exists.
// Both transitions are `UPDATE storage.objects SET status = ...` in the engine's repository, so
// INSERT alone stops a run after it has written its bytes — which is what failed INT03 run
// 9095f2a3 five times, with SELECT and INSERT held and UPDATE denied on the deployed role.
//
// DELETE stays out: a storage row is the identity of bytes that exist, and the worker never
// retracts one. Corruption is recorded by moving the row to QUARANTINED, not by removing it.
// Registration and status transitions now execute through attempt-fenced
// functions. SELECT is sufficient for reconciliation and result reads; a
// table-wide INSERT/UPDATE would let this role forge AVAILABLE evidence.
return "storage".equals(schema)
&& "objects".equals(table)
&& (access == Access.READ || access == Access.INSERT || access == Access.UPDATE);
&& access == Access.READ;
}

private static boolean allowsPipeline(Access access, String schema, String table) {
Expand Down
67 changes: 67 additions & 0 deletions db-migration/src/main/resources/db/migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,71 @@ The central assembler validates the immutable V1 checksum, migration naming, glo

Every assembled bundle ends with generated repeatable migration `R__database_runtime_grants.sql`. `DatabaseAccessPolicy` remains its single source of truth. It creates credential-free group roles, revokes public application access, and grants only the required schema and table privileges.

`V20260902000002__backtest_narrow_runtime_attempt_writes.sql` and
`V20260902000003__pipeline_narrow_backtest_object_writes.sql` remove direct
`run_attempts` and `storage.objects` mutation from the backtest runtime role. Claim,
heartbeat, close, recovery, registration, verification, cleanup, and immediate-successor
reconciliation are exposed only through attempt-fenced function capabilities. Existing
provider bytes registered without a database row remain deliberately unowned, and a
later descendant cannot adopt an older ancestor's artifact.

Environment-specific login roles and passwords remain deployment/bootstrap concerns and never appear in migration SQL.

## `storage.objects` event trigger and RDS major upgrades

`V20260902000001__pipeline_bind_backtest_cleanup_ownership.sql` installs the narrowly
scoped `storage_reject_unvalidated_object_fks` event trigger. It runs only after
`ALTER TABLE`, `CREATE TABLE`, or `CREATE TABLE AS`, and rejects only a command that
leaves an unvalidated foreign key targeting `storage.objects`. Unrelated DDL is not
blocked.

PostgreSQL restricts event-trigger creation to superusers. The Development deployment
contract satisfies that requirement without relying on the application role:

- `infra/terraform/environments/development/database.tf` creates the RDS master user
`idea2strategy_admin` with an AWS-managed master secret.
- `scripts/aws/development-database-bootstrap.sh` resolves that exact
`master_user_secret` and supplies its username and password to Flyway.
- The migration fails explicitly unless Flyway's current user is a PostgreSQL
superuser or a member of AWS RDS's `rds_superuser` role.

AWS RDS requires event triggers to be removed before a major-version upgrade. During
the upgrade maintenance window, stop application and migration traffic, then run as
the RDS master user:

```sql
DROP EVENT TRIGGER storage_reject_unvalidated_object_fks;
```

Immediately after the upgrade, first verify that no unsafe constraint was introduced:

```sql
SELECT n.nspname AS source_schema, c.relname AS source_table, fk.conname
FROM pg_constraint AS fk
JOIN pg_class AS c ON c.oid = fk.conrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE fk.contype = 'f'
AND fk.confrelid = 'storage.objects'::regclass
AND NOT fk.convalidated;
```

The result must be empty. Then recreate the trigger from the already-migrated function
and verify that it is enabled:

```sql
CREATE EVENT TRIGGER storage_reject_unvalidated_object_fks
ON ddl_command_end
WHEN TAG IN ('ALTER TABLE', 'CREATE TABLE', 'CREATE TABLE AS')
EXECUTE FUNCTION storage.reject_unvalidated_storage_object_fks();

SELECT evtname, evtenabled
FROM pg_event_trigger
WHERE evtname = 'storage_reject_unvalidated_object_fks';
```

Do not resume cleanup traffic unless the constraint query is empty and the trigger is
present with `evtenabled = 'O'`. See the PostgreSQL event-trigger privilege contract
and the AWS RDS major-upgrade event-trigger prerequisite:

- <https://www.postgresql.org/docs/current/sql-createeventtrigger.html>
- <https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_UpgradeDBInstance.PostgreSQL.MajorVersion.html>
Loading