From cf046089f4ed8886ff8d2548117c5a42450c45e4 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:54:05 -0400 Subject: [PATCH] iac(access): temporary shoc-assessment-dynamo-reader for Luby initial assessment (30-day, read-only) (#141) * iac(access): temporary shoc-assessment-dynamo-reader role for Luby initial assessment 30-day, read-only (GetItem/Query/Scan/DescribeTable) cross-account role in seahaven-prod trusting seahaven-external-dev, trust-policy hard expiry 2026-08-23. Steady state remains procurement-api + webhook; teardown script included. * harden(access): resolve sh-security-review findings on assessment reader C1 (confirmed medium): DateLessThan expiry condition duplicated into both permissions statements so in-flight sessions die at the deadline, not +1h. C2 (confirmed medium): teardown now strips ALL inline/attached policies and instance profiles before DeleteRole (kill-switch semantics restored, idempotent), emergency-revocation section added to README. Cheap hardenings: CDPATH-immune SCRIPT_DIR, MaxSessionDuration re-asserted on update path, data-handling expectations documented. * harden(access): address Open SWE review on #141 - Trust now requires ArnLike aws:PrincipalArn on the Identity Center role path (human SSO sessions only; string condition survives permission-set reprovisioning, unlike a role-ARN Principal pin) - README extend instructions cover BOTH expiry sites (trust + permissions) - Create script logs caller ARN + timestamp and validates the policy's KMS ARN against SSM /seahaven/dynamodb/cmk-arn before applying --- infra/shoc-assessment-reader/README.md | 81 ++++++++++++ .../create-assessment-reader.sh | 116 ++++++++++++++++++ .../permissions-policy.json | 46 +++++++ .../teardown-assessment-reader.sh | 61 +++++++++ .../shoc-assessment-reader/trust-policy.json | 21 ++++ 5 files changed, 325 insertions(+) create mode 100644 infra/shoc-assessment-reader/README.md create mode 100755 infra/shoc-assessment-reader/create-assessment-reader.sh create mode 100644 infra/shoc-assessment-reader/permissions-policy.json create mode 100755 infra/shoc-assessment-reader/teardown-assessment-reader.sh create mode 100644 infra/shoc-assessment-reader/trust-policy.json diff --git a/infra/shoc-assessment-reader/README.md b/infra/shoc-assessment-reader/README.md new file mode 100644 index 0000000..659f053 --- /dev/null +++ b/infra/shoc-assessment-reader/README.md @@ -0,0 +1,81 @@ +# shoc-assessment-dynamo-reader (TEMPORARY) + +Time-boxed cross-account read role in **seahaven-prod (011934824531)** that lets +the Luby team assess the procurement-ingest DynamoDB tables directly from +**seahaven-external-dev (396287094661)** during their initial assessment. + +**This is not the steady-state access path.** SHOC's durable integration is the +`procurement-api` SigV4 read API plus the `workorder-shoc-emitter` webhook. +This role exists only so the team can eyeball raw tables while scoping that +integration, and it self-expires. + +| Item | Value | +|---|---| +| Role | `arn:aws:iam::011934824531:role/shoc-assessment-dynamo-reader` | +| Trusts | `arn:aws:iam::396287094661:root` restricted by `ArnLike aws:PrincipalArn` to `role/aws-reserved/sso.amazonaws.com/*` — human Identity Center sessions in external-dev only (no EC2/service/created roles) | +| Hard expiry | `2026-08-23T00:00:00Z` — `DateLessThan` on `aws:CurrentTime` in BOTH the trust policy (new AssumeRole calls fail) and every permissions statement (in-flight sessions lose all data access at the same instant) | +| Actions | `dynamodb:GetItem`, `Query`, `Scan`, `DescribeTable` | +| Tables | `purchase-orders`, `verified-sites`, `pending-site-review`, `WorkOrders`, `WorkOrderComments` (+ `index/*` on each) | +| KMS | `kms:Decrypt` on `seahaven-dynamodb-cmk` (`be5fa4cb-c546-40fe-a13d-c7bec79f5d12`), `kms:ViaService`-pinned to DynamoDB (the three po-ingest tables are CMK-encrypted); create script validates the key ARN against SSM `/seahaven/dynamodb/cmk-arn` before applying | +| Session | 1h (chained sessions from SSO cap at 1h regardless) | + +## Deliberate design choices (re-review before changing) + +- **Root trust + `aws:PrincipalArn` condition, no ExternalId.** The assessors + are humans on Identity Center sessions in external-dev; console Switch-Role + cannot pass an ExternalId, and there is no service deputy in this path. The + `ArnLike` condition on the SSO role path restricts to human sessions without + pinning a specific role ARN in `Principal` (role principals resolve to + unique IDs and silently break when Identity Center reprovisions the + permission set — a string condition survives that). +- **`DescribeTable` added** beyond the requested GetItem/Query/Scan: the + DynamoDB console item explorer and SDK table bindings require it + (metadata-only). +- **Not in CDK, not a table resource policy.** Table resource policies would + re-open the cross-account grant surface deliberately zeroed on 2026-07-23 and + trip `tests/test_cross_account_principal_pin.py`. A standalone role keeps the + grant in one deletable object; the pin test's scope (cdk/) is intact. +- **Reads are not access-logged** (no CloudTrail data events on these tables) — + accepted for a 30-day read-only window. + +## Data handling (communicate to Luby with the grant) + +Table contents are Sea Haven confidential and the work-order comment bodies are +free text that may contain personal data. View/query for the assessment only; +no bulk export or copies outside the AWS session; delete any local extracts +when the assessment ends. The governing vendor agreement applies. + +## Emergency revocation + +Fastest kill: run `./teardown-assessment-reader.sh`. A successful `DeleteRole` +immediately invalidates all outstanding session credentials — and the script +strips every inline/attached policy first, so `DeleteRole` cannot fail on +`DeleteConflict` (including the `AWSRevokeOlderSessions` inline policy the +console's "Revoke active sessions" button attaches). + +## Usage (Luby side) + +Console: Switch Role → account `011934824531`, role +`shoc-assessment-dynamo-reader`. Direct table deep-links: +`https://us-east-1.console.aws.amazon.com/dynamodbv2/home?region=us-east-1#item-explorer?table=` + +CLI: + +```bash +aws sts assume-role \ + --role-arn arn:aws:iam::011934824531:role/shoc-assessment-dynamo-reader \ + --role-session-name luby-assessment +aws dynamodb scan --table-name WorkOrders --max-items 25 # with the returned creds +``` + +## Lifecycle + +- Create/update: `./create-assessment-reader.sh` (gated on GPT-4.1 cross-family + review + `/sh-security-review` of these exact files). +- Remove: `./teardown-assessment-reader.sh` when the assessment is done; the + trust expiry is the backstop, not the plan. +- Extend: edit the `DateLessThan` timestamp in **both** `trust-policy.json` + AND every statement of `permissions-policy.json` (the expiry is enforced in + both layers — extending only the trust policy yields a role that assumes but + cannot read), then re-run both review gates before + `create-assessment-reader.sh`. diff --git a/infra/shoc-assessment-reader/create-assessment-reader.sh b/infra/shoc-assessment-reader/create-assessment-reader.sh new file mode 100755 index 0000000..43b0be7 --- /dev/null +++ b/infra/shoc-assessment-reader/create-assessment-reader.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +############################################################################### +# create-assessment-reader.sh +# +# Creates / updates the TEMPORARY cross-account read role +# `shoc-assessment-dynamo-reader` in AWS account 011934824531 (seahaven-prod), +# us-east-1, for the Luby team's initial data assessment from +# seahaven-external-dev (396287094661). +# +# TEMPORARY BY DESIGN: +# - Hard expiry 2026-08-23T00:00:00Z enforced in BOTH layers: the trust +# policy (new AssumeRole calls fail) and every permissions statement +# (in-flight sessions lose data access at the same instant), so access +# dies at the deadline even if teardown never runs. +# - Steady-state SHOC access is the procurement-api SigV4 read API plus the +# shoc-webhook emitter, NOT this role. Tear this down (or let it expire) +# once the assessment is done; extend only by a deliberate edit to +# trust-policy.json re-run through the review gates. +# +# GATE - DO NOT EXECUTE until BOTH of the following have passed on these +# exact artifact files: +# 1. GPT-4.1 cross-family review (IAM trust/permissions change) via +# cross_review.py in the security-review repo. +# 2. /sh-security-review (deep agentic pass - IaC/IAM is a gated surface). +# +# Design notes (deliberate, do not "fix" without re-review): +# - Account-root trust (396287094661:root), NOT a pinned role: the assessors +# are the Luby humans on SSO Admin sessions in external-dev, and every +# principal in that account is Luby-controlled + SCP/boundary-contained. +# - NO sts:ExternalId condition: console Switch-Role cannot pass one, and +# there is no third-party deputy in this human-driven path. (The old +# mgmt-account `shoc-dynamo-reader` used ExternalId because it served an +# EC2 role, i.e. an actual service deputy.) +# - dynamodb:DescribeTable is included beyond the requested +# GetItem/Query/Scan because the DynamoDB console item explorer and most +# SDK table bindings require it (metadata only). +# - kms:Decrypt is ViaService-pinned to DynamoDB: the three po-ingest tables +# are CMK-encrypted (seahaven-dynamodb-cmk); the WO tables are +# AWS-owned-key encrypted and need no KMS grant. +# - Max session 3600s: sessions from external-dev are role-chained (SSO -> +# Admin role -> this role), and chained sessions cap at 1h regardless. +############################################################################### + +set -euo pipefail + +PROFILE="seahaven-prod" +ROLE_NAME="shoc-assessment-dynamo-reader" +POLICY_NAME="shoc-assessment-dynamo-read" +ACCOUNT_ID="011934824531" +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +TRUST_POLICY="file://${SCRIPT_DIR}/trust-policy.json" +PERMS_POLICY="file://${SCRIPT_DIR}/permissions-policy.json" + +echo "==> Verifying active account for profile '${PROFILE}'..." +CALLER_ACCOUNT="$(aws --profile "${PROFILE}" sts get-caller-identity --query Account --output text)" +if [[ "${CALLER_ACCOUNT}" != "${ACCOUNT_ID}" ]]; then + echo "ERROR: profile '${PROFILE}' resolves to account ${CALLER_ACCOUNT}, expected ${ACCOUNT_ID}. Aborting." >&2 + exit 1 +fi +CALLER_ARN="$(aws --profile "${PROFILE}" sts get-caller-identity --query Arn --output text)" +echo " Audit: run by ${CALLER_ARN} at $(date -u +%Y-%m-%dT%H:%M:%SZ)" + +echo "==> Validating the KMS key ARN in permissions-policy.json against SSM /seahaven/dynamodb/cmk-arn..." +LIVE_CMK_ARN="$(aws --profile "${PROFILE}" ssm get-parameter \ + --name /seahaven/dynamodb/cmk-arn --query Parameter.Value --output text)" +if ! grep -qF "\"${LIVE_CMK_ARN}\"" "${SCRIPT_DIR}/permissions-policy.json"; then + echo "ERROR: permissions-policy.json does not reference the live DynamoDB CMK (${LIVE_CMK_ARN})." >&2 + echo " Copy/paste drift would make CMK-table reads fail silently at runtime. Aborting." >&2 + exit 1 +fi +echo " OK - policy references the live CMK." + +echo "==> Ensuring role '${ROLE_NAME}' exists with the correct trust policy..." +if aws --profile "${PROFILE}" iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then + echo " Role exists - updating assume-role (trust) policy + re-asserting session cap." + aws --profile "${PROFILE}" iam update-assume-role-policy \ + --role-name "${ROLE_NAME}" \ + --policy-document "${TRUST_POLICY}" + aws --profile "${PROFILE}" iam update-role \ + --role-name "${ROLE_NAME}" \ + --max-session-duration 3600 +else + echo " Role absent - creating." + aws --profile "${PROFILE}" iam create-role \ + --role-name "${ROLE_NAME}" \ + --assume-role-policy-document "${TRUST_POLICY}" \ + --description "TEMPORARY read-only DynamoDB access for Luby initial assessment from seahaven-external-dev; trust self-expires 2026-08-23" \ + --max-session-duration 3600 \ + --tags Key=project,Value=procurement-ingest Key=managed-by,Value=create-assessment-reader.sh Key=temporary,Value=expires-2026-08-23 +fi + +echo "==> Putting inline permissions policy '${POLICY_NAME}' (create-or-replace)..." +aws --profile "${PROFILE}" iam put-role-policy \ + --role-name "${ROLE_NAME}" \ + --policy-name "${POLICY_NAME}" \ + --policy-document "${PERMS_POLICY}" + +echo "==> Checking for unexpected policies on the role..." +EXTRA_INLINE="$(aws --profile "${PROFILE}" iam list-role-policies \ + --role-name "${ROLE_NAME}" --query "PolicyNames[?@!='${POLICY_NAME}']" --output text)" +EXTRA_ATTACHED="$(aws --profile "${PROFILE}" iam list-attached-role-policies \ + --role-name "${ROLE_NAME}" --query 'AttachedPolicies[].PolicyName' --output text)" +if [[ -n "${EXTRA_INLINE}" || -n "${EXTRA_ATTACHED}" ]]; then + echo " WARNING: unreviewed policies present on ${ROLE_NAME} (not in these artifacts):" >&2 + [[ -n "${EXTRA_INLINE}" ]] && echo " inline: ${EXTRA_INLINE}" >&2 + [[ -n "${EXTRA_ATTACHED}" ]] && echo " attached: ${EXTRA_ATTACHED}" >&2 + echo " Review and remove them (delete-role-policy / detach-role-policy) if unexpected." >&2 +else + echo " OK - only ${POLICY_NAME} present." +fi + +ROLE_ARN="$(aws --profile "${PROFILE}" iam get-role --role-name "${ROLE_NAME}" \ + --query Role.Arn --output text)" + +echo "==> Done. Assessment reader ready (expires 2026-08-23T00:00:00Z):" +echo " ${ROLE_ARN}" diff --git a/infra/shoc-assessment-reader/permissions-policy.json b/infra/shoc-assessment-reader/permissions-policy.json new file mode 100644 index 0000000..cd5d622 --- /dev/null +++ b/infra/shoc-assessment-reader/permissions-policy.json @@ -0,0 +1,46 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadProcurementTables", + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:DescribeTable" + ], + "Resource": [ + "arn:aws:dynamodb:us-east-1:011934824531:table/purchase-orders", + "arn:aws:dynamodb:us-east-1:011934824531:table/purchase-orders/index/*", + "arn:aws:dynamodb:us-east-1:011934824531:table/verified-sites", + "arn:aws:dynamodb:us-east-1:011934824531:table/verified-sites/index/*", + "arn:aws:dynamodb:us-east-1:011934824531:table/pending-site-review", + "arn:aws:dynamodb:us-east-1:011934824531:table/pending-site-review/index/*", + "arn:aws:dynamodb:us-east-1:011934824531:table/WorkOrders", + "arn:aws:dynamodb:us-east-1:011934824531:table/WorkOrders/index/*", + "arn:aws:dynamodb:us-east-1:011934824531:table/WorkOrderComments", + "arn:aws:dynamodb:us-east-1:011934824531:table/WorkOrderComments/index/*" + ], + "Condition": { + "DateLessThan": { + "aws:CurrentTime": "2026-08-23T00:00:00Z" + } + } + }, + { + "Sid": "DecryptTableCmkViaDynamo", + "Effect": "Allow", + "Action": "kms:Decrypt", + "Resource": "arn:aws:kms:us-east-1:011934824531:key/be5fa4cb-c546-40fe-a13d-c7bec79f5d12", + "Condition": { + "StringEquals": { + "kms:ViaService": "dynamodb.us-east-1.amazonaws.com" + }, + "DateLessThan": { + "aws:CurrentTime": "2026-08-23T00:00:00Z" + } + } + } + ] +} diff --git a/infra/shoc-assessment-reader/teardown-assessment-reader.sh b/infra/shoc-assessment-reader/teardown-assessment-reader.sh new file mode 100755 index 0000000..c697d4d --- /dev/null +++ b/infra/shoc-assessment-reader/teardown-assessment-reader.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +############################################################################### +# teardown-assessment-reader.sh +# +# Deletes the temporary `shoc-assessment-dynamo-reader` role in seahaven-prod +# (011934824531). Run once the Luby initial assessment is complete — and this +# is ALSO the emergency kill switch: a successful DeleteRole immediately +# invalidates all outstanding session credentials for the role. +# +# DeleteRole fails with DeleteConflict while ANY policy remains on the role +# (including the inline `AWSRevokeOlderSessions` policy the IAM console's +# "Revoke active sessions" button attaches), so this script enumerates and +# strips ALL inline policies, attached managed policies, and instance-profile +# memberships first. Idempotent: a rerun after the role is gone exits 0. +############################################################################### + +set -euo pipefail + +PROFILE="seahaven-prod" +ROLE_NAME="shoc-assessment-dynamo-reader" +ACCOUNT_ID="011934824531" + +CALLER_ACCOUNT="$(aws --profile "${PROFILE}" sts get-caller-identity --query Account --output text)" +if [[ "${CALLER_ACCOUNT}" != "${ACCOUNT_ID}" ]]; then + echo "ERROR: profile '${PROFILE}' resolves to account ${CALLER_ACCOUNT}, expected ${ACCOUNT_ID}. Aborting." >&2 + exit 1 +fi + +if ! aws --profile "${PROFILE}" iam get-role --role-name "${ROLE_NAME}" >/dev/null 2>&1; then + echo "==> Role '${ROLE_NAME}' already absent in ${ACCOUNT_ID} - nothing to do." + exit 0 +fi + +echo "==> Deleting ALL inline policies on '${ROLE_NAME}'..." +for POLICY in $(aws --profile "${PROFILE}" iam list-role-policies \ + --role-name "${ROLE_NAME}" --query 'PolicyNames[]' --output text); do + echo " delete-role-policy ${POLICY}" + aws --profile "${PROFILE}" iam delete-role-policy \ + --role-name "${ROLE_NAME}" --policy-name "${POLICY}" +done + +echo "==> Detaching ALL managed policies on '${ROLE_NAME}'..." +for POLICY_ARN in $(aws --profile "${PROFILE}" iam list-attached-role-policies \ + --role-name "${ROLE_NAME}" --query 'AttachedPolicies[].PolicyArn' --output text); do + echo " detach-role-policy ${POLICY_ARN}" + aws --profile "${PROFILE}" iam detach-role-policy \ + --role-name "${ROLE_NAME}" --policy-arn "${POLICY_ARN}" +done + +echo "==> Removing '${ROLE_NAME}' from any instance profiles..." +for IP in $(aws --profile "${PROFILE}" iam list-instance-profiles-for-role \ + --role-name "${ROLE_NAME}" --query 'InstanceProfiles[].InstanceProfileName' --output text); do + echo " remove-role-from-instance-profile ${IP}" + aws --profile "${PROFILE}" iam remove-role-from-instance-profile \ + --instance-profile-name "${IP}" --role-name "${ROLE_NAME}" +done + +echo "==> Deleting role '${ROLE_NAME}' (this invalidates all outstanding sessions)..." +aws --profile "${PROFILE}" iam delete-role --role-name "${ROLE_NAME}" + +echo "==> Done. ${ROLE_NAME} removed from ${ACCOUNT_ID}; all live sessions are dead." diff --git a/infra/shoc-assessment-reader/trust-policy.json b/infra/shoc-assessment-reader/trust-policy.json new file mode 100644 index 0000000..9856cde --- /dev/null +++ b/infra/shoc-assessment-reader/trust-policy.json @@ -0,0 +1,21 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ExternalDevAssessmentAssume", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::396287094661:root" + }, + "Action": "sts:AssumeRole", + "Condition": { + "DateLessThan": { + "aws:CurrentTime": "2026-08-23T00:00:00Z" + }, + "ArnLike": { + "aws:PrincipalArn": "arn:aws:iam::396287094661:role/aws-reserved/sso.amazonaws.com/*" + } + } + } + ] +}