mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
Compare commits
8 commits
3c6d6b1326
...
f2e708dbf0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2e708dbf0 | ||
| 9a3784c471 | |||
| e289ad06e6 | |||
| 7569bd8250 | |||
| 2f2fc83a82 | |||
| eb56d39b93 | |||
| 5abc369e02 | |||
| c8f779a60b |
20 changed files with 29 additions and 677 deletions
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
|
|
@ -19,8 +19,8 @@ jobs:
|
|||
spec-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/`
|
|||
|
||||
- **Spec is source of truth:** `lambdas/api/openapi.json` (OpenAPI 3.1). Its top-level `webhooks` section documents the outbound SHOC work-order feed, so one page describes both directions (call + be-called). `tests/test_api_spec_drift.py` pins the spec's paths to the router table, so spec and implementation cannot drift.
|
||||
- **Auth:** data routes use API Gateway `AWS_IAM` (SigV4) plus a resource policy allowing exactly `arn:aws:iam::396287094661:role/shoc-backend-dev` on `GET/*`; same-account admin callers authorize via identity policy (Postman signs SigV4 natively). Docs routes are auth `NONE` at the gateway (resource-policy carve-out for exactly those two GETs) but the handler fails closed on the shared token (`lambdas/shared/web_ui_auth.py`, secret `procurement-ingest/web-ui-auth-token`) — not an unauthenticated data path (INFRA-74 posture).
|
||||
- **Custom domain:** `https://procurement-api.seahaven.com` (REGIONAL API Gateway domain, TLS 1.2, empty base-path mapping to the `prod` stage, so callers hit `/work-orders` with no `/prod` segment). The stable SHOC-facing endpoint; the raw `*.execute-api.us-east-1.amazonaws.com/prod` URL still works. **Cross-account DNS:** the `seahaven.com` public zone is in the mgmt account (`328440206208`), so the ACM cert's validation record and the A-alias are added there out of band — `scripts/setup_procurement_api_domain.sh cert` issues the cert (DNS-validated against the mgmt zone) and writes its ARN to prod SSM `/procurement-api/custom-domain/certificate-arn`, which the stack reads (`value_for_string_parameter`, CFN-resolved at deploy); after `cdk deploy procurement-api`, `scripts/setup_procurement_api_domain.sh alias` adds the A-alias from the stack's `ProcurementApiAliasTarget`/`ProcurementApiAliasHostedZoneId` outputs. SigV4 is unaffected (same underlying API id + resource policy); the docs page injects whichever host served the request into `servers[0].url`.
|
||||
- **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class).
|
||||
- **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers).
|
||||
- **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic).
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ from aws_cdk import (
|
|||
Duration,
|
||||
Stack,
|
||||
aws_apigateway as apigateway,
|
||||
aws_certificatemanager as acm,
|
||||
aws_cloudwatch as cloudwatch,
|
||||
aws_cloudwatch_actions as cw_actions,
|
||||
aws_dynamodb as dynamodb,
|
||||
|
|
@ -40,12 +39,6 @@ SHOC_BACKEND_DEV_ROLE_ARN = "arn:aws:iam::396287094661:role/shoc-backend-dev"
|
|||
|
||||
STAGE_NAME = "prod"
|
||||
|
||||
# Custom domain for the SHOC-facing read API. The seahaven.com zone is in the
|
||||
# mgmt account, so the cert is issued out of band and its ARN handed in via
|
||||
# this SSM parameter (see the custom-domain block below).
|
||||
CUSTOM_DOMAIN_NAME = "procurement-api.seahaven.com"
|
||||
CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM = "/procurement-api/custom-domain/certificate-arn"
|
||||
|
||||
|
||||
class ProcurementApiStack(Stack):
|
||||
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
|
||||
|
|
@ -287,41 +280,6 @@ class ProcurementApiStack(Stack):
|
|||
authorization_type=apigateway.AuthorizationType.NONE,
|
||||
)
|
||||
|
||||
# --- Custom domain: procurement-api.seahaven.com ---
|
||||
# Stable, brandable endpoint for the SHOC backend to sign SigV4 against
|
||||
# (replaces the opaque execute-api URL). REGIONAL to match the API, so
|
||||
# the ACM cert lives in this account+region (us-east-1).
|
||||
#
|
||||
# CROSS-ACCOUNT DNS: the seahaven.com public zone
|
||||
# (Z06652411XKH89KTZD3XA) is in the mgmt account (328440206208), not
|
||||
# here. So the cert's DNS-validation CNAME and the final A-alias record
|
||||
# are added to that zone OUT OF BAND (see the README runbook /
|
||||
# scripts/setup_procurement_api_domain.sh), and the issued cert ARN is
|
||||
# handed to this stack via SSM. Reading it with value_for_string_
|
||||
# parameter keeps the reference CFN-resolved at deploy -- no synth-time
|
||||
# AWS creds, unlike a cross-account HostedZone.from_lookup. The
|
||||
# A-alias record itself is created out of band too (the zone is not in
|
||||
# this account, so CDK cannot own it); the outputs below give the exact
|
||||
# alias target.
|
||||
cert_arn = ssm.StringParameter.value_for_string_parameter(
|
||||
self, CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM
|
||||
)
|
||||
certificate = acm.Certificate.from_certificate_arn(
|
||||
self, "ProcurementApiCertificate", cert_arn
|
||||
)
|
||||
custom_domain = apigateway.DomainName(
|
||||
self,
|
||||
"ProcurementApiDomain",
|
||||
domain_name=CUSTOM_DOMAIN_NAME,
|
||||
certificate=certificate,
|
||||
endpoint_type=apigateway.EndpointType.REGIONAL,
|
||||
security_policy=apigateway.SecurityPolicy.TLS_1_2,
|
||||
)
|
||||
# Empty base path: the custom domain root maps to the prod stage, so
|
||||
# callers hit https://procurement-api.seahaven.com/work-orders (no
|
||||
# /prod segment -- the mapping strips it).
|
||||
custom_domain.add_base_path_mapping(api, stage=api.deployment_stage)
|
||||
|
||||
# --- Alarms (ALARM-only -> site-alerts, NOT_BREACHING) ---
|
||||
# common.add_standard_lambda_alarms is NOT used here: its duration
|
||||
# threshold is a fixed 45000 ms (75% of the processors' 60 s timeout),
|
||||
|
|
@ -404,24 +362,3 @@ class ProcurementApiStack(Stack):
|
|||
value=api_fn.function_arn,
|
||||
description="ARN of the procurement-api Lambda",
|
||||
)
|
||||
cdk.CfnOutput(
|
||||
self,
|
||||
"ProcurementApiCustomDomainUrl",
|
||||
value=f"https://{CUSTOM_DOMAIN_NAME}/",
|
||||
description="procurement-api custom domain (SHOC-facing endpoint)",
|
||||
)
|
||||
# A-alias target for the mgmt-account Route53 record. Create an ALIAS A
|
||||
# record: procurement-api.seahaven.com -> this regional domain name,
|
||||
# with this hosted-zone id, EvaluateTargetHealth=false.
|
||||
cdk.CfnOutput(
|
||||
self,
|
||||
"ProcurementApiAliasTarget",
|
||||
value=custom_domain.domain_name_alias_domain_name,
|
||||
description="Route53 A-alias target (add in the mgmt seahaven.com zone)",
|
||||
)
|
||||
cdk.CfnOutput(
|
||||
self,
|
||||
"ProcurementApiAliasHostedZoneId",
|
||||
value=custom_domain.domain_name_alias_hosted_zone_id,
|
||||
description="Route53 A-alias target hosted-zone id (mgmt zone record)",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
aws-cdk-lib==2.262.1
|
||||
aws-cdk-lib==2.261.0
|
||||
constructs==10.7.1
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
#!/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}"
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
#!/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."
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"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/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -82,16 +82,7 @@ def _load_spec() -> str:
|
|||
def _base_url(event: dict) -> str | None:
|
||||
rc = event.get("requestContext") or {}
|
||||
domain, stage = rc.get("domainName"), rc.get("stage")
|
||||
if not domain:
|
||||
return None
|
||||
# A custom domain (base-path mapping) serves the stage at the root, so the
|
||||
# public URL has NO /{stage} segment; only the raw execute-api host carries
|
||||
# it. Emitting /{stage} for a custom-domain request would advertise a
|
||||
# broken server URL in the docs. All API Gateway default domains end with
|
||||
# .amazonaws.com, so check the suffix rather than a substring.
|
||||
if domain.endswith(".amazonaws.com"):
|
||||
return f"https://{domain}/{stage}" if stage else None
|
||||
return f"https://{domain}"
|
||||
return f"https://{domain}/{stage}" if domain and stage else None
|
||||
|
||||
|
||||
def _spec_for_request(event: dict) -> str:
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@
|
|||
"description": "Read API over the procurement-ingest pipelines (work orders and purchase orders). The outbound SHOC work-order webhook feed is documented under **webhooks** below.\n\nData endpoints use AWS IAM SigV4; the `/docs` and `/openapi.json` routes use a shared token. Listings are unordered, cursor-paginated scans. Endpoints tagged **x-planned** are phase 2 and currently answer `501`.\n\nThis API replaces SHOC's retired SyncController DynamoDB scan as the reconciliation and backfill path. Full detail: the repo README and `docs/shoc-webhook-contract.md`."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://procurement-api.seahaven.com",
|
||||
"description": "seahaven-prod (011934824531) custom domain — recommended."
|
||||
},
|
||||
{
|
||||
"url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod",
|
||||
"description": "seahaven-prod (011934824531)."
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.56
|
||||
boto3==1.43.53
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3==1.43.56
|
||||
boto3==1.43.53
|
||||
|
|
|
|||
|
|
@ -28,30 +28,19 @@ import urllib.request
|
|||
from http import HTTPStatus
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Required, NO default (Open SWE #0): a hardcoded fallback URL would silently
|
||||
# ship production work-order state to that endpoint if the CDK-set env var were
|
||||
# ever dropped. Unset -> deliver() fails closed (retries in order) rather than
|
||||
# defaulting anywhere. The single source of the URL is the Lambda environment
|
||||
# (CDK), confirmed by the activation PR.
|
||||
SHOC_WEBHOOK_URL = os.environ.get("SHOC_WEBHOOK_URL")
|
||||
# Endpoint path is TBD by SHOC; the activation PR confirms the final URL.
|
||||
SHOC_WEBHOOK_URL = os.environ.get(
|
||||
"SHOC_WEBHOOK_URL", "https://api.dev.seahaven.com/api/webhooks/work-orders"
|
||||
)
|
||||
HMAC_SECRET_ARN = os.environ.get("HMAC_SECRET_ARN")
|
||||
|
||||
POST_TIMEOUT_SECONDS = 10
|
||||
USER_AGENT = "workorder-shoc-emitter/1"
|
||||
|
||||
# Bound the Secrets Manager client's timeouts (Open SWE #16): default botocore
|
||||
# timeouts can run to ~60s, which combined with the 10s POST could exhaust the
|
||||
# Lambda budget and make a slow AWS call re-deliver the whole batch. Fast, few
|
||||
# retries -- the key is 300s-cached so this fetch is rare.
|
||||
_BOTO_CONFIG = Config(
|
||||
connect_timeout=3, read_timeout=5, retries={"max_attempts": 2, "mode": "standard"}
|
||||
)
|
||||
|
||||
# 2xx = delivered; 429 and 5xx retry; everything else parks (contract sec. 7).
|
||||
_HTTP_SUCCESS_RANGE = range(HTTPStatus.OK, HTTPStatus.MULTIPLE_CHOICES)
|
||||
_HTTP_SERVER_ERROR_MIN = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
|
@ -128,7 +117,7 @@ def _refresh_hmac_keys() -> list[dict]:
|
|||
"""Force a fresh Secrets Manager fetch, bypassing the TTL cache."""
|
||||
global _hmac_keys_cache, _hmac_keys_cached_at
|
||||
now = time.monotonic()
|
||||
secrets = boto3.client("secretsmanager", config=_BOTO_CONFIG)
|
||||
secrets = boto3.client("secretsmanager")
|
||||
try:
|
||||
secret = secrets.get_secret_value(SecretId=HMAC_SECRET_ARN)
|
||||
keys = json.loads(secret["SecretString"]).get("keys") or []
|
||||
|
|
@ -161,14 +150,12 @@ def deliver(envelope: dict) -> tuple[str, int]:
|
|||
Raises RetryableDeliveryError for 429/5xx/timeout/connection failures so
|
||||
the caller can block the shard (in-order retry, contract section 7).
|
||||
"""
|
||||
if not SHOC_WEBHOOK_URL or not SHOC_WEBHOOK_URL.startswith("https://"):
|
||||
# Fail closed if the URL is unset (no hardcoded fallback -- Open SWE
|
||||
# #0) or is any non-HTTPS scheme (file://, http://, ...): urllib would
|
||||
# otherwise follow it, and the HMAC only protects an HTTPS transport.
|
||||
# Retryable, so a config gap blocks the shard (visible via iterator-age)
|
||||
# rather than shipping data somewhere unintended.
|
||||
if not SHOC_WEBHOOK_URL.startswith("https://"):
|
||||
# Fail closed on any non-HTTPS scheme (file://, http://, ...): the
|
||||
# URL is operator-set env config, but urllib would happily follow
|
||||
# other schemes and the HMAC only protects an HTTPS transport.
|
||||
raise RetryableDeliveryError(
|
||||
"SHOC_WEBHOOK_URL is unset or not an https:// URL; refusing to deliver"
|
||||
"SHOC_WEBHOOK_URL is not an https:// URL; refusing to deliver"
|
||||
)
|
||||
raw_body = json.dumps(envelope).encode("utf-8")
|
||||
timestamp = int(time.time())
|
||||
|
|
|
|||
|
|
@ -36,20 +36,12 @@ import time
|
|||
import boto3
|
||||
import delivery
|
||||
import envelope
|
||||
from botocore.config import Config
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
REJECTED_QUEUE_URL = os.environ.get("REJECTED_QUEUE_URL")
|
||||
|
||||
# Bound the SQS client's timeouts (Open SWE #17): a slow SQS response while
|
||||
# parking a rejected envelope must not hang the invocation toward its 60s
|
||||
# timeout and re-deliver the whole batch.
|
||||
_BOTO_CONFIG = Config(
|
||||
connect_timeout=3, read_timeout=5, retries={"max_attempts": 2, "mode": "standard"}
|
||||
)
|
||||
|
||||
# Lazy cached SQS client. Keeps the public attribute name ``sqs`` so the test
|
||||
# monkeypatch target changes module only, not attribute name.
|
||||
sqs = None
|
||||
|
|
@ -58,7 +50,7 @@ sqs = None
|
|||
def _get_sqs():
|
||||
global sqs
|
||||
if sqs is None:
|
||||
sqs = boto3.client("sqs", config=_BOTO_CONFIG)
|
||||
sqs = boto3.client("sqs")
|
||||
return sqs
|
||||
|
||||
|
||||
|
|
@ -125,24 +117,6 @@ def handler(event, context):
|
|||
if webhook_event is None:
|
||||
skipped += 1
|
||||
continue
|
||||
# Capture the sequence number BEFORE any delivery attempt so the
|
||||
# failure paths below can never KeyError on the subscript (Open SWE
|
||||
# #9/#26 -- the catch-all exists to keep an exception from re-delivering
|
||||
# the whole batch, so it must not itself raise). SequenceNumber is
|
||||
# present on every real DynamoDB stream record; a record lacking it is
|
||||
# unmappable-to-a-checkpoint, so log and skip rather than crash.
|
||||
seq = record.get("dynamodb", {}).get("SequenceNumber")
|
||||
if seq is None:
|
||||
logger.warning(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "shoc_missing_sequence_number",
|
||||
"delivery_id": webhook_event["delivery_id"],
|
||||
}
|
||||
)
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
emitted += 1
|
||||
start = time.monotonic()
|
||||
try:
|
||||
|
|
@ -159,7 +133,11 @@ def handler(event, context):
|
|||
logger.info(_batch_summary(len(records), emitted, skipped))
|
||||
# Stop here: reporting this record's sequence number makes the ESM
|
||||
# retry from it in order; earlier successes are not re-delivered.
|
||||
return {"batchItemFailures": [{"itemIdentifier": seq}]}
|
||||
return {
|
||||
"batchItemFailures": [
|
||||
{"itemIdentifier": record["dynamodb"]["SequenceNumber"]}
|
||||
]
|
||||
}
|
||||
except Exception:
|
||||
# Catch-all so an unexpected error (e.g. an SQS park failure)
|
||||
# cannot escape and fail the WHOLE invocation -- that would make
|
||||
|
|
@ -175,6 +153,10 @@ def handler(event, context):
|
|||
)
|
||||
)
|
||||
logger.info(_batch_summary(len(records), emitted, skipped))
|
||||
return {"batchItemFailures": [{"itemIdentifier": seq}]}
|
||||
return {
|
||||
"batchItemFailures": [
|
||||
{"itemIdentifier": record["dynamodb"]["SequenceNumber"]}
|
||||
]
|
||||
}
|
||||
logger.info(_batch_summary(len(records), emitted, skipped))
|
||||
return {"batchItemFailures": []}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
boto3>=1.43.56
|
||||
boto3>=1.43.53
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
###############################################################################
|
||||
# setup_procurement_api_domain.sh
|
||||
#
|
||||
# One-time (idempotent) wiring for the procurement-api custom domain
|
||||
# (procurement-api.seahaven.com). CROSS-ACCOUNT: the API + ACM cert live in
|
||||
# seahaven-prod (011934824531), but the seahaven.com public zone lives in the
|
||||
# mgmt account (328440206208), so the cert's DNS-validation record and the
|
||||
# final A-alias are added to the mgmt zone.
|
||||
#
|
||||
# Order of operations:
|
||||
# 1. ./setup_procurement_api_domain.sh cert
|
||||
# - requests (or reuses) the ACM cert in prod, us-east-1
|
||||
# - adds its DNS-validation CNAME to the mgmt seahaven.com zone
|
||||
# - waits for ISSUED, then writes the cert ARN to prod SSM
|
||||
# (/procurement-api/custom-domain/certificate-arn)
|
||||
# 2. deploy the procurement-api stack (cdk deploy procurement-api) -- it
|
||||
# reads the SSM param and creates the API Gateway DomainName + mapping
|
||||
# 3. ./setup_procurement_api_domain.sh alias
|
||||
# - reads the stack's regional alias target from the outputs
|
||||
# - adds the A-alias (procurement-api.seahaven.com -> API GW) to the
|
||||
# mgmt zone
|
||||
#
|
||||
# Requires SSO sessions for BOTH profiles (prod for ACM/SSM, mgmt for Route53).
|
||||
###############################################################################
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="procurement-api.seahaven.com"
|
||||
REGION="us-east-1"
|
||||
PROD_PROFILE="${PROD_PROFILE:-seahaven-prod}"
|
||||
MGMT_PROFILE="${MGMT_PROFILE:-seahaven-mgmt}"
|
||||
PROD_ACCOUNT="011934824531"
|
||||
MGMT_ACCOUNT="328440206208"
|
||||
ZONE_ID="Z06652411XKH89KTZD3XA" # seahaven.com public zone, in the mgmt account
|
||||
SSM_PARAM="/procurement-api/custom-domain/certificate-arn"
|
||||
STACK_NAME="procurement-api"
|
||||
|
||||
_verify_account() {
|
||||
local profile="$1" expected="$2"
|
||||
local got
|
||||
got="$(aws sts get-caller-identity --profile "${profile}" --query Account --output text)"
|
||||
if [[ "${got}" != "${expected}" ]]; then
|
||||
echo "ERROR: profile ${profile} resolves to ${got}, expected ${expected}. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_cert() {
|
||||
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
|
||||
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
|
||||
|
||||
echo "==> Finding or requesting ACM cert for ${DOMAIN} in ${PROD_ACCOUNT}/${REGION}"
|
||||
local cert_arn
|
||||
cert_arn="$(aws acm list-certificates --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--query "CertificateSummaryList[?DomainName=='${DOMAIN}'].CertificateArn | [0]" --output text)"
|
||||
if [[ "${cert_arn}" == "None" || -z "${cert_arn}" ]]; then
|
||||
cert_arn="$(aws acm request-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--domain-name "${DOMAIN}" --validation-method DNS \
|
||||
--query CertificateArn --output text)"
|
||||
echo " requested ${cert_arn}; waiting for the validation record to populate..."
|
||||
sleep 8
|
||||
else
|
||||
echo " reusing existing ${cert_arn}"
|
||||
fi
|
||||
|
||||
echo "==> Reading the DNS-validation record"
|
||||
local rec_name rec_value
|
||||
rec_name="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--certificate-arn "${cert_arn}" \
|
||||
--query "Certificate.DomainValidationOptions[0].ResourceRecord.Name" --output text)"
|
||||
rec_value="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--certificate-arn "${cert_arn}" \
|
||||
--query "Certificate.DomainValidationOptions[0].ResourceRecord.Value" --output text)"
|
||||
|
||||
echo "==> Upserting validation CNAME in the mgmt seahaven.com zone"
|
||||
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
|
||||
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
|
||||
{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
|
||||
"Name":"${rec_name}","Type":"CNAME","TTL":300,
|
||||
"ResourceRecords":[{"Value":"${rec_value}"}]}}]}
|
||||
JSON
|
||||
)" >/dev/null
|
||||
|
||||
echo "==> Waiting for cert to reach ISSUED (can take a few minutes)"
|
||||
aws acm wait certificate-validated --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--certificate-arn "${cert_arn}"
|
||||
|
||||
echo "==> Writing cert ARN to prod SSM ${SSM_PARAM}"
|
||||
aws ssm put-parameter --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--name "${SSM_PARAM}" --type String --overwrite --value "${cert_arn}" >/dev/null
|
||||
|
||||
echo "OK: cert ISSUED and SSM param set. Now: cdk deploy ${STACK_NAME}, then '$0 alias'."
|
||||
}
|
||||
|
||||
cmd_alias() {
|
||||
_verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}"
|
||||
_verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}"
|
||||
|
||||
echo "==> Reading regional alias target from the ${STACK_NAME} stack outputs"
|
||||
local target zone
|
||||
target="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasTarget'].OutputValue | [0]" --output text)"
|
||||
zone="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \
|
||||
--stack-name "${STACK_NAME}" \
|
||||
--query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasHostedZoneId'].OutputValue | [0]" --output text)"
|
||||
if [[ -z "${target}" || "${target}" == "None" ]]; then
|
||||
echo "ERROR: no alias target output; deploy the stack first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Upserting A-alias ${DOMAIN} -> ${target} in the mgmt zone"
|
||||
aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \
|
||||
--hosted-zone-id "${ZONE_ID}" --change-batch "$(cat <<JSON
|
||||
{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
|
||||
"Name":"${DOMAIN}","Type":"A",
|
||||
"AliasTarget":{"DNSName":"${target}","HostedZoneId":"${zone}","EvaluateTargetHealth":false}}}]}
|
||||
JSON
|
||||
)" >/dev/null
|
||||
|
||||
echo "OK: A-alias set. Verify: curl -sS -o /dev/null -w '%{http_code}\\n' https://${DOMAIN}/docs (expect 401 without a token)."
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
cert) cmd_cert ;;
|
||||
alias) cmd_alias ;;
|
||||
*) echo "usage: $0 {cert|alias}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
|
@ -130,47 +130,6 @@ def test_docs_served_when_authenticated(api, monkeypatch):
|
|||
assert json.loads(spec["body"])["openapi"] == "3.1.0"
|
||||
|
||||
|
||||
def test_base_url_custom_domain_omits_stage(api):
|
||||
mod, _ = api
|
||||
# execute-api host keeps the /{stage} segment...
|
||||
execapi = {
|
||||
"requestContext": {
|
||||
"domainName": "mvul1efda2.execute-api.us-east-1.amazonaws.com",
|
||||
"stage": "prod",
|
||||
}
|
||||
}
|
||||
assert mod._base_url(execapi) == (
|
||||
"https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod"
|
||||
)
|
||||
# ...but a custom domain (base-path mapping) serves the stage at the root,
|
||||
# so no /{stage} is appended (it would advertise a broken server URL).
|
||||
custom = {
|
||||
"requestContext": {
|
||||
"domainName": "procurement-api.seahaven.com",
|
||||
"stage": "prod",
|
||||
}
|
||||
}
|
||||
assert mod._base_url(custom) == "https://procurement-api.seahaven.com"
|
||||
assert mod._base_url({}) is None
|
||||
|
||||
|
||||
def test_spec_injects_custom_domain_server(api):
|
||||
mod, _ = api
|
||||
event = {
|
||||
"requestContext": {
|
||||
"domainName": "procurement-api.seahaven.com",
|
||||
"stage": "prod",
|
||||
}
|
||||
}
|
||||
spec = json.loads(mod._spec_for_request(event))
|
||||
urls = [s["url"] for s in spec["servers"]]
|
||||
# Accessed via the custom domain, every server URL resolves to the custom
|
||||
# domain (the static entry plus the injected one), and none carry a /prod
|
||||
# suffix. Exact-equality checks (no substring-in-URL) so this reads
|
||||
# cleanly and doesn't trip CodeQL's URL-sanitization heuristic.
|
||||
assert set(urls) == {"https://procurement-api.seahaven.com"}
|
||||
|
||||
|
||||
def test_token_query_shim_synthesizes_header(api, monkeypatch):
|
||||
mod, _ = api
|
||||
seen = {}
|
||||
|
|
|
|||
|
|
@ -119,15 +119,6 @@ class _FakeResponse:
|
|||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _webhook_url(monkeypatch, delivery):
|
||||
# SHOC_WEBHOOK_URL has no default now (Open SWE #0): set it for tests that
|
||||
# exercise deliver(), which fails closed on an unset URL.
|
||||
monkeypatch.setattr(
|
||||
delivery, "SHOC_WEBHOOK_URL", "https://shoc.example/api/webhooks/work-orders"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signing_keys(monkeypatch, delivery):
|
||||
monkeypatch.setattr(delivery, "_get_hmac_keys", lambda: TEST_KEYS)
|
||||
|
|
@ -147,22 +138,6 @@ def test_2xx_is_delivered(monkeypatch, delivery, signing_keys, status):
|
|||
assert delivery.deliver(ENVELOPE) == ("delivered", status)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [None, "", "http://insecure.example/hook"])
|
||||
def test_unset_or_non_https_url_fails_closed(monkeypatch, delivery, signing_keys, url):
|
||||
# No hardcoded fallback (Open SWE #0): an unset/empty/non-https URL must
|
||||
# raise (retryable) and NOT sign or POST anything.
|
||||
monkeypatch.setattr(delivery, "SHOC_WEBHOOK_URL", url)
|
||||
called = {"opened": False}
|
||||
_patch_urlopen(
|
||||
monkeypatch,
|
||||
delivery,
|
||||
lambda request, timeout: called.__setitem__("opened", True),
|
||||
)
|
||||
with pytest.raises(delivery.RetryableDeliveryError):
|
||||
delivery.deliver(ENVELOPE)
|
||||
assert called["opened"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [429, 500, 503])
|
||||
def test_429_and_5xx_raise_retryable(monkeypatch, delivery, signing_keys, status):
|
||||
def _raise(request, timeout):
|
||||
|
|
@ -297,9 +272,7 @@ def cache_reset(monkeypatch, delivery):
|
|||
|
||||
|
||||
def _wire_cache(monkeypatch, delivery, fake, clock):
|
||||
# client() is now called with a config= kwarg (bounded timeouts), so accept
|
||||
# and ignore it.
|
||||
monkeypatch.setattr(delivery.boto3, "client", lambda service, **kwargs: fake)
|
||||
monkeypatch.setattr(delivery.boto3, "client", lambda service: fake)
|
||||
monkeypatch.setattr(delivery.time, "monotonic", lambda: clock["t"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -205,25 +205,6 @@ def test_unexpected_error_reports_record_not_whole_batch(monkeypatch, handler_mo
|
|||
assert attempted == ["evt-1", "evt-2"]
|
||||
|
||||
|
||||
def test_record_missing_sequence_number_is_skipped_not_crashed(
|
||||
monkeypatch, handler_mod
|
||||
):
|
||||
# A record that maps to an envelope but lacks SequenceNumber (unreachable
|
||||
# for real streams) must be skipped, not crash the failure paths (Open SWE
|
||||
# #9/#26). Craft one that build_event accepts but strip SequenceNumber.
|
||||
rec = _wo_record("evt-1", "999")
|
||||
del rec["dynamodb"]["SequenceNumber"]
|
||||
attempted, _ = _wire(
|
||||
monkeypatch, handler_mod, {"evt-1": "retryable", "evt-2": ("delivered", 200)}
|
||||
)
|
||||
good = _wo_record("evt-2", "1000")
|
||||
result = handler_mod.handler({"Records": [rec, good]}, None)
|
||||
# The seq-less record is skipped (never delivered); the good one delivers.
|
||||
assert result == {"batchItemFailures": []}
|
||||
assert "evt-1" not in attempted
|
||||
assert "evt-2" in attempted
|
||||
|
||||
|
||||
def test_empty_batch_returns_no_failures(monkeypatch, handler_mod):
|
||||
attempted, _ = _wire(monkeypatch, handler_mod, {})
|
||||
assert handler_mod.handler({"Records": []}, None) == {"batchItemFailures": []}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue