From 8b09206ee591f316583f78548e5537ca9b482dc4 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Tue, 8 Sep 2026 11:39:43 -0400 Subject: [PATCH] Scope schema and ingest OIDC trust to their owning repositories Signed-off-by: Connor Tsui --- .github/workflows/web-ci.yml | 6 +++ infra/README.md | 24 +++++++++- infra/provision.sh | 85 ++++++++++++++-------------------- infra/tests/test_oidc_trust.py | 46 ++++++++++++++++++ 4 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 infra/tests/test_oidc_trust.py diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 54f8900..83dc8c4 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -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}" diff --git a/infra/README.md b/infra/README.md index 5245fd6..2d3806b 100644 --- a/infra/README.md +++ b/infra/README.md @@ -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. @@ -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. diff --git a/infra/provision.sh b/infra/provision.sh index f270bcd..6c0e741 100755 --- a/infra/provision.sh +++ b/infra/provision.sh @@ -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` @@ -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 @@ -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 </dev/null); then @@ -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 </dev/null); then @@ -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 diff --git a/infra/tests/test_oidc_trust.py b/infra/tests/test_oidc_trust.py new file mode 100644 index 0000000..214a54f --- /dev/null +++ b/infra/tests/test_oidc_trust.py @@ -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)