Files
adr-sml/docs/reference/intake-table-access-verification.md
T
Pouya LajevardiandClaude Opus 5 6aaf089b05 feat: rule Q62 by stating the truth; strike the /med-arb/ gloss; re-stamp R18
Pouya's four rulings of 2026-09-01, applied 2026-09-02.

Q62 — RULED "state the truth", not "remove the access". /legal/privacy/
now says two people can read the intake table, names their role, and adds
the two stronger facts the false sentence had crowded out: the handler
role holds PutItem only, and adr-sml-deploy is implicitDeny on all seven
read and write actions. Wording is subject to Pouya's read-through —
Q63(a), with a TODO(pouya) beside the copy.

The ruling named one sentence; a vocabulary sweep found the falsehood in
three places, and the audit then found two more. Five paragraphs now
answer "who can see it" and change together.

The tripwire stays permanently, per ruling, and grew from two
alternatives to five. Every alternative is one string that reached dist/.
Proven both ways against the pre-correction page rebuilt from bd282aa:
exit 1 with 5 matches at dist/legal/privacy/index.html:54,67,67,68,72;
exit 0 on the corrected page, self-test 12 patterns / 36 approved
strings.

/med-arb/ — the gloss is struck with no replacement, per ruling. The
strike left "the section above" pointing at the ADRIC rule set and "the
agreement" with no antecedent; both fixed. The bare designations line
sitting under ADRIC's quoted competence requirement is also struck, which
goes beyond the ruling and is flagged for Pouya.

R18 — re-stamped, two-tier: (a)(c)(d) re-verified against a source,
(b)(e)(f)(g) held on a cadence judgement. All seven hold, no shipped
sentence changed. R18's trigger had NO cutover checklist item and had
stamped five extracts of seven; both fixed. Candidate limb (h) flagged.

R10 — fired and unsatisfied; left open on instruction.

The evidence behind the new privacy sentence was weaker than the
sentence. Re-measured: 33 of 33 roles simulated (23 of 26 carried inline
policies nobody had read; the two CDK lookup roles can read the table),
four trust policies, the CloudFormation escalation path for all five
users, 0 federated providers, root recorded. Every read path terminates
at the same two people.

Two review rounds, 36 findings. 35 fixed, 1 declined. Five of round 2's
were defects in round 1's own fixes; stopped at two per D19.

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

22 KiB
Raw Blame History

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. Figures in the ORIGINAL sections were read from AWS on 2026-09-01; everything in the 2026-09-02 addendum at the foot of this file was read on 2026-09-02, and it supersedes the original role screen. Both were run read-only as arn:aws:iam::327082975128:user/pouya with the commands listed at the end. No command in this file creates or changes anything. Re-run them rather than trusting this file; it is dated for that reason.


RULED AND APPLIED — 2026-09-02

Pouya ruled state the truth, not remove the access (§9 Q62, ruled 2026-09-01, applied 2026-09-02). Option 2 below is the one taken; option 1 was declined. lars's membership of admins is unchanged.

The shipped sentences, so this file can be compared against the live page rather than against a struck one:

Two people can. The table sits in an Amazon Web Services account that also runs systems unrelated to this practice, and that account has two administrators — me, and one other person who administers it with me. Administrative access to the account carries the ability to read the table, so both of us can read what you send.

No one outside those two people has been granted access to the table, and that is measured rather than assumed: every user and every role in the account was simulated against this table, and the only ones that come back able to read it lead to those same two people.

The function that receives the form can only add a record — it cannot read the table back. And the credential that publishes this website has no access to the table at all, for reading or for writing.

Wording is subject to Pouya's read-through approval — §9 Q63, with a TODO(pouya) beside the copy. The measurement is settled; the phrasing is not.

⚠️ AND THE ENUMERATION BELOW WAS NOT ENOUGH TO SUPPORT THE SECOND OF THOSE SENTENCES. See the addendum at the foot of this file, which is what the page actually rests on. Read it before citing the five-row table.


The claim being checked — ⚠️ STRUCK 2026-09-01, QUOTED HERE AS THE DEFECT

This is no longer on the page. It is kept because it is the string check-claims.mjs's sole-administrator-q62 pattern permanently bars, and a tripwire whose target is not recorded anywhere becomes unmaintainable.

src/pages/legal/privacy.astro, §Who can see it, as it stood at bd282aa:

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: adminsAdministratorAccess 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 had to happen before /legal/privacy/ went public — RESOLVED BY OPTION 2

Tracked as AGENTS.md §9 Q62, closed 2026-09-02 on option 2. Kept unstruck because the reasoning is what makes the ruling re-readable, and because option 1 remains live in one direction: if that access is ever actually removed, the page and the tripwire both have to change, and check-claims.mjs's rule: line carries that instruction.

  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.

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 alreadyAGENTS.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.


⚠️ ADDENDUM 2026-09-02 — THE ENUMERATION ABOVE WAS INCOMPLETE IN THREE WAYS, AND ITS CONCLUSION SURVIVES ANYWAY

Why this addendum exists. /legal/privacy/ publishes a completeness claim about who can read this table — the second of the three sentences quoted at the top of this file, under The shipped sentences. Written as it stood, this file did not support it.

(This paragraph quoted a different sentence until 2026-09-02 — round 1's pre-fix wording, "every account and role in this infrastructure…", which the audit then struck. So this file briefly quoted two different sentences as the live one, in the two halves of the same document, which defeats the comparison R14 exists for. Fixed by pointing at the block above rather than re-quoting it: one copy of a fact, in one place. adversarial-reviewer, round 2.) Under R14 the artefact is what a reviewer compares the claim against, so a claim stronger than its artefact is unverifiable by construction even when it happens to be true.

What was missing.

  1. aws iam list-role-policies was never run. The command block above lists only list-attached-role-policies, which returns managed policies. 23 of the 26 non-service-linked roles carry inline policies, and none of them had been read. The screen for "broad policies" could not have seen an inline grant.
  2. No role was ever simulated against the table. Access was inferred from policy names (AdministratorAccess) rather than measured as a decision.
  3. The five users were simulated for reads onlyGetItem, Query, Scan. So the page's "the credential that publishes this website has no access to the table at all" covered three read actions and said "at all".

What the measurement found, and it changes the role count. Simulating all 26 non-service-linked roles across seven actions — GetItem, Query, Scan, BatchGetItem, PutItem, UpdateItem, DeleteItem:

role decision on the table trust
cdk-hnb659fds-cfn-exec-role-…-ca-central-1 allowed on all 7 cloudformation.amazonaws.com only
cdk-hnb659fds-cfn-exec-role-…-us-east-1 allowed on all 7 cloudformation.amazonaws.com only
cdk-hnb659fds-lookup-role-…-ca-central-1 allowed on the 4 READS, denied on writes arn:aws:iam::327082975128:root
cdk-hnb659fds-lookup-role-…-us-east-1 allowed on the 4 READS, denied on writes arn:aws:iam::327082975128:root
adr-intake-lambda-role PutItem only; implicitDeny on the other six lambda.amazonaws.com
the other 21 implicitDeny on all 7

So FOUR roles can read the table, not the two this file recorded. The two lookup roles were missed by exactly the gap above: their grant is the inline LookupRolePolicy, and list-attached-role-policies returns nothing for them.

Why the published sentence is nevertheless correct. The question is not how many roles exist but which people they lead back to.

  • The two cfn-exec roles trust cloudformation.amazonaws.com and nothing else. No human can assume them; they are reachable only by deploying a CloudFormation/CDK stack, which requires a principal who can deploy one.
  • The two lookup roles trust the account root, which delegates the decision to the caller's own identity policy. Simulated for sts:AssumeRole against both role ARNs, for all five users, reading ResourceSpecificResults (ten per-resource decisions, not five — EvaluationResults is one entry per action, and an earlier pass here asserted the wrong count):
principal assume lookup-…-ca-central-1 assume lookup-…-us-east-1
user/pouya allowed allowed
user/lars allowed allowed
user/adr-sml-deploy implicitDeny implicitDeny
user/gitea-deploy-meshkinilaw implicitDeny implicitDeny
user/meshkini-backend-deploy implicitDeny implicitDeny
  • The CloudFormation escalation path this file named and left untested"a real path to the table for anyone who can deploy a CDK stack" — is now measured. Simulated for cloudformation:CreateStack, UpdateStack, CreateChangeSet, ExecuteChangeSet, iam:PassRole and sts:AssumeRole, five users × six actions = 30 decisions, count asserted:
principal the CDK / CloudFormation path
user/pouya allowed on all six
user/lars allowed on all six
user/adr-sml-deploy implicitDeny on all six
user/gitea-deploy-meshkinilaw implicitDeny on all six
user/meshkini-backend-deploy implicitDeny on all six

This is the finding that mattered most, because meshkini-backend-deploy is by its name another project's backend-deploy credential and gitea-deploy-meshkinilaw is held on a jointly administered Gitea instance (Q23). Either one, had it been able to drive CloudFormation, would have read the table without appearing in the five-row table above — and "Two people can" would have been wrong. Neither can.

  • And the seven roles every sweep here had excluded BY CONSTRUCTION are now measured too. Every role loop in this file filters grep -v '^AWSServiceRole', and the assertion was written as "twenty-six role rows" — so a service-linked role was outside the claim rather than inside it, which matters because a service-linked role for a backup or migration service can read table contents. All 7 (APIGateway, CloudFrontLogger, InternetMonitor, RDS, ResourceExplorer, Support, TrustedAdvisor) are implicitDeny on all seven actions — 49 decisions, count asserted [verified 2026-09-02]. So the enumeration is 33 of 33 roles, not 26 of 33, and the page's "every user and every role in the account" is now literally true. Raised by adversarial-reviewer, round 2.

  • There is no federated identity surface at all: list-saml-providers 0, list-open-id-connect-providers 0, sso-admin list-instances 0 [verified 2026-09-02]. So "every user and every role" is not leaving out a federated principal, because there is none to leave out.

Every read path therefore terminates at pouya or lars. The count of people is two, and it is now the result of an enumeration rather than of a policy name.

The account root user, recorded because an enumeration that quietly omits it is not an enumeration. Root is not an IAM user and does not appear in list-users, so it cannot be simulated and no policy constrains it — root can always read the table. Two facts bound it: get-account-summary reports AccountAccessKeysPresent: 0, so there is no programmatic root credential, and AccountMFAEnabled: 1. Root access therefore requires the root password and its MFA device. The page does not mention root and should not: an account owner's own credential is inherent to every cloud account and is not a third party who has been granted access — which is why the shipped sentence is scoped to "every user and every role" and to who has been granted access, rather than to a bare "nobody else can". Who holds the root credentials is not established in this repository; it is not a page-blocking fact, and it is noted here rather than guessed at.

Two page sentences are now supported that were not before.

  • "The function that receives the form can only add a record and cannot read the table back"adr-intake-lambda-role returns allowed for PutItem and implicitDeny for GetItem, Query, Scan, BatchGetItem, UpdateItem and DeleteItem. Previously this rested on reading the policy document; it is now the simulator's decision.
  • "the credential that publishes this website has no access to the table at all"adr-sml-deploy is implicitDeny on all seven, so "at all" now covers writes and deletes as well as reads.

And it corroborates §10 from the IAM surface. Of the 26 non-service-linked roles, 9 belong to CDK bootstrap and 14 to four unrelated production systems in the same account — which is what /legal/privacy/ now tells a reader in as many words. (Their role names were listed here until 2026-09-02 and are not any more: this is a committed file, they are another project's IAM surface, and the count carries the whole of the argument. adversarial-reviewer, round 2.)

⚠️ THE INSTRUMENT FAILED FIRST, UNIFORMLY, AND IN THE DIRECTION THAT READS AS CLEAN. The role sweep was first run as --action-names $ACTS with the seven actions in a shell variable. zsh does not word-split parameter expansions, so simulate-principal-policy received one action name — the whole string — and answered it: implicitDeny for 22 roles, and allowed for the four with a * grant, because * matches a bogus action too. Twenty-two clean rows and a plausible four. The tell was the shape of the output, not the verdict: one decision per role where there should have been seven. The fix is the assertion, not the memory — the loop now counts EvaluationResults per call and refuses a row that does not carry exactly seven, and the users' assume check counts ResourceSpecificResults and refuses a row that does not carry exactly two. CLAUDE.md records this class five times over; this is the sixth, and it is the "uniformly good" half.

Commands — the ones this addendum rests on

Read-only, run as user/pouya in ca-central-1. No stderr suppressed, exit status read on every call, and note the literal action lists: they are not in a variable, which is the whole point above.

# 1. Inline policies — the command the original block never ran.
aws iam list-roles --query 'Roles[].RoleName' --output text \
  | tr '\t' '\n' | grep -v '^AWSServiceRole' \
  | while IFS= read -r R; do
      aws iam list-role-policies --role-name "$R" --query 'PolicyNames' --output text
    done

# 2. Every non-service-linked role, seven actions, decision asserted per row.
#    The count check is what makes a broken call loud instead of clean.
aws iam simulate-principal-policy \
  --policy-source-arn "arn:aws:iam::327082975128:role/<ROLE>" \
  --action-names dynamodb:GetItem dynamodb:Query dynamodb:Scan \
                 dynamodb:BatchGetItem dynamodb:PutItem dynamodb:UpdateItem \
                 dynamodb:DeleteItem \
  --resource-arns "arn:aws:dynamodb:ca-central-1:327082975128:table/adr-intake-submissions" \
  --query 'length(EvaluationResults)' --output text    # must print 7

# 3. Trust policies of the four roles that can read.
aws iam get-role --role-name <ROLE> --query 'Role.AssumeRolePolicyDocument'

# 4. Who can assume the two lookup roles — per RESOURCE, not per action.
aws iam simulate-principal-policy \
  --policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
  --action-names sts:AssumeRole \
  --resource-arns "arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-ca-central-1" \
                  "arn:aws:iam::327082975128:role/cdk-hnb659fds-lookup-role-327082975128-us-east-1" \
  --query 'EvaluationResults[].ResourceSpecificResults[].{R:EvalResourceName,D:EvalResourceDecision}' \
  --output text                                        # must print 2 rows
# 5. The CloudFormation / CDK escalation path, per user. Six actions, literal.
aws iam simulate-principal-policy \
  --policy-source-arn "arn:aws:iam::327082975128:user/<USER>" \
  --action-names cloudformation:CreateStack cloudformation:UpdateStack \
                 cloudformation:CreateChangeSet cloudformation:ExecuteChangeSet \
                 iam:PassRole sts:AssumeRole \
  --query 'EvaluationResults[].{A:EvalActionName,D:EvalDecision}' --output text

# 6. Federated identity surfaces, and root.
aws iam list-saml-providers            --query 'length(SAMLProviderList)'
aws iam list-open-id-connect-providers --query 'length(OpenIDConnectProviderList)'
aws sso-admin list-instances           --query 'length(Instances)'
aws iam get-account-summary \
  --query 'SummaryMap.{AccessKeysPresentRoot:AccountAccessKeysPresent,MFA:AccountMFAEnabled}'

Five user rows and twenty-six role rows, or the run did not happen. And for every simulation: seven decisions per role call, six per CDK-path call, two per-resource decisions per assume call — the counts are the assertion, because a call that silently received one bogus action name answers implicitDeny and reads exactly like a clean row.