mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 06:03:14 +00:00
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
This commit is contained in:
parent
cf8ca2eeb6
commit
cf046089f4
5 changed files with 325 additions and 0 deletions
81
infra/shoc-assessment-reader/README.md
Normal file
81
infra/shoc-assessment-reader/README.md
Normal file
|
|
@ -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=<TableName>`
|
||||
|
||||
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`.
|
||||
116
infra/shoc-assessment-reader/create-assessment-reader.sh
Executable file
116
infra/shoc-assessment-reader/create-assessment-reader.sh
Executable file
|
|
@ -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}"
|
||||
46
infra/shoc-assessment-reader/permissions-policy.json
Normal file
46
infra/shoc-assessment-reader/permissions-policy.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
61
infra/shoc-assessment-reader/teardown-assessment-reader.sh
Executable file
61
infra/shoc-assessment-reader/teardown-assessment-reader.sh
Executable file
|
|
@ -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."
|
||||
21
infra/shoc-assessment-reader/trust-policy.json
Normal file
21
infra/shoc-assessment-reader/trust-policy.json
Normal file
|
|
@ -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/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue