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
6 changes: 6 additions & 0 deletions .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Check infrastructure trust policies offline
working-directory: .
run: |
bash -n infra/provision.sh
python3 -m unittest discover -s infra/tests -v

# pnpm must exist before setup-node so its `cache: pnpm` can resolve the store.
- name: Install pnpm
run: npm install -g "pnpm@${PNPM_VERSION}"
Expand Down
24 changes: 22 additions & 2 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ Single one-shot script. Provisions, in order:
3. An RDS Postgres instance `vortex-bench-prod` on `db.t4g.micro`, Postgres 16, 20 GiB GP3 storage, IAM auth enabled, RDS-managed master password (auto-rotated, stored in Secrets Manager), publicly accessible, single-AZ, 35-day backup window.
4. An RDS Proxy `vortex-bench-proxy` in front of the instance, `IAMAuth=REQUIRED`, TLS required, pulling the master credential from the Secrets-Manager-managed secret via a service-linked IAM role.
5. The GitHub OIDC provider `token.actions.githubusercontent.com` (account-scoped — created once if not present).
6. An IAM role `GitHubBenchmarkSchemaRole` trusted to GitHub Actions OIDC for the `vortex-data/vortex` repo (branches `develop` + `ct/bench-v4`) with `sts:AssumeRoleWithWebIdentity`. Permission policy: `rds-db:connect` scoped to the `migrator` Postgres user on the **instance** resource only (CI schema deploys connect to the public instance endpoint). The dead proxy grant this role carried through PR-1.6 was dropped in PR-2.1's least-privilege cleanup; the VPC-internal proxy serves only the Vercel reader.
7. An IAM role `GitHubBenchmarkIngestRole` (PR-2.1) trusted to the same OIDC provider, repo, and branches. Permission policy: `rds-db:connect` scoped to the `bench_ingest` Postgres user on the instance resource only. This is the dedicated least-privilege identity for the Phase-2 CI dual-write ingest path, deliberately separate from the schema-deploy `migrator` identity so the high-frequency ingest path can write data but never run DDL or migrations.
6. An IAM role `GitHubBenchmarkSchemaRole` trusted to GitHub Actions OIDC for `vortex-data/benchmarks-website` on `develop` with `sts:AssumeRoleWithWebIdentity`. Permission policy: `rds-db:connect` scoped to the `migrator` Postgres user on the **instance** resource only (CI schema deploys connect to the public instance endpoint). The dead proxy grant this role carried through PR-1.6 was dropped in PR-2.1's least-privilege cleanup; the VPC-internal proxy serves only the Vercel reader.
7. An IAM role `GitHubBenchmarkIngestRole` (PR-2.1) trusted to the same OIDC provider for `vortex-data/vortex` on `develop`. Permission policy: `rds-db:connect` scoped to the `bench_ingest` Postgres user on the instance resource only. This is the dedicated least-privilege identity for the Phase-2 CI dual-write ingest path, deliberately separate from the schema-deploy `migrator` identity so the high-frequency ingest path can write data but never run DDL or migrations.

The `migrator` Postgres user is created by `migrations/002_iam_db_user.sql` (PR-1.3) and the `bench_ingest` user by `migrations/004_ingest_role.sql` (PR-2.1); each OIDC role's permission ARN is pre-scoped to its user. `002` is applied as the RDS master because it creates a role (it needs `CREATEROLE` and grants the `rds_iam` / schema privileges the master holds), while `003` and `004` are applied as the master because they additionally grant on master-owned objects (the ledger and the six data tables, respectively). All three run during the one-time bootstrap; the migrator-run `schema-deploy` path then records them as already-applied.

Expand Down Expand Up @@ -243,3 +243,23 @@ Steady-state monthly bill once provisioned (rough). NOTE: prod `vortex-bench-pro
| Secrets Manager (1 secret) | ~$0.40 |
| Data transfer (CI ingests + reader fetches) | <$1 |
| **Total** | **~$30/month** |

## OIDC trust inputs

`SCHEMA_GITHUB_REPO` defaults to `vortex-data/benchmarks-website` and
`INGEST_GITHUB_REPO` defaults to `vortex-data/vortex`. Each role trusts only its repository's
`refs/heads/develop` subject with the `sts.amazonaws.com` audience. PRs, tags, environments,
and the retired `ct/bench-v4` branch are excluded. Manual schema deploys must select `develop`.
The old shared `GITHUB_REPO` input is rejected to prevent an ambiguous role assignment.

Reprovisioning replaces each role's trust policy with these inputs. It does not preserve manual
subjects. Review both generated policies offline before applying infrastructure changes:

```bash
bash infra/provision.sh --print-trust schema
bash infra/provision.sh --print-trust ingest
python3 -m unittest discover -s infra/tests -v
```

The print commands require only `jq` and make no AWS calls. They do not apply the policy.
The schema and ingest permission policies remain scoped to their respective database users.
85 changes: 35 additions & 50 deletions infra/provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
# 7. Creates the RDS Proxy `vortex-bench-proxy` in front of the RDS
# instance, also with IAM auth.
# 8. Creates two GitHub Actions OIDC IAM roles trusted to
# `token.actions.githubusercontent.com` for the `vortex-data/vortex` repo
# (branches `develop` + `ct/bench-v4`), each scoped to an instance dbuser:
# `token.actions.githubusercontent.com`, each scoped to its repository
# develop branch and one instance dbuser:
# - `GitHubBenchmarkSchemaRole`: `rds-db:connect` on the `migrator`
# Postgres user (schema deploys; migration 002 in PR-1.3).
# - `GitHubBenchmarkIngestRole`: `rds-db:connect` on the `bench_ingest`
Expand Down Expand Up @@ -69,7 +69,8 @@ readonly DB_MASTER_USERNAME="${DB_MASTER_USERNAME:-postgres}"
readonly SCHEMA_ROLE_NAME="${SCHEMA_ROLE_NAME:-GitHubBenchmarkSchemaRole}"
readonly INGEST_ROLE_NAME="${INGEST_ROLE_NAME:-GitHubBenchmarkIngestRole}"
readonly PROXY_ROLE_NAME="${PROXY_ROLE_NAME:-vortex-bench-proxy-role}"
readonly GITHUB_REPO="${GITHUB_REPO:-vortex-data/vortex}"
readonly SCHEMA_GITHUB_REPO="${SCHEMA_GITHUB_REPO:-vortex-data/benchmarks-website}"
readonly INGEST_GITHUB_REPO="${INGEST_GITHUB_REPO:-vortex-data/vortex}"
# Postgres role created by migrations/002 in PR-1.3; the OIDC role's
# rds-db:connect permission is scoped to this user. NOT overridable (unlike the
# other names above): the role name is hardcoded in migrations/002
Expand Down Expand Up @@ -409,36 +410,29 @@ ensure_oidc_provider() {
OIDC_PROVIDER_ARN="$provider_arn"
}

# Exact subjects deliberately exclude PRs, tags, and retired migration branches.
# Reprovisioning replaces trust with this declared policy, including on existing roles.
github_trust_policy() {
jq -n --arg account "$TARGET_ACCOUNT" --arg repo "$1" '{
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Principal: {Federated: ("arn:aws:iam::" + $account + ":oidc-provider/token.actions.githubusercontent.com")},
Action: "sts:AssumeRoleWithWebIdentity",
Condition: {StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": ("repo:" + $repo + ":ref:refs/heads/develop")
}}
}]
}'
}

ensure_schema_role() {
log "Step 6: GitHub Actions OIDC role ${SCHEMA_ROLE_NAME}."
ensure_oidc_provider

# Trust-policy sub-claim is scoped to the specific branches the
# schema-deploy.yml workflow runs on (`develop` + `ct/bench-v4`); this
# restriction is the gate against unauthorized OIDC role assumption. A
# `schema-deploy` GitHub Environment / manual-approval gate was deliberately
# declined per the 2026-05-29 deploy-model decision (it only re-confirms the
# authorization already given at PR merge; execution safety comes from the
# per-PR testcontainer migration test), NOT deferred for lack of repo-admin.
local trust_policy
trust_policy=$(cat <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "${OIDC_PROVIDER_ARN}"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
"StringLike": {"token.actions.githubusercontent.com:sub": [
"repo:${GITHUB_REPO}:ref:refs/heads/develop",
"repo:${GITHUB_REPO}:ref:refs/heads/ct/bench-v4"
]}
}
}]
}
EOF
)
trust_policy=$(github_trust_policy "$SCHEMA_GITHUB_REPO")

if SCHEMA_ROLE_ARN=$(aws iam get-role --role-name "$SCHEMA_ROLE_NAME" \
--query 'Role.Arn' --output text 2>/dev/null); then
Expand Down Expand Up @@ -493,29 +487,8 @@ ensure_ingest_role() {
log "Step 6b: GitHub Actions OIDC role ${INGEST_ROLE_NAME}."
ensure_oidc_provider

# Same branch-scoped trust as the schema role: the Phase-2 dual-write CI
# workflows (bench.yml / sql-benchmarks.yml / v3-commit-metadata.yml) ingest on
# push to `develop`, plus `ct/bench-v4` during the migration's dual-write soak.
# The sub-claim restriction is the gate against unauthorized role assumption.
local trust_policy
trust_policy=$(cat <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "${OIDC_PROVIDER_ARN}"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
"StringLike": {"token.actions.githubusercontent.com:sub": [
"repo:${GITHUB_REPO}:ref:refs/heads/develop",
"repo:${GITHUB_REPO}:ref:refs/heads/ct/bench-v4"
]}
}
}]
}
EOF
)
trust_policy=$(github_trust_policy "$INGEST_GITHUB_REPO")

if INGEST_ROLE_ARN=$(aws iam get-role --role-name "$INGEST_ROLE_NAME" \
--query 'Role.Arn' --output text 2>/dev/null); then
Expand Down Expand Up @@ -631,6 +604,18 @@ EOF
# -----------------------------------------------------------------------------

main() {
[ -z "${GITHUB_REPO+x}" ] || die "GITHUB_REPO was replaced by SCHEMA_GITHUB_REPO and INGEST_GITHUB_REPO."
if [ "$#" -gt 0 ]; then
if [ "$#" -eq 2 ] && [ "$1" = "--print-trust" ]; then
case "$2" in
schema) github_trust_policy "$SCHEMA_GITHUB_REPO" ;;
ingest) github_trust_policy "$INGEST_GITHUB_REPO" ;;
*) die "Expected schema or ingest." ;;
esac
return
fi
die "Usage: provision.sh [--print-trust schema|ingest]"
fi
verify_prereqs
discover_default_vpc
ensure_db_subnet_group
Expand Down
46 changes: 46 additions & 0 deletions infra/tests/test_oidc_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright the Vortex contributors

import json
import os
from pathlib import Path
import subprocess
import unittest

SCRIPT = Path(__file__).resolve().parents[1] / "provision.sh"


class TrustTests(unittest.TestCase):
def render(self, role, **overrides):
env = {k: v for k, v in os.environ.items()
if k not in {"GITHUB_REPO", "SCHEMA_GITHUB_REPO", "INGEST_GITHUB_REPO", "TARGET_ACCOUNT"}}
env.update(overrides)
return subprocess.run(["bash", str(SCRIPT), "--print-trust", role],
env=env, text=True, capture_output=True)

def test_exact_default_subjects(self):
for role, repo in (("schema", "benchmarks-website"), ("ingest", "vortex")):
result = self.render(role)
self.assertEqual(result.returncode, 0, result.stderr)
statement, = json.loads(result.stdout)["Statement"]
self.assertEqual(statement["Action"], "sts:AssumeRoleWithWebIdentity")
self.assertEqual(statement["Principal"], {"Federated":
"arn:aws:iam::245040174862:oidc-provider/token.actions.githubusercontent.com"})
self.assertEqual(statement["Condition"], {"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": f"repo:vortex-data/{repo}:ref:refs/heads/develop",
}})

def test_repository_inputs_are_independent(self):
overrides = {"SCHEMA_GITHUB_REPO": "example/schema", "INGEST_GITHUB_REPO": "example/writer"}
for role, repo in (("schema", "schema"), ("ingest", "writer")):
result = self.render(role, **overrides)
self.assertEqual(result.returncode, 0, result.stderr)
condition = json.loads(result.stdout)["Statement"][0]["Condition"]
self.assertEqual(condition["StringEquals"]["token.actions.githubusercontent.com:sub"],
f"repo:example/{repo}:ref:refs/heads/develop")

def test_shared_legacy_input_is_rejected(self):
result = self.render("schema", GITHUB_REPO="example/old")
self.assertNotEqual(result.returncode, 0)
self.assertIn("was replaced", result.stderr)
Loading