Files
adr-sml/docs/reference/intake-table-access-verification.md
T
Pouya LajevardiandClaude Opus 5 bd282aa47d
Build and deploy / build-and-deploy (push) Failing after 4s
feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 06:52:20 -04:00

142 lines
7.1 KiB
Markdown

# Who can read `adr-intake-submissions` — verification extract
**Why this file exists.** `/legal/privacy/` makes a statement to the public about
who can see the contents of the intake table. `AGENTS.md` §4 admits no factual
claim that cannot be traced, and `CLAUDE.md`'s R14 rule is that anything a spec
claims about must be reachable from the repository — *"if the artefact lives only
in a console, no reviewer can compare the claim against it and the claim is
unverifiable by construction."* Until this file existed, that sentence was the
one claim on the site whose subject was entirely outside the repo.
Raised by `claims-auditor` in the D20 cutover audit, 2026-09-01, finding 8.
**Provenance.** Every figure below was read from AWS on **2026-09-01** with the
commands listed at the end, run read-only as `arn:aws:iam::327082975128:user/pouya`.
No command in this file creates or changes anything. Re-run them rather than
trusting this file; it is dated for that reason.
---
## The claim being checked
`src/pages/legal/privacy.astro`, §Who can see it:
> The table is reachable by the function that writes to it and by one
> administrative account, which is mine — nobody else has access to the table.
> There is no team, no assistant and no external administrator.
## The finding: it is false
The account has an IAM group **`admins`** carrying the AWS managed policy
**`AdministratorAccess`**, and it has **two members: `pouya` and `lars`.**
`iam simulate-principal-policy` for `dynamodb:GetItem`, `dynamodb:Query` and
`dynamodb:Scan` against
`arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions`, across
all five IAM users in the account:
| principal | GetItem | Query | Scan |
|---|---|---|---|
| `user/pouya` | **allowed** | **allowed** | **allowed** |
| `user/lars` | **allowed** | **allowed** | **allowed** |
| `user/adr-sml-deploy` | implicitDeny | implicitDeny | implicitDeny |
| `user/gitea-deploy-meshkinilaw` | implicitDeny | implicitDeny | implicitDeny |
| `user/meshkini-backend-deploy` | implicitDeny | implicitDeny | implicitDeny |
`lars` holds exactly the access `pouya` holds, by the same route: membership of
`admins`. The user's own attachments are only `IAMUserChangePassword`, so the
group is the whole of it.
So the published sentence is wrong on both of its halves — a second account has
access, and it belongs to a second administrator of a shared account.
## The rest of the surface, recorded so the check is complete rather than partial
- **5 IAM users**: `adr-sml-deploy`, `gitea-deploy-meshkinilaw`, `lars`,
`meshkini-backend-deploy`, `pouya`. The three deploy users are all
`implicitDeny` above. `adr-sml-deploy`'s scope is S3 + CloudFront and touches
no table (`docs/reference/deploy-credential-verification.md`).
- **1 IAM group**: `admins``AdministratorAccess` and `Billing`, two members.
- **33 IAM roles**, 26 of them not service-linked. Two carry
`AdministratorAccess`:
`cdk-hnb659fds-cfn-exec-role-327082975128-ca-central-1` and
`…-us-east-1`. These are **AWS CDK bootstrap CloudFormation execution roles**,
assumable by CloudFormation for stack deployment. They are a real path to the
table for anyone who can deploy a CDK stack in this account — which is the two
administrators above — rather than a third party.
- **`adr-intake-lambda-role`** is the writing principal: `dynamodb:PutItem` on
this table, `ses:SendEmail`/`SendRawEmail`, plus
`AWSLambdaBasicExecutionRole`. **`PutItem` only — it cannot read the table**,
which is worth stating because it is a stronger fact than the page currently
claims and it is the part of the sentence that is true.
- **No resource-based policy on the table.** DynamoDB supports one; this table
has none, so access is governed entirely by identity policies.
- The account is **not single-project** (`AGENTS.md` §10). `lars` and the two
`meshkini*`/`gitea*` users are evidence of that on the IAM surface, not just
in the S3 bucket listing §10 describes.
## What has to happen before `/legal/privacy/` goes public
Tracked as `AGENTS.md` §9 **Q62**. It is one of two things and both are Pouya's:
1. **Remove the access** — take `lars` out of `admins`, or replace that
membership with a policy that denies DynamoDB on this table — and then this
sentence becomes true. Note the likely collision: `AGENTS.md` Q23 records the
Gitea instance as *jointly administered* and blocked on *"its second
administrator"*, so this account is probably not the only thing that access
is for.
2. **Correct the sentence** to what is true. It is a privacy policy, so the
honest version is short and specific — the number of people with
administrative access, and that the function that writes cannot read.
**Do not resolve it by softening.** "Access is limited to authorised
administrators" is the shape §4 exists to bar: defensible, uninformative, and it
would replace a false specific with a true vacancy on the one page where a reader
is entitled to the specific.
---
## Commands
Run as `user/pouya`, `ca-central-1`, all read-only. Exit status read on each; no
stderr suppressed anywhere.
```bash
aws iam list-users --query 'Users[].UserName'
aws iam list-groups --query 'Groups[].GroupName'
aws iam get-group --group-name admins --query 'Users[].UserName'
aws iam list-attached-group-policies --group-name admins --query 'AttachedPolicies[].PolicyName'
aws iam list-group-policies --group-name admins --query 'PolicyNames'
aws iam list-groups-for-user --user-name lars --query 'Groups[].GroupName'
aws iam list-attached-user-policies --user-name lars --query 'AttachedPolicies[].PolicyName'
aws iam list-user-policies --user-name lars --query 'PolicyNames'
TARN="arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions"
for U in pouya lars adr-sml-deploy gitea-deploy-meshkinilaw meshkini-backend-deploy; do
aws iam simulate-principal-policy \
--policy-source-arn "arn:aws:iam::327082975128:user/${U}" \
--action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
--resource-arns "$TARN" \
--query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text
done
# Roles: 33 total, 26 non-service-linked; screened for broad policies.
for R in $(aws iam list-roles --query 'Roles[].RoleName' --output text \
| tr '\t' '\n' | grep -v '^AWSServiceRole'); do
aws iam list-attached-role-policies --role-name "$R" \
--query 'AttachedPolicies[].PolicyName' --output text
done
aws iam get-role-policy --role-name adr-intake-lambda-role \
--policy-name adr-intake-lambda-inline --query PolicyDocument
aws iam list-attached-role-policies --role-name adr-intake-lambda-role
```
⚠️ **`simulate-principal-policy` was the tool that produced a false negative on
this project once already** — `AGENTS.md` Q22, where eight checks returned empty
because `2>/dev/null` was hiding an `InvalidInput` error caused by a zsh
parameter-expansion bug (`$ACCT:user/` parses `:u` as a history modifier). The
loop above brace-quotes `${U}` for that reason, prints one line per principal so
a silently-skipped iteration is visible as a missing row, and suppresses nothing.
**Five rows, or the run did not happen.**