Skip to content

fix(aws): scope cross-account SES credentials to EMAILS_SES_* only - #85

Merged
andrei-hasna merged 3 commits into
mainfrom
rebase/pr82-ses-scoping
Jul 26, 2026
Merged

fix(aws): scope cross-account SES credentials to EMAILS_SES_* only#85
andrei-hasna merged 3 commits into
mainfrom
rebase/pr82-ses-scoping

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Supersedes #82 — that PR is CONFLICTING against main (it branched at 45584a7,
41 commits ago) and has never had a single CI check run. This is #82's commit
rebased onto 5bb126e, plus one correction described below.

What it does

emails-prod currently injects both credential name pairs from Secrets Manager
into the API task:

  • AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — repoints the container's entire
    SDK default credential chain
  • EMAILS_SES_ACCESS_KEY_ID / EMAILS_SES_SECRET_ACCESS_KEY — the scoped names the
    1.3.0 sender reads

The unscoped pair existed only so the 1.2.7 image could send without a code
change. 1.3.0 is live and its boot log confirms credentials=environment, so the
pair is redundant. This drops it, returning the default chain to the API task role.

This is a privilege reduction, not new credential handling. It adds no
credential storage, no database columns, no provider secrets, and no
encryption-at-rest requirement — it removes an over-broad credential from a task
definition. Resolves item 1 of #61.

Correction on top of #82

#82 replaced two boundary guards in deploy/aws/tests/dormant.tftest.hcl of the form

!strcontains(upper(entry.name), "ACCESS_KEY")

with !contains([<the four names this module injects today>], entry.name).

That narrows a pattern into a name allowlist, on exactly the task
definitions whose SES-blind task roles are the reason inbound mail keeps working.
Neither rewrite was needed: after the scoping change the worker and migration tasks
carry only EMAILS_DATABASE_URL, and the API carries EMAILS_DATABASE_URL plus
EMAILS_API_SIGNING_KEY when the SES ARNs are unset — so the original patterns
still pass. They are restored here, with a comment saying why they are patterns.

Demonstrated, not asserted — injecting a secret named EMAILS_S3_ACCESS_KEY_ID
into the worker task:

guard form result
restored pattern fails (SES credentials are API-only…)
#82's name allowlist passes silently

#82's new exact-set assertion on the API container's secrets block is kept —
that one is a genuine strengthening, and it is what catches a reintroduced unscoped
pair. The TypeScript test change is also kept as-is: it splits the constant into
scoped/generic and asserts the generic names are absent, which strengthens the
guard.

Verification

  • terraform init -backend=false + terraform validate: pass
  • terraform test: 24 passed, 0 failed
  • positive control — re-injecting AWS_ACCESS_KEY_ID into ses_credential_secrets
    fails 2 assertions (the exact-set assertion and the generic-name assertion), so
    the guards are live rather than vacuous
  • positive control — EMAILS_S3_ACCESS_KEY_ID on the worker fails the restored
    pattern guard (see table above)
  • full suite, clean env (temp HOME, env -u all cloud/AWS creds, EMAILS_MODE=local):
    2410 pass / 0 fail / 137 skip — identical to main
  • staged gitleaks scan: 0 findings

No version bump and no publish.

Before merging: this needs a [BREAKING] heads-up

Applying this module removes the unscoped pair, so any deployed image older than
1.3.0 loses SES sending
— those images rely on the default credential chain.
variables.tf now states the new requirement: when the cross-account SES secret
ARN inputs are set, the image must implement the scoped EMAILS_SES_* contract.

That is deployment-affecting for third-party self-hosters, so per the operating
rules it wants a [BREAKING] post to #announcements (what, blast radius, when,
rollback) before this merges. Flagging rather than merging.

Scope note

#82's title covers three follow-ups from #61 — credential scoping, a
stranded uncertain send intent, and the prod client-env session token. Only the
first is in the diff.
The stranded-intent item is an evidence investigation with
no code change; the client-env token item is not present here. Neither is a blocker
for this change, but #61 should not be closed on this PR alone.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…, a stranded uncertain row, and the prod client-env sessi
The SES credential scoping change replaced two boundary guards of the form

  !strcontains(upper(entry.name), "ACCESS_KEY")

with `!contains([<the four names this module injects today>], entry.name)`.
That narrows a pattern into an allowlist: a credential added later under
any other access-key name passes, on exactly the task definitions whose
SES-blind task roles are the reason inbound mail keeps working.

Neither rewrite was needed for the scoping change — after it, the worker
and migration tasks carry only EMAILS_DATABASE_URL, and the API carries
EMAILS_DATABASE_URL plus EMAILS_API_SIGNING_KEY when the SES ARNs are
unset, so the original patterns still hold. Restored, with a comment
saying why they are patterns.

Demonstrated: injecting a secret named EMAILS_S3_ACCESS_KEY_ID into the
worker task fails the restored guard and passes the allowlist form.

The new exact-set assertion on the API container's `secrets` block is
kept — it is a genuine strengthening, and it is what catches a
reintroduced unscoped pair.

  terraform test: 24 passed, 0 failed
Rebasing onto 1.3.2 dropped this bullet inside the released 1.3.2
section. The scoping change is not part of that release.

Note for a separate fix: 1.3.2 landed with `## [Unreleased]` emptied and
the ~10 entries that were sitting under it moved beneath the `## 1.3.2`
heading, including a BREAKING one. Those are unrelated to 1.3.2 and are
now attributed to it.
@andrei-hasna
andrei-hasna force-pushed the rebase/pr82-ses-scoping branch from 6961fac to 895e17c Compare July 26, 2026 17:52
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

Rebased onto fe61a46 (now includes #83). Clean rebase, no conflicts in the Terraform or TypeScript changes.

One fix needed on the way: rebasing onto the new 1.3.2 section put the SES scoping bullet inside the released 1.3.2 block. Moved to [Unreleased], where it belongs — this change is not part of that release.

Re-verified on the new base:

  • terraform test: 24 passed, 0 failed
  • full suite, clean env: 2489 pass / 0 fail / 138 skip — identical to main
  • staged gitleaks scan: 0 findings

Still needs the [BREAKING] heads-up to #announcements before merge (pre-1.3.0 images lose SES sending when this module is applied).

Unrelated CHANGELOG defect now on main, worth a separate fix

1.3.2 landed with ## [Unreleased] emptied and the ~10 entries that were sitting under it relocated beneath the ## 1.3.2 (2026-07-26) heading — including the BREAKING (self-hosted auth) entry about EMAILS_AUTH_ALLOWED_EMAIL_DOMAINS / EMAILS_AUTH_FROM being required. Those changes are not part of 1.3.2 and are now attributed to it, which matters because that particular entry tells self-hosters to set two variables before upgrading. Flagged on #83 before it merged; not fixing it here to keep this PR to one concern.

@andrei-hasna
andrei-hasna merged commit e97b527 into main Jul 26, 2026
4 checks passed
andrei-hasna added a commit that referenced this pull request Jul 27, 2026
…-key guards

main already carries this branch's SES credential scoping (landed as #85,
0c54ce3) and then corrected the test guards in c96b196. This merge resolves
both conflicts in favour of main:

- deploy/aws/tests/dormant.tftest.hcl: restores
  !strcontains(upper(entry.name), "ACCESS_KEY") in the worker/migration guard
  of ses_credentials_injected_by_arn_reference_only and in the API guard of
  no_ses_credentials_leaves_the_task_role_alone. The 4-name allowlist this
  branch carried let any future credential secret under a different
  access-key name pass, on exactly the task definitions whose SES-blind task
  roles keep inbound mail working.
- CHANGELOG.md: main's entry is a strict superset; no branch content is lost.

The resulting tree is byte-identical to origin/main, so this branch can no
longer regress the default branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant