phase: 8, status: plan-as-execute, persona: security-engineer+platform-engineer, task: T-8.1..T-8.4
---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.
This commit is contained in:
@@ -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()
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Create the ACDL v1.1 spike AWS state backend (idempotent).
|
||||||
|
|
||||||
|
- S3 bucket acdl-tfstate-<account_id>-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()
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user