From f8ddd8b182d27c48cd8a0abd6574c4fb184a1460 Mon Sep 17 00:00:00 2001 From: Jon Chery Date: Tue, 21 Jul 2026 18:58:29 +0000 Subject: [PATCH] phase: 8, status: plan-as-execute, persona: security-engineer+platform-engineer, task: T-8.1..T-8.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ---ci--- project: acdl phase: 8 milestone: v1.1 status: plan-as-execute persona: security-engineer+platform-engineer task: [T-8.1, T-8.2, T-8.3, T-8.4] requirements.covered: [REQ-23] ---/ci--- Waves 1+2: IAM policy + state backend + IAM user creation scripts. - T-8.1 (security): terraform/bootstrap/spike_runner_policy.json — least-privilege IAM policy: Allow S3 r/w on the state bucket, DynamoDB r/w on the outbox table, sts:GetCallerIdentity; final Deny statement (Action *, NotResource = the above ARNs) enforcing least privilege. No terraform apply permission (plan-only spike). - T-8.2/T-8.3 (platform): terraform/bootstrap/create_state_backend.py — boto3, idempotent: creates S3 bucket acdl-tfstate-581513795199-us-east-1 (versioning enabled) + DynamoDB table acdl-outbox (PAY_PER_REQUEST, PK contractId, SK eventType#eventTs per D-P08-1 one table for both lock + outbox). Writes .bootstrap_state.json marker. - T-8.4 (platform + security review): terraform/bootstrap/create_iam_user.py — boto3, idempotent: creates IAM user acdl-spike-runner, attaches the inline policy from spike_runner_policy.json, creates an initial access key if none active exists (prints to stdout for the orchestrator to capture; NEVER committed). py_compile + policy JSON valid. --- terraform/bootstrap/create_iam_user.py | 72 ++++++++++++++++ terraform/bootstrap/create_state_backend.py | 88 ++++++++++++++++++++ terraform/bootstrap/spike_runner_policy.json | 51 ++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 terraform/bootstrap/create_iam_user.py create mode 100644 terraform/bootstrap/create_state_backend.py create mode 100644 terraform/bootstrap/spike_runner_policy.json diff --git a/terraform/bootstrap/create_iam_user.py b/terraform/bootstrap/create_iam_user.py new file mode 100644 index 0000000..996f16d --- /dev/null +++ b/terraform/bootstrap/create_iam_user.py @@ -0,0 +1,72 @@ +"""Create the ACDL v1.1 spike IAM user + scoped inline policy + initial key. + +Idempotent: skips user creation if the user exists; creates an initial +access key if none active exists. Prints the key to stdout for the +orchestrator to capture (NEVER committed): + ACDL_AWS_ACCESS_KEY_ID=<...> + ACDL_AWS_SECRET_ACCESS_KEY=<...> + +Run with the bootstrap root key in env: + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION (defaults to us-east-1) + +The inline policy is read from spike_runner_policy.json (next to this +file). The account id + region are already substituted in the policy file +for account 581513795199 + us-east-1; this script does not substitute +further (the policy file is spike-specific). +""" + +import json +import os +import sys + +import boto3 + + +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +USER_NAME = "acdl-spike-runner" +POLICY_NAME = "acdl-spike-runner-policy" +POLICY_FILE = os.path.join(os.path.dirname(__file__), "spike_runner_policy.json") + + +def main(): + session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=REGION, + ) + iam = session.client("iam") + + # --- IAM user (idempotent) --- + try: + iam.get_user(UserName=USER_NAME) + print(f"iam: user {USER_NAME} already exists") + except iam.exceptions.NoSuchEntityException: + iam.create_user(UserName=USER_NAME) + print(f"iam: created user {USER_NAME}") + + # --- Inline policy (idempotent: put_user_policy overwrites) --- + with open(POLICY_FILE, "r") as fh: + policy_doc = fh.read() + iam.put_user_policy( + UserName=USER_NAME, + PolicyName=POLICY_NAME, + PolicyDocument=policy_doc, + ) + print(f"iam: inline policy {POLICY_NAME} attached to {USER_NAME}") + + # --- Initial access key (create only if no active key exists) --- + keys = iam.list_access_keys(UserName=USER_NAME).get("AccessKeyMetadata", []) + active = [k for k in keys if k["Status"] == "Active"] + if active: + print(f"iam: {USER_NAME} already has {len(active)} active key(s); not creating a new one") + print(" (use scripts/rotate_spike_key.sh to rotate)") + return + new_key = iam.create_access_key(UserName=USER_NAME)["AccessKey"] + print("ACDL_AWS_ACCESS_KEY_ID=" + new_key["AccessKeyId"]) + print("ACDL_AWS_SECRET_ACCESS_KEY=" + new_key["SecretAccessKey"]) + print(f"iam: created initial access key {new_key['AccessKeyId']} for {USER_NAME}", file=sys.stderr) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/terraform/bootstrap/create_state_backend.py b/terraform/bootstrap/create_state_backend.py new file mode 100644 index 0000000..1544d78 --- /dev/null +++ b/terraform/bootstrap/create_state_backend.py @@ -0,0 +1,88 @@ +"""Create the ACDL v1.1 spike AWS state backend (idempotent). + +- S3 bucket acdl-tfstate--us-east-1 (versioning enabled). +- DynamoDB table acdl-outbox (PAY_PER_REQUEST; PK contractId, SK + eventType#eventTs) — used for BOTH Terraform state locking AND the + evidence outbox (D-P08-1). + +Run with the bootstrap root key in env: + ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID / ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION (defaults to us-east-1) + +Writes terraform/bootstrap/.bootstrap_state.json (gitignored bookkeeping). +""" + +import datetime +import json +import os +import sys + +import boto3 + + +REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") +STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1" +OUTBOX_TABLE = "acdl-outbox" +ACCOUNT_ID = "581513795199" + + +def main(): + session = boto3.Session( + aws_access_key_id=os.environ["ACDL_BOOTSTRAP_AWS_ACCESS_KEY_ID"], + aws_secret_access_key=os.environ["ACDL_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"], + region_name=REGION, + ) + s3 = session.client("s3", region_name=REGION) + dyn = session.client("dynamodb", region_name=REGION) + + # --- S3 state bucket (idempotent) --- + try: + s3.head_bucket(Bucket=STATE_BUCKET) + print(f"s3: bucket {STATE_BUCKET} already exists") + except Exception: + kwargs = {"Bucket": STATE_BUCKET} + if REGION != "us-east-1": + kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION} + s3.create_bucket(**kwargs) + print(f"s3: created bucket {STATE_BUCKET}") + # Enable versioning (idempotent) + s3.put_bucket_versioning( + Bucket=STATE_BUCKET, + VersioningConfiguration={"Status": "Enabled"}, + ) + print(f"s3: versioning enabled on {STATE_BUCKET}") + + # --- DynamoDB outbox table (idempotent) --- + try: + dyn.describe_table(TableName=OUTBOX_TABLE) + print(f"dynamodb: table {OUTBOX_TABLE} already exists") + except dyn.exceptions.ResourceNotFoundException: + dyn.create_table( + TableName=OUTBOX_TABLE, + BillingMode="PAY_PER_REQUEST", + AttributeDefinitions=[ + {"AttributeName": "contractId", "AttributeType": "S"}, + {"AttributeName": "eventType#eventTs", "AttributeType": "S"}, + ], + KeySchema=[ + {"AttributeName": "contractId", "KeyType": "HASH"}, + {"AttributeName": "eventType#eventTs", "KeyType": "RANGE"}, + ], + ) + print(f"dynamodb: created table {OUTBOX_TABLE}") + dyn.get_waiter("table_exists").wait(TableName=OUTBOX_TABLE) + + marker = { + "account_id": ACCOUNT_ID, + "bucket_name": STATE_BUCKET, + "table_name": OUTBOX_TABLE, + "region": REGION, + "created_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + } + with open(os.path.join(os.path.dirname(__file__), ".bootstrap_state.json"), "w") as fh: + json.dump(marker, fh, indent=2) + print("bootstrap state marker written:", marker) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/terraform/bootstrap/spike_runner_policy.json b/terraform/bootstrap/spike_runner_policy.json new file mode 100644 index 0000000..6b68895 --- /dev/null +++ b/terraform/bootstrap/spike_runner_policy.json @@ -0,0 +1,51 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SpikeStateBucketReadWrite", + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject", + "s3:ListBucket", + "s3:GetBucketLocation", + "s3:GetBucketVersioning" + ], + "Resource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*" + ] + }, + { + "Sid": "SpikeOutboxTableReadWrite", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:DescribeTable" + ], + "Resource": "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + }, + { + "Sid": "SpikeStsSelfIdentify", + "Effect": "Allow", + "Action": "sts:GetCallerIdentity", + "Resource": "*" + }, + { + "Sid": "DenyEverythingElse", + "Effect": "Deny", + "Action": "*", + "NotResource": [ + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1", + "arn:aws:s3:::acdl-tfstate-581513795199-us-east-1/*", + "arn:aws:dynamodb:us-east-1:581513795199:table/acdl-outbox" + ] + } + ] +} \ No newline at end of file