From 7eec07fc15475c854f644cc0253150f8fc1b487b Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 28 Jul 2026 12:44:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(P56):=20IAM=20re-bootstrap=20=E2=80=94=20p?= =?UTF-8?q?olicy=20extension=20+=20IAM=5FPOLICY.md=20+=20baseline=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical slice 1 of Phase 56 (REQ-116). Offline-testable deliverables landed; the live IAM apply step is escalated (D-095) below. terraform/bootstrap/spike_runner_policy.json — extended with the minimum permissions to terraform apply + probe CAP-017..022: - cloudfront:* (CAP-020 static-assets stack) - wafv2:* (CAP-020 WAF ACL) - lambda:* on function:acdl-* (CAP-018 contract-ingestor) - dynamodb:* on acdl-contracts + acdl-change-requests (CAP-017) - secretsmanager:GetSecretValue on secret:acdl/* (CAP-018 github-token) - sns:* on acdl-* (CAP-017 acdl-sod-halt) - ce:Get* (REQ-119 Cost Explorer read-only) - kms:* (CAP-017 platform + per-stack CMKs) - iam:CreateOpenIDConnectProvider + iam:CreateRole (CAP-022 OIDC re-create) .ciagent/IAM_POLICY.md — new baseline document. Original grants (v1.1–v1.10) + v1.11 grants table + least-privilege scoping notes + OIDC act_runner role plan + D-095 escalation note. tests/test_iam_policy_baseline.py — 15 tests. Asserts the required actions are present per service group, Lambda scoped to acdl-*, CE read-only, no iam:PassRole to Resource:*, DynamoDB acdl-contracts in resource. Regression-testable: any future permission drift surfaces as a test failure at milestone COMPLETE (D-091 gate). Test results: 15/15 pass. Full offline suite 509/509 pass (pre-existing test_seeded_registry_runs_and_reports_honest_status in test_verify_regression_mode.py hangs without AWS creds — environmental, not introduced here). ---ci--- project: acdl phase: 56 milestone: v1.11 status: execute escalation: type: deploy id: D-095 reason: ACDL_BOOTSTRAP_AWS_* not set in the execution environment blocking: live IAM policy apply (aws iam put-user-policy) + OIDC role re-creation (CAP-022) — requires an admin AWS principal action_required: provide fresh ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID + ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the run environment, then re-invoke ciagent-run to resume Phase 56 live step fallback: none (D-095 confirmed: escalate to human, no silent fallback to the deck-marking path) ---/ci--- --- .ciagent/IAM_POLICY.md | 82 +++++++++ terraform/bootstrap/spike_runner_policy.json | 135 +++++++++++++- tests/test_iam_policy_baseline.py | 179 +++++++++++++++++++ 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 .ciagent/IAM_POLICY.md create mode 100644 tests/test_iam_policy_baseline.py diff --git a/.ciagent/IAM_POLICY.md b/.ciagent/IAM_POLICY.md new file mode 100644 index 0000000..874b403 --- /dev/null +++ b/.ciagent/IAM_POLICY.md @@ -0,0 +1,82 @@ +# ACDL — IAM Policy Baseline (v1.11, REQ-116) + +> Source of truth: `terraform/bootstrap/spike_runner_policy.json`. +> Regression-tested by: `tests/test_iam_policy_baseline.py` (Phase 56). + +The `acdl-spike-runner` IAM user is the principal that runs the ACDL +platform pipeline (plan + apply) against account `581513795199`. This +document is the baseline of the permissions it holds, scoped to the +minimum required for the v1.11 milestone (Operating Model + Deploy +Verification, REQ-116..122). Any future grant must be documented here +and covered by the baseline test. + +## Original grants (v1.1–v1.10) + +| Capability | Actions | Resource scope | +|-----------|---------|----------------| +| Terraform state (S3) | `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject`, `s3:ListBucket`, `s3:GetBucketLocation`, `s3:GetBucketVersioning` | `acdl-tfstate-581513795199-us-east-1` + `/*` | +| DynamoDB outbox | `dynamodb:GetItem`, `PutItem`, `DeleteItem`, `UpdateItem`, `Query`, `Scan`, `DescribeTable` | `table/acdl-outbox` | +| STS identity | `sts:GetCallerIdentity` | `*` | +| ECS | `ecs:Create*`, `Describe*`, `Delete*`, `Update*`, `Register*`, `Deregister*`, `List*` | `ecs:us-east-1:581513795199:*` | +| ECR | `ecr:Create*`, `Describe*`, `Delete*`, `Get*`, `Batch*`, `Put*`, `Upload*`, `Initiate*`, `Complete*` | `ecr:us-east-1:581513795199:*` | +| ELB | `elasticloadbalancing:Create*`, `Describe*`, `Delete*`, `Modify*`, `Register*`, `Deregister*` | `elasticloadbalancing:us-east-1:581513795199:*` | +| IAM (role + policy mgmt) | `iam:Create*`, `Get*`, `Delete*`, `PassRole`, `Attach*`, `Detach*`, `List*`, `Put*` | `iam::581513795199:*` | +| EC2 (VPC + SG) | `ec2:Create*`, `Describe*`, `Delete*`, `Associate*`, `Disassociate*`, `Attach*`, `Detach*`, `Authorize*` | `ec2:us-east-1:581513795199:*` | + +## v1.11 grants (Phase 56, REQ-116) + +| Capability | Actions | Resource scope | REQ | +|-----------|---------|----------------|-----| +| CloudFront (CAP-020) | `cloudfront:Create*`, `Describe*`, `Get*`, `List*`, `Update*`, `Delete*`, `TagResource`, `UntagResource` | `*` (CloudFront ARNs are regional-global) | REQ-118 | +| WAFv2 (CAP-020) | `wafv2:Create*`, `Describe*`, `Get*`, `List*`, `Update*`, `Delete*` | `*` (WAFv2 global + regional) | REQ-118 | +| Lambda (CAP-018) | `lambda:Create*`, `Get*`, `List*`, `Update*`, `Delete*`, `InvokeFunction`, `InvokeFunctionUrl`, `TagResource`, `UntagResource`, `PublishLayerVersion` | `lambda:us-east-1:581513795199:function:acdl-*` | REQ-117 | +| DynamoDB contracts (CAP-017) | `dynamodb:Create*`, `Describe*`, `Get*`, `Put*`, `Update*`, `Delete*`, `Query`, `Scan`, `Batch*` | `table/acdl-contracts` + `/*` + `table/acdl-change-requests` + `/*` | REQ-117 | +| Secrets Manager (CAP-018) | `secretsmanager:GetSecretValue`, `DescribeSecret`, `CreateSecret`, `PutSecretValue`, `DeleteSecret`, `ListSecrets` | `secret:acdl/*` | REQ-117 | +| SNS (CAP-017) | `sns:CreateTopic`, `Publish`, `GetTopicAttributes`, `SetTopicAttributes`, `DeleteTopic`, `ListTopics` | `sns:us-east-1:581513795199:acdl-*` | REQ-117 | +| Cost Explorer (REQ-119) | `ce:GetCostAndUsage`, `GetCostForecast`, `GetCostAndUsageWithResources`, `GetDimensionValues`, `GetTags` | `*` (CE is account-scoped) | REQ-119 | +| KMS (CAP-017) | `kms:CreateKey`, `CreateAlias`, `Describe*`, `Get*`, `List*`, `Update*`, `Delete*`, `EnableKey`, `DisableKey`, `ScheduleKeyDeletion`, `TagResource`, `UntagResource` | `*` (KMS ARNs are account-wide) | REQ-117/118 | +| IAM OIDC (CAP-022) | `iam:CreateOpenIDConnectProvider`, `GetOpenIDConnectProvider`, `DeleteOpenIDConnectProvider`, `ListOpenIDConnectProviders`, `UpdateOpenIDConnectProviderThumbprint`, `iam:CreateRole`, `GetRole`, `ListRoles`, `DeleteRole`, `UpdateRole`, `TagRole`, `UntagRole` | `*` (OIDC providers + roles are account-wide) | REQ-116 | + +## OIDC act_runner role (CAP-022, Phase 56) + +The OIDC role for the Gitea `act_runner` was created in Phase 08 and +gone since (CAPABILITY_INVENTORY.md CAP-022). Phase 56 re-creates it +with a trust policy for the Gitea runner ARN. The role grants the +spike-runner-equivalent permissions to the runner via `sts:AssumeRole`, +so the runner does not need a long-lived access key. This closes the +chicken-and-egg: the spike-runner creates the OIDC role using the +bootstrap root key; the runner then assumes the role. + +> **Note:** Real OIDC federation (D-039) is blocked on +> `go-gitea/gitea#36988`. Phase 56 re-creates the IAM role + trust +> policy; act_runner adoption is out of scope for v1.11 (see +> REQUIREMENTS.md §Out of Scope v1.11). The role exists so the +> spike-runner can be rotated out once Gitea merges OIDC support. + +## Least-privilege scoping notes + +- **CloudFront/WAF/KMS/CE/OIDC use `Resource: "*"`** because these + services use account-scoped or global ARNs that cannot be resource- + restricted at the statement level. Scope is bounded by the action + list (e.g. only `ce:Get*` read actions for Cost Explorer; no `ce:*` + write because CE has no write surface). +- **Lambda is scoped to `function:acdl-*`** — only ACDL-owned + functions, not all functions in the account. +- **DynamoDB is scoped to `acdl-contracts` + `acdl-change-requests`** + in addition to the original `acdl-outbox` grant. The spike-runner + cannot touch other tables in the account. +- **Secrets Manager is scoped to `secret:acdl/*`** — only ACDL-owned + secrets. +- **SNS is scoped to `acdl-*`** topic names. +- **No `iam:PassRole` to `*`** — the original `iam:PassRole` grant is + scoped to `iam::581513795199:*` (account roles only); the v1.11 + grant does not extend it. + +## Escalation (D-095) + +Applying this policy requires the bootstrap root key +(`ACDL_BOOTSTRAP_AWS_*`). If the key is invalid (D-034 closed the +original root key), the run escalates to human for fresh access keys +— no silent fallback to the deck-marking path. The escalation is +committed as a `---ci---` block with `escalation` type and the run +pauses for the user. \ No newline at end of file diff --git a/terraform/bootstrap/spike_runner_policy.json b/terraform/bootstrap/spike_runner_policy.json index 1f1812e..99380d8 100644 --- a/terraform/bootstrap/spike_runner_policy.json +++ b/terraform/bootstrap/spike_runner_policy.json @@ -101,6 +101,139 @@ "ec2:Authorize*" ], "Resource": "arn:aws:ec2:us-east-1:581513795199:*" + }, + { + "Effect": "Allow", + "Action": [ + "cloudfront:Create*", + "cloudfront:Describe*", + "cloudfront:Get*", + "cloudfront:List*", + "cloudfront:Update*", + "cloudfront:Delete*", + "cloudfront:TagResource", + "cloudfront:UntagResource" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "wafv2:Create*", + "wafv2:Describe*", + "wafv2:Get*", + "wafv2:List*", + "wafv2:Update*", + "wafv2:Delete*" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "lambda:Create*", + "lambda:Get*", + "lambda:List*", + "lambda:Update*", + "lambda:Delete*", + "lambda:InvokeFunction", + "lambda:InvokeFunctionUrl", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:PublishLayerVersion" + ], + "Resource": "arn:aws:lambda:us-east-1:581513795199:function:acdl-*" + }, + { + "Effect": "Allow", + "Action": [ + "dynamodb:Create*", + "dynamodb:Describe*", + "dynamodb:Get*", + "dynamodb:Put*", + "dynamodb:Update*", + "dynamodb:Delete*", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:Batch*" + ], + "Resource": [ + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-contracts", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-contracts/*", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-change-requests", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-change-requests/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:ListSecrets" + ], + "Resource": "arn:aws:secretsmanager:us-east-1:581513795199:secret:acdl/*" + }, + { + "Effect": "Allow", + "Action": [ + "sns:CreateTopic", + "sns:Publish", + "sns:GetTopicAttributes", + "sns:SetTopicAttributes", + "sns:DeleteTopic", + "sns:ListTopics" + ], + "Resource": "arn:aws:sns:us-east-1:581513795199:acdl-*" + }, + { + "Effect": "Allow", + "Action": [ + "ce:GetCostAndUsage", + "ce:GetCostForecast", + "ce:GetCostAndUsageWithResources", + "ce:GetDimensionValues", + "ce:GetTags" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "kms:CreateKey", + "kms:CreateAlias", + "kms:Describe*", + "kms:Get*", + "kms:List*", + "kms:Update*", + "kms:Delete*", + "kms:EnableKey", + "kms:DisableKey", + "kms:ScheduleKeyDeletion", + "kms:TagResource", + "kms:UntagResource" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "iam:CreateOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:ListOpenIDConnectProviders", + "iam:UpdateOpenIDConnectProviderThumbprint", + "iam:CreateRole", + "iam:GetRole", + "iam:ListRoles", + "iam:DeleteRole", + "iam:UpdateRole", + "iam:TagRole", + "iam:UntagRole" + ], + "Resource": "*" } ] -} \ No newline at end of file +} diff --git a/tests/test_iam_policy_baseline.py b/tests/test_iam_policy_baseline.py new file mode 100644 index 0000000..33a9b56 --- /dev/null +++ b/tests/test_iam_policy_baseline.py @@ -0,0 +1,179 @@ +"""Tests for the IAM policy baseline (REQ-116, v1.11 Phase 56). + +Asserts that terraform/bootstrap/spike_runner_policy.json grants the +minimum permissions required for CAP-017..022 + the Cost Explorer query +(REQ-119). This is the regression-testable surface for the IAM re- +bootstrap: any future drift (a permission removed) surfaces as a test +failure at milestone COMPLETE (D-091 gate). +""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +ROOT = Path(__file__).resolve().parent.parent +POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json" + +REQUIRED_ACTIONS = { + "cloudfront": [ + "cloudfront:Create*", + "cloudfront:Get*", + "cloudfront:List*", + "cloudfront:Update*", + "cloudfront:Delete*", + ], + "waf": [ + "wafv2:Create*", + "wafv2:Get*", + "wafv2:List*", + "wafv2:Update*", + "wafv2:Delete*", + ], + "lambda": [ + "lambda:Create*", + "lambda:Get*", + "lambda:List*", + "lambda:Update*", + "lambda:Delete*", + "lambda:InvokeFunction", + "lambda:InvokeFunctionUrl", + ], + "dynamodb_contracts": [ + "dynamodb:Create*", + "dynamodb:Describe*", + "dynamodb:Get*", + "dynamodb:Put*", + "dynamodb:Update*", + "dynamodb:Delete*", + "dynamodb:Query", + "dynamodb:Scan", + ], + "secretsmanager": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecrets", + ], + "sns": [ + "sns:CreateTopic", + "sns:Publish", + "sns:ListTopics", + ], + "cost_explorer": [ + "ce:GetCostAndUsage", + "ce:GetCostForecast", + "ce:GetCostAndUsageWithResources", + "ce:GetDimensionValues", + "ce:GetTags", + ], + "kms": [ + "kms:CreateKey", + "kms:CreateAlias", + "kms:Describe*", + "kms:Get*", + "kms:List*", + "kms:ScheduleKeyDeletion", + ], + "iam_oidc": [ + "iam:CreateOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:ListOpenIDConnectProviders", + "iam:CreateRole", + "iam:GetRole", + "iam:ListRoles", + ], +} + + +def _all_actions(policy): + actions = set() + for stmt in policy["Statement"]: + if stmt.get("Effect") != "Allow": + continue + stmt_actions = stmt.get("Action", []) + if isinstance(stmt_actions, str): + stmt_actions = [stmt_actions] + for a in stmt_actions: + actions.add(a) + return actions + + +def _has_action(all_actions, required): + if required.endswith("*"): + prefix = required[:-1] + return any(a.startswith(prefix) for a in all_actions) + return required in all_actions + + +class TestIAMPolicyBaseline: + """REQ-116: the spike_runner_policy.json grants the v1.11 minimum.""" + + @pytest.fixture(scope="class") + def policy(self): + return json.loads(POLICY_PATH.read_text()) + + def test_policy_file_exists_and_is_valid_json(self, policy): + assert "Statement" in policy + assert isinstance(policy["Statement"], list) + assert len(policy["Statement"]) >= 15 + + def test_all_statements_are_allow_or_have_effect(self, policy): + for stmt in policy["Statement"]: + assert "Effect" in stmt + assert stmt["Effect"] in {"Allow", "Deny"} + + @pytest.mark.parametrize("group", sorted(REQUIRED_ACTIONS)) + def test_required_actions_present(self, policy, group): + all_actions = _all_actions(policy) + missing = [a for a in REQUIRED_ACTIONS[group] if not _has_action(all_actions, a)] + assert not missing, f"missing required {group} actions: {missing}" + + def test_dynamodb_contracts_table_in_resource(self, policy): + contracts_stmts = [ + s for s in policy["Statement"] + if any("acdl-contracts" in r for r in ( + s.get("Resource") if isinstance(s.get("Resource"), list) else [s.get("Resource", "")] + )) + ] + assert contracts_stmts, "no statement references the acdl-contracts table" + + def test_lambda_scoped_to_acdl_functions(self, policy): + lambda_stmts = [s for s in policy["Statement"] if any( + a.startswith("lambda:") for a in ( + s.get("Action") if isinstance(s.get("Action"), list) else [s.get("Action", "")] + ) + )] + assert lambda_stmts, "no lambda statement" + for s in lambda_stmts: + res = s.get("Resource", "") + if isinstance(res, list): + res = " ".join(res) + assert "function:acdl-*" in res or res == "*", \ + "lambda actions not scoped to acdl-* functions" + + def test_cost_explorer_is_read_only(self, policy): + ce_actions = set() + for s in policy["Statement"]: + acts = s.get("Action", []) + if isinstance(acts, str): + acts = [acts] + for a in acts: + if a.startswith("ce:"): + ce_actions.add(a) + for a in ce_actions: + assert a.startswith("ce:Get") or a.startswith("ce:List") or a.startswith("ce:Describe"), \ + f"non-read Cost Explorer action granted: {a}" + + def test_no_statement_uses_iam_passrole_to_star(self, policy): + for s in policy["Statement"]: + acts = s.get("Action", []) + if isinstance(acts, str): + acts = [acts] + if "iam:PassRole" in acts: + res = s.get("Resource", "") + if isinstance(res, list): + res = " ".join(res) + assert res != "*", "iam:PassRole must not be granted to Resource: *" \ No newline at end of file