fix(senderidentity): adopt provably-owned pre-tag identities, stop stranding the ledger - #908
Conversation
…randing the ledger v1.7.8 (#888) started tagging every SES identity e2a creates with e2a-managed=sender-identity-v1 and treating any untagged identity as foreign (ErrIdentityNotOwned). Every identity created before that release is untagged, so on upgrade Provision/Status hit an untagged GetEmailIdentity response, marked the domain sending_status=failed, and the ErrIdentityNotOwned handler unconditionally deleted the domain's row from sender_identity_managed_domains. Since the periodic reaper only iterates that ledger, the domain was never automatically revisited again — confirmed in production, where the ledger emptied and affected domains stayed failed indefinitely until rows were manually re-inserted. Part A: adoption. An untagged identity is now provably e2a's own, and gets tagged in place before proceeding normally, when ALL hold: - e2a has DKIM key material on file for the domain (the caller's stored selector) - SES reports the identity as BYODKIM (DkimAttributes. SigningAttributesOrigin == EXTERNAL) — only e2a's own Provision ever supplies signing key material - the installed selector (DkimAttributes.Tokens) matches e2a's stored selector for that exact domain Any other combination (no key material, an AWS-managed/Easy-DKIM identity, or a selector mismatch) still returns ErrIdentityNotOwned untouched — this is the security-critical negative case: a too-loose rule would let e2a silently take over a different application's SES identity in a shared AWS account, the exact scenario the tag exists to prevent. Adoption is permanent behavior (not a one-time migration flag), so it also self-heals a tag removed out-of-band. Implemented in SESProvider (internal/senderidentity/ses.go): canAdoptIdentity is the pure criteria check, adoptIdentity calls TagResource against the identity's ARN (built from region + the AWS account ID, resolved once via STS GetCallerIdentity since no SES read API returns one). The Provider.Status signature widens to take the caller's expected selector so it can make the same judgement Provision does. Part B: stop deleting the ledger row for a live domain. ForgetSendingIdentityManaged (internal/identity/store.go) now leaves the row alone when the domain still exists in `domains` with a non-null user_id — expressed as a NOT EXISTS guard inside the DELETE itself (not a check-then-act at the call site) because the mutation lock and the domain-delete transaction's advisory locks use different lock names and don't exclude each other. A genuinely deleted domain's row is still removed exactly as before, via FinalizeSendingIdentityTombstone in the disjoint teardown branch, which this change does not touch. Tests: canAdoptIdentity truth table; Provision/Status adopt-and-proceed on a matching selector; both still refuse a mismatched selector and an AWS-managed DKIM origin with no tag applied; an already-tagged identity is not retagged; a transient TagResource error propagates for retry instead of a false not-owned; a live owned domain's ledger row survives an ownership failure; a genuinely deleted domain's tombstone still finalizes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onfig canAdoptIdentity now refuses adoption when the identity carries a ConfigurationSetName or any Policies. Both are configuration e2a's own Provision never writes, so their presence is evidence the identity is doing something e2a didn't ask for, and adoption previously left them in place: - ConfigurationSetName controls where SES routes delivery/bounce/ complaint feedback (including recipient addresses) for sends through this identity. When delivery_feedback.ses_configuration_set is unset (self-hosts, e2a's own staging), SES falls back to the identity's own default config set, so adoption would silently hand that feedback to whoever owns the set. - An identity policy (e.g. granting ses:SendRawEmail to another AWS account) would survive adoption and keep applying. Addresses review finding 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent tightenings to canAdoptIdentity's criteria (finding 2 and finding 3 from review): Finding 2 — canAdoptIdentity's doc claimed "expectedSelector non-empty" meant "e2a has DKIM key material on file", but LoadSendingIdentityState can return a non-empty selector with a nil/empty private key (e.g. mid a domain reclaim). Adopting on the selector alone would tag an identity e2a cannot actually sign for — and the no-key branch in worker.go would then find it tagged and let Deprovision actually delete it, where pre-adoption behavior refused that delete. canAdoptIdentity now takes an explicit haveKeyMaterial bool and requires it. Provision passes len(dkimPrivateKeyDER) > 0 directly (it already has the real bytes). Status's own interface has no key bytes, so Provider.Status widens to take haveKeyMaterial too; both worker.go call sites now pass len(state.PrivateKey) > 0. Finding 3 — the existing criteria (EXTERNAL origin + selector token match) are both publicly-derivable from OSS source (dkim.SelectorForNow is a fixed monthly convention), so the match alone has no entropy. canAdoptIdentity now also requires DkimAttributes.Status == SUCCESS, which SES only reports once it has cryptographically matched the key material IT holds against the DNS TXT at <selector>._domainkey.<domain> — no new provider call needed (GetEmailIdentity already returns it). Chose strict SUCCESS with no PENDING fallback, documented in canAdoptIdentity's doc comment: every domain this feature exists to unstrand was already at e2a's own sending_status=verified before the ownership-tag regression (which itself required DKIM SUCCESS under the all-or-nothing rollup), so the incident population is SUCCESS by construction. A DNS-comparing follow-up in the provider layer is noted as a possible future hardening, not required here. Also documents canAdoptIdentity's reachability bound: adoption is only ever attempted for a domain whose ownership TXT + apex MX e2a's own verification flow already confirmed via live DNS probe (domains.verified=true gates every call site in worker.go). Tests: TestCanAdoptIdentity's truth table gains haveKeyMaterial and two new negative rows (no key material, DKIM pending/failed); TestSESProvider_StatusRefusesAdoptionWithoutKeyMaterial and TestSESProvider_StatusRefusesAdoptionWhileDkimPending pin the two findings directly against Status(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NewSESProviderFromConfig called sts:GetCallerIdentity synchronously at
construction with context.Background() (no deadline), and
cmd/e2a/main.go wraps any provider-construction error in log.Fatalf. An
STS blip, an egress allowlist covering only SES, an SCP deny, or an
IMDS hiccup then took the whole API down — for a value only adoption's
TagResource call needs (the identity ARN's account-id segment).
The account id is now resolved lazily via sync.Once on first adoption
attempt (accountIDForAdoption), and a resolution failure is non-fatal:
it degrades adoptIdentity to a wrapped ErrIdentityNotOwned ("cannot
adopt"), exactly the pre-adoption-PR behavior, while every other
Provider capability (Provision/Status/Deprovision/List for
already-owned identities) keeps working. NewSESProvider's explicit-
accountID constructor path is unaffected — it never touches STS at all.
Also: callerIdentity.Account == nil is now treated as an error
(accountIDFromCallerIdentity) instead of silently producing
arn:aws:ses:<region>::identity/<domain>.
Folds in the "successful adoption" half of finding 8 (observability):
adoptIdentity now logs on a successful TagResource.
Addresses review finding 4.
Tests: TestAccountIDFromCallerIdentity covers the nil-output/nil-Account
cases; TestSESProvider_AdoptionDegradesWhenAccountIDUnavailable proves
the degrade-to-ErrIdentityNotOwned behavior and that resolveAccountID
is invoked at most once across repeated adoption attempts;
TestSESProvider_NewSESProviderNeverResolvesAccountID pins the
explicit-accountID path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adoptIdentity returned raw AWS TagResource errors, and both call sites (Provision, Status) commented "transient/permission — retry". For a permanent error — AccessDeniedException (unmodeled by the SES v2 SDK; IAM denials arrive generically, so it's matched via smithy's APIError.ErrorCode() rather than a concrete exception type), BadRequestException (invalid ARN), or NotFoundException — that never succeeds on retry: it made the reaper return an error on EVERY hourly sweep forever (never resolving, never flagged prominently), and made the reconcile path burn its whole attempt budget before mislabeling the domain "verification timed out" — sending operators toward DNS instead of the real IAM problem. classifyAdoptionError now maps those three into a double-wrapped ErrIdentityNotOwned (the original error stays reachable via errors.Is for diagnostics) and leaves everything else — throttling, 5xx, network — untouched for the caller to retry. Both Provision's and Status's adoption path already treat ErrIdentityNotOwned as the normal "not adoptable" outcome (fail closed once, mark failed, no infinite retry-burning), so this composes with the existing control flow rather than adding a new one. Verified against the live prod IAM policy (2026-08): ses:TagResource IS allowed for e2a.dev's own runtime principal, so the catastrophic every-sweep-red variant isn't active in production today — but a self-hoster following the design doc's narrower, conditioned grant would hit exactly this. Addresses review finding 5. Tests: TestClassifyAdoptionError covers all three permanent codes plus two must-not-classify cases (throttling, an arbitrary error); TestSESProvider_ProvisionAdoptionAccessDeniedRefusesNotRetries proves the end-to-end wiring through Provision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes both store.ForgetSendingIdentityManaged calls from worker.go's ErrIdentityNotOwned handlers (reconcileProviderIdentity and syncProviderIdentityWithInspection's Provision branch). The in-query NOT EXISTS guard added by the base PR does not close the race the commit message claimed: it evaluates against the DELETING transaction's own snapshot, so if a concurrent domain-delete commits first, the guard still passes and Forget deletes the tombstone — orphaning the SES identity forever (there is no other durable handle back to it). An ownership failure is never evidence of teardown. Deleting these two calls is a simplification, not a new mechanism: FinalizeSendingIdentityTombstone (drain-window guarded, called only from the genuine-teardown branch of syncProviderIdentityWithInspection) is now the sole ledger-deletion path. The NOT EXISTS guard stays in ForgetSendingIdentityManaged's SQL as defense-in-depth — the method itself is still correct and still used nowhere in production code after this change, which is fine; removing the method entirely is out of scope for this fix. Folds in the "refused for a live domain" half of finding 8 (observability): both handlers now log an ALERT-convention line (matching reaper.go's style) before returning — otherwise a refused adoption on a live domain is a silent unbounded hourly retry (~3 SES calls/domain/hour forever, invisible until someone reads recordReaperError's own ALERT, which this path never reaches since it doesn't propagate an error). Removes TestReconcileWorker_NotOwnedForgetFailureStillFires and TestSyncWorker_NotOwnedForgetFailureStillFires, which pinned the now-removed "Forget failure must propagate" behavior. Strengthens the two retained TestXWorker_NotOwnedRetainsLedgerForLiveDomain tests by poisoning store.forgetErr: Work succeeding now proves Forget is never even invoked on this path, not just that its failure doesn't leak. Addresses review finding 6 and half of finding 8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mains FinalizeSendingIdentityTombstone only ever age-gated its DELETE (updated_at older than olderThan). "Never forget a live owned domain's row" was therefore only a property of ForgetSendingIdentityManaged, not a store-wide invariant — this method is the disjoint teardown branch's deletion path and had no equivalent guard. Concrete gap: a verified, live, owned domain with NULL dkim_selector/dkim_private_key hits the no-key branch of syncProviderIdentityWithInspection, which calls this method under the reaper's finalizeDeletion=true. Once the row aged past the drain window, it was deleted even though the domain is still live — stranding it exactly like the original ForgetSendingIdentityManaged incident, just reached via a different trigger (missing key material instead of a missing ownership tag). Adds the same NOT EXISTS (domains WHERE user_id IS NOT NULL) guard ForgetSendingIdentityManaged already uses. A domain torn down for real (DELETE FROM domains) satisfies NOT EXISTS immediately, so this is a tightening for genuine teardown, not a behavior change — confirmed by the existing TestSendingIdentityTombstoneFinalizeRespectsAge, updated to delete the domain row first so it isolates the age gate as before. Mirrors the same guard in the in-memory fakeStore (internal/senderidentity/fakestore_test.go) used by worker-level tests. Addresses review finding 7. Tests: TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain (DB-backed, internal/identity) pins the guard directly at the store layer. TestReapWorker_NoKeyMaterialLiveDomainRetainsLedger (internal/senderidentity) reproduces the concrete gap end-to-end through the reaper's no-key branch — verified to fail without the fakeStore guard and pass with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four corrections flagged as a merge blocker by the adversarial reviewer: 1. The amendment claimed "Steps 4, 6, and 7 (IAM hardening) are unaffected." Wrong in the way that matters: the doc justified the aws:ResourceTag IAM condition as closing a race "that a client-side ownership check cannot close" — but adoption now writes that tag itself, so the two controls are no longer independent the way the original phrasing implied. Rewritten to explain precisely what IAM still does (stop an untrusted OTHER principal) and what it no longer independently catches (a bug in canAdoptIdentity itself). Steps 6/7 (blue/green choreography) really are unaffected. 2. The normative bullet (~:64-70) said untagged identities "will neither update nor delete" — added "unless provably e2a's own (see canAdoptIdentity)". 3. Documented the domain-verification gate as the real bound on attacker-controlled input: adoption is only reachable once domains.verified=true, which requires a live DNS probe of BOTH the ownership TXT and an apex MX pointing at the relay — no customer can aim adoption at a domain they don't control. Added to both the amendment and canAdoptIdentity's doc comment in ses.go (prior commit) — it was load-bearing and undocumented before this. 4. Restated the existing "do not run two e2a installs sharing this tag value in one AWS account/region" constraint inside the amendment itself: adoption makes violating it silent (no error either side) and escalates it (an adopted identity becomes deletable by Deprovision where it was previously flatly protected). Also brought the amendment's adoption-criteria summary and the Verification section up to date with this batch's additions (foreign config refusal, real key material, DKIM SUCCESS, permanent-error classification, lazy/non-fatal STS, observability logging), and added a Deferred entry for the DNS-comparing hardening noted as a possible follow-up rather than required now. Addresses review finding 9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batch A revision — merge-blocking findings addressedPushed 7 commits ( 1. Refuse adoption of identities carrying foreign configuration ( 2+3. Require real key material and DKIM SUCCESS to adopt (
4. Lazy, non-fatal STS resolution ( 5. Classify permanent AWS errors as non-retryable ( 6. Stop forgetting the ledger on ownership failure ( 7. Guard 8. Adoption observability — folded into 9. Doc corrections (
Verification
New/updated tests
🤖 Generated with Claude Code |
Provider.Status carries two caller-supplied judgement inputs beyond the
domain — expectedSelector and haveKeyMaterial (the latter added by the prior
commit) — that drive canAdoptIdentity's decision in the real SES provider.
FakeProvider.Status discarded both, recording only the domain, so a call
site that regressed to passing "", a stale selector, or the wrong boolean
would still pass every worker test: the compiler cannot catch a
wrong-but-same-typed argument, and adoption would silently stop firing in
production for exactly the domains this PR exists to rescue.
StatusCalls is now []StatusCall{Domain, Selector, HaveKey} instead of
[]string. Every existing caller only used len(...) or %v formatting, so this
is a mechanical, non-breaking change.
Adds TestReconcileWorker_StatusCallCarriesRealSelectorAndKeyMaterial,
exercising reconcileProviderIdentity's real call site and asserting it
passes the store's actual selector and an accurate key-material signal, both
with and without key material on file. Also strengthens
TestSyncWorker_AlreadyVerifiedNoOp with the same assertion for the other
call site (syncProviderIdentityWithInspection's providerStatus closure).
Verified both new assertions actually fail when the call site is mutated to
pass ("", false).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guards ForgetSendingIdentityManaged and FinalizeSendingIdentityTombstone both gate their DELETE on "NOT EXISTS (domain still live and owned)". Only the retain arm (domain live) had an explicitly-named, isolated test; the DELETE arm (domain genuinely gone) was only exercised as a side effect of two other tests (TestManagedSendingIdentityLedgerSurvivesDomainDelete's tail assertion, and TestSendingIdentityTombstoneFinalizeRespectsAge's final "aged row" step, which the prior commit had to start deleting the domain up front to isolate the age gate from this guard). Verified empirically: inverting both guards to EXISTS causes all four of those existing tests to fail today, so the DELETE arm was NOT actually uncovered — but that coverage was incidental to tests written for a different purpose, and a future edit to either of those broader tests could silently drop it without anyone noticing. Adds TestForgetSendingIdentityManagedRemovesLedgerForGenuinelyDeletedDomain and TestFinalizeSendingIdentityTombstoneRemovesLedgerForGenuinelyDeletedDomain as explicit, minimal, DB-backed companions to the existing retain-arm tests (TestForgetSendingIdentityManagedRetainsLedgerForLiveOwnedDomain / TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain), so the DELETE arm has its own dedicated pin independent of any other test's setup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…convergence TestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain and TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain used to assert only that the in-memory fakeStore's own `managed` map still had an entry — bookkeeping that reverting the real store.go NOT EXISTS guard would leave completely unaffected, since these worker-level tests never touch real SQL. Both now add a phase 2: after the ownership problem resolves, drive the reaper (the actual retry mechanism — a domain sitting `failed` from an ownership issue is never revisited by another reconcile poll, only by the hourly sweep's applied-vs-incarnation mismatch) through two sweeps and assert the domain actually reaches `verified`. That's the consequence the incident cared about: a retained row buys a retry that converges, not just a surviving map entry. Also strengthens TestReapWorker_GenuineDeleteStillFinalizesTombstone, whose comment claimed to prove the teardown ran through FinalizeSendingIdentityTombstone rather than ForgetSendingIdentityManaged, but whose only assertion (the ledger map ending up empty) can't actually distinguish the two — both simply delete the same map entry in the fake. Gives fakeStore separate ForgetCalls/FinalizeTombstoneCalls recorders (it previously only had a shared forgetErr injection point) and asserts on them directly: Forget is never called, Finalize is called exactly once. The existing prov.List() assertion already carried real, independent value (proving Deprovision itself ran) and is unchanged; only the comment overstated what the ledger-emptiness check alone proved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
syncProviderIdentityWithInspection's inspect block claimed the periodic sweep "never MUTATES a healthy applied identity" and that both states it inspects are "READ-ONLY". That was true of the DB record when written, but providerStatus() calls provider.Status, which self-heals a removed ownership tag via a TagResource write (canAdoptIdentity/adoptIdentity in ses.go) whenever the identity is untagged but provably e2a's own — a provider-side mutation this same inspect block can now trigger on an otherwise "healthy" identity that merely lost its tag out-of-band. Narrows the claim to the DB record specifically and adds a CAUTION note so a future reader doesn't build on "this block never talks to SES with a write." Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adoption ARN was hardcoded to arn:aws:ses:..., which is wrong outside the commercial partition — GovCloud is aws-us-gov, China is aws-cn — and TagResource rejects an ARN built with the wrong partition as invalid, permanently failing adoption there. STS GetCallerIdentity's Arn field (arn:<partition>:sts::...) already carries the correct partition, so parse it out instead. Extends the existing lazy, non-fatal, sync.Once account-id resolution (accountIDForAdoption, renamed identityForAdoption) to resolve the partition alongside the account id from the same STS call — no extra round trip. NewSESProvider (the explicit-account-id constructor; no STS call is ever made on that path) defaults to "aws" since there's no ARN available to derive it from; every production caller goes through NewSESProviderFromConfig instead. A nil or unparseable STS Arn also degrades to "aws" rather than failing resolution outright, since Account (not Arn) is the field AWS actually guarantees on GetCallerIdentityOutput. Also renames the misleading `ststypes` alias (which actually points at aws-sdk-go-v2/service/sesv2/types, not STS) to `sestypes` throughout ses.go and ses_test.go — it sat one letter apart from the real `sts` import this PR's STS integration added, for two unrelated AWS services. Mechanical rename, no behavior change. Tests: TestIdentityFromCallerIdentity (renamed from TestAccountIDFromCallerIdentity) adds cases for a commercial, GovCloud, and China Arn, plus a nil/unparseable Arn falling back to "aws". TestSESProvider_AdoptIdentityUsesResolvedPartitionInARN proves the resolved partition reaches the actual TagResource call end-to-end, not just the pure parsing function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batch B — review polish (5 commits,
|
| Commit | Finding addressed |
|---|---|
3bb785e42 |
FakeProvider.Status now records the arguments it receives |
8a2bb7971 |
DB-level tests pinning the DELETE arm of both NOT EXISTS guards |
8c3ab1dcd |
Ownership-failure ledger tests now assert convergence, not fake bookkeeping |
c4eae8b4b |
Corrected the stale "sweep is READ-ONLY" invariant comment |
abb1faff5 |
ARN partition derived from the STS Arn instead of hardcoded arn:aws: |
Why the first one mattered most: Provider.Status now carries two caller-supplied values (expectedSelector, haveKeyMaterial) that nothing verified — the fake discarded both. A call site passing "" or the wrong boolean would have left every worker test green while adoption silently stopped firing for exactly the domains this PR exists to rescue. Both are now recorded and asserted.
On the DELETE-arm tests: only the retain arm was covered, so an inverted NOT EXISTS predicate would have passed the entire suite. Both guards now have paired retain/remove coverage.
The ststypes → sestypes rename from the review was already resolved in batch A; no separate commit.
Verification
go build ./...,go vet ./internal/senderidentity/... ./internal/identity/...— cleango test ./internal/senderidentity/...— green- All 15 sender-identity tests in
internal/identity— green across two consecutive runs, including the 4 added here - Known pre-existing failures elsewhere in
internal/identitywere reproduced on unmodifiedmainand are unrelated to this diff
Note for reviewers:
internal/identityis unreliable against a shared local Postgres —mainitself varied between 1 and 13+ failures across runs, with a different failure set each time. Several tests there use fixed fixture names and discard every setup error, so an upstream failure surfaces as a confusing nil-deref or "no rows" in an unrelated test. CI against a clean database is the arbiter. Worth a separate hygiene issue.
…ct empty account id Two independent narrow fixes from the batch C re-review: - classifyAdoptionError mapped a TagResource NotFoundException to ErrIdentityNotOwned. That call only ever fires moments after a successful GetEmailIdentity fed canAdoptIdentity, so NotFound here means the identity vanished between the two calls (a delete raced adoption), not that it is foreign. Map it to ErrIdentityNotFound instead, which callers already treat as "repair/converge" (reconcileProviderIdentity's repairMissingIdentity branch). The previous mapping produced a wrong customer-visible sending_error and a spurious domain.sending_failed webhook. - identityFromCallerIdentity guarded against a nil Account but not an empty-string one, which yields the exact malformed ARN (arn:aws:ses:<region>::identity/<domain>, missing the account id segment) the guard exists to prevent. Reject "" the same way.
…guard to require verified The guard blocked Finalize whenever a domain row existed with a non-NULL user_id, regardless of DNS-verification status. That correctly protects the incident population (verified/live/owned) and the no-key branch (also verified/live/owned), but also permanently blocks a legitimate branch: delete followed by an immediate re-register of the same domain lands a fresh, live, owned, but UNVERIFIED row before the post-drain audit runs. The old incarnation's ledger tombstone then survives forever and the hourly reaper re-sweeps it pointlessly. Tighten the NOT EXISTS predicate to require d.verified as well as d.user_id IS NOT NULL: the protected populations (verified/live/owned, including the no-key branch) stay protected, and the unverified re-register case can finalize again. Adds TestFinalizeSendingIdentityTombstoneRemovesLedgerForLiveUnverifiedDomain and updates the existing retain-arm test to explicitly VerifyDomain so it continues to isolate the now-narrower protected population.
…v1.7.8 regression
No migration re-populated ledger rows the pre-adoption v1.7.8
ErrIdentityNotOwned handler deleted via ForgetSendingIdentityManaged.
Migration 101's trigger only re-inserts on INSERT, a transition FROM
sending_status='none', or a verification_token change — none of which
a domain already sitting at `failed` (its state since the moment it
was stranded) hits again on its own. reaper.go skips any provider
identity whose domain row still exists, so a stranded domain is
invisible to BOTH reaper phases; only a customer-initiated POST
/domains/{d}/verify recovers one today.
Add migration 105, an idempotent re-run of 101's backfill scoped to
the stranded population (live, owned, sending_status='failed', no
ledger row). applied_incarnation is deliberately NULL: setting it to
the current token would make needsProvision=false, and the reaper's
non-forced inspect switch only acts on ledger status pending/verified
— a failed-status row would hit `default: return nil` and the repair
would buy nothing.
This mirrors a repair already performed by hand on one production
deployment.
Adds TestSenderIdentityStrandedDomainRepairMigrationBackfillsForgottenLedgerRows,
which models a stranded domain and an already-ledgered control domain
and proves the migration repairs the former while leaving the latter
untouched, applied twice to also confirm ON CONFLICT DO NOTHING makes
a manual re-run safe.
…diagnosable, guard non-production adoption
Five related findings from the batch C re-review, implemented together
since they all touch canAdoptIdentity/Provision/Status:
1. sync.Once cached the FIRST identityForAdoption result forever,
success or failure. The cached ctx belongs to the first caller (a
River job); jobs.go sets no JobTimeout so River's 1-minute default
applies, and reapManagedIdentityPage's up-to-25-domain pages with
several SES round trips each can land the deadline mid-STS-call on
the largest post-upgrade sweep. A sync.Once then caches
DeadlineExceeded FOREVER: every later adoption returns
ErrIdentityNotOwned, every owned-but-untagged domain flips to
failed, and a domain.sending_failed webhook fires per customer —
recoverable only by a restart. Same failure shape this PR exists to
fix, one layer down. Fixed: resolveIdentity now runs on
context.WithoutCancel(ctx) with its own 10s timeout, and the cache
is a mutex, not sync.Once — only success is cached permanently; a
failure is cached for a 30s cooldown, then retried by the next
caller (adoption is already ~hourly-rate-limited, so this isn't
hammering STS).
2. worker.go's two ALERT log lines logged only a constant reason
string, discarding the actual wrapped error (classifyAdoptionError's
detail, or canAdoptIdentity's now-added refusal reason). A missing
IAM grant, a wrong-partition ARN, DKIM still PENDING, a selector
mismatch, and a genuinely foreign identity all logged identically.
Fixed: canAdoptIdentity now returns (ok bool, reason string);
Provision/Status wrap the reason into the returned ErrIdentityNotOwned;
both ALERT lines now log %v of the real error alongside the constant
summary. The customer-visible sending_error wording is unchanged.
4. Added a missing negative-arm test: TestSyncWorker_AlreadyVerifiedNoOp
only ever exercised HaveKey=true at the healthy-recheck provider.Status
call site in syncProviderIdentityWithInspection. Mutation-testing
confirmed hardcoding `true` there leaves the whole suite green — the
exact "tag an identity e2a cannot sign for, then let Deprovision
delete it" path. New test
TestSyncWorker_AlreadyVerifiedNoOpCarriesRealKeyMaterialSignal seeds
no key material and asserts HaveKey:false; verified it actually
catches the mutation before landing it.
5. Provider.Status(ctx, domain, expectedSelector string, haveKeyMaterial
bool) replaced with Status(ctx, domain string, evidence
AdoptionEvidence) — the two booleans existed only to feed one joint
decision and had to be kept consistent by convention. AdoptionEvidence
is built by one helper (adoptionEvidence in worker.go) shared by both
provider.Status call sites.
10. Documented AND closed the reachability-bound gap: canAdoptIdentity's
"no customer can aim adoption at a domain they don't control" bound
depends on domain-verification's DNS probe actually running, but
checkDomainRecords short-circuits to found for every domain when
!production. Added SESProvider.refuseAdoption, set from
NewSESProviderFromConfig's new production parameter (cfg.IsProduction()
in main.go): when true, Provision/Status refuse to adopt any untagged
identity outright, regardless of what canAdoptIdentity would otherwise
conclude. Zero value (false) preserves every existing test's behavior.
11. The no-key branch in syncProviderIdentityWithInspection re-runs hourly
forever for a live/owned/verified domain with NULL DKIM key material,
with no log line at all — invisible to operators. Added an ALERT log
matching the two ErrIdentityNotOwned branches' convention, noting it
recurs hourly so it doesn't read as a fresh incident each sweep.
Also carries item 9 (already committed separately would duplicate;
folded here): corrected the doc overclaim that DKIM SUCCESS proves SES
matched "e2a's key" — it proves SES matched whatever key IT holds,
which for a foreign BYODKIM identity is that app's key, not e2a's.
Updated in both ses.go's canAdoptIdentity doc and the design doc.
Updates docs/design/sender-identity-mailfrom.md throughout to match:
the two-return canAdoptIdentity contract, AdoptionEvidence, the cache-
only-success account-id contract, the non-production adoption guard,
and migration 105.
…r.Status signature Trailing cleanup from the AdoptionEvidence refactor — a test doc comment still described the pre-refactor 4-arg provider.Status call.
Batch C revision (5 new commits,
|
* chore: go mod tidy (smithy-go is a direct import)
github.com/aws/smithy-go is imported directly by internal/senderidentity/ses.go
(for smithy.APIError) but was still listed // indirect in go.mod. go mod tidy
moves it to the direct require block; no other changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(senderidentity): make fakestore_test.go gofmt-clean
Two doc comments (immediately preceding a func declaration) wrote SQL's
empty-string literal as two adjacent straight single quotes ('') Go's
doc-comment reformatter collapses that specific adjacent pair into a single
Unicode right-double-quote character, which is what gofmt -l was flagging.
Switch to double quotes (""), which the reformatter leaves alone, so the
comment still reads as the intended SQL literal instead of a mangled
character. Confirmed unrelated to main: the same gofmt run on main shows
several pre-existing non-clean files elsewhere in the repo that this PR does
not touch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(senderidentity): use %w consistently when wrapping the account-id error
adoptIdentity's account-id-unavailable wrap used %w for ErrIdentityNotOwned
but %v for the underlying resolveIdentity error, making that error
unreachable via errors.Is/As — unlike classifyAdoptionError's deliberate
double-%w a few lines below, which exists specifically so both the sentinel
and the original error stay reachable. Match that idiom here too.
Confirmed no test or call site depends on the original error being
unreachable at this call site: TestSESProvider_AdoptionDegradesWhenAccountIDUnavailable
and TestSESProvider_AccountIDResolutionCachesOnlySuccess only assert
errors.Is(err, ErrIdentityNotOwned), never against the wrapped resolver
error, and both still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(senderidentity): fix DKIM-status gate overclaim in 3 adoption stubs
TestSESProvider_ProvisionRefusesForeignConfiguration (both subtests) and
TestSESProvider_StatusRefusesMismatchedSelector built their DkimAttributes
without Status: DkimStatusSuccess, so canAdoptIdentity's DKIM-SUCCESS gate
(checked before the selector match, and — for these three stubs — before
the foreign-configuration/selector guard the test names claim to cover) was
what actually refused adoption in each case; the guard the test name
advertises was never exercised. TestCanAdoptIdentity's truth table already
covers each guard directly, so this was an overclaim, not a coverage hole.
Add Status: sestypes.DkimStatusSuccess to all three stubs so each now fails
for the reason its name states. Verified by mutation: temporarily disabling
the foreign-configuration guard failed
TestSESProvider_ProvisionRefusesForeignConfiguration (both subtests), and
temporarily disabling the selector-match guard failed
TestSESProvider_StatusRefusesMismatchedSelector; both guards restored
afterwards and the full internal/senderidentity suite is green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(senderidentity): correct TestReapWorker_GenuineDeleteStillFinalizesTombstone's doc comment
The comment claimed the ForgetCalls/FinalizeTombstoneCalls assertion
disambiguates two live call sites (deletion vs. ownership-failure) inside
syncProviderIdentityWithInspection. ForgetSendingIdentityManaged has zero
production callers (both were removed by #908), so there is only one live
deletion path here — the ForgetCalls check is defense-in-depth, not evidence
of picking between two branches that both actually run. The
FinalizeTombstoneCalls assertion and the independent prov.List() evidence
still carry their original value; only the comment's framing changes, not
the test's assertions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(senderidentity): fix stale 'Forget' references in worker.go comments
Three (plus one analogous) comments illustrated 'fire before the error
check' rationale with call sites that no longer exist or no longer apply,
left over from #908 removing both ErrIdentityNotOwned handlers' calls to
ForgetSendingIdentityManaged:
- reconcileProviderIdentity's closing comment cited '(e.g. the ledger Forget
after a not-owned failure)' as the example of a later store call that
could fail — that call site is gone, and no later store call remains in
this function's ownership-failure branch to serve as a replacement
example, so the stale example is dropped and the general rule kept.
- The no-key branch's 'out before Forget' now precedes
FinalizeSendingIdentityTombstone, not Forget.
- syncProviderIdentityWithInspection's provision-branch comment claimed
'same rule as the two branches above' (the two ErrIdentityNotOwned
branches) — those branches no longer have any store call following their
out-assignment, so there's nothing left to draw the comparison to; the
comparison is dropped and 'Forget/MarkApplied' corrected to the calls that
actually follow (FinalizeSendingIdentityTombstone or
MarkSendingIdentityApplied).
- The function's closing comment had the same stale '(e.g. the ledger
Forget)' example; corrected the same way.
No behavior change - comments only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(identity,senderidentity): document ForgetSendingIdentityManaged as intentionally-retained dead code
After #908 removed both ErrIdentityNotOwned call sites in
internal/senderidentity/worker.go, ForgetSendingIdentityManaged has zero
non-test callers repo-wide. Its doc comment still claimed "Only the
ErrIdentityNotOwned handlers in internal/senderidentity/worker.go call
this" - a relationship that no longer holds.
Evaluated removing the method, its Store interface entry, the storeAdapter
shim, and the two fakes (folding its retain/delete DB test coverage into
FinalizeSendingIdentityTombstone's equivalents, which already exist:
TestFinalizeSendingIdentityTombstoneRetainsLedgerForLiveOwnedDomain and
TestFinalizeSendingIdentityTombstoneRemovesLedgerForGenuinelyDeletedDomain
in internal/identity/sender_identity_lock_test.go already pin the same
retain/delete invariant for Finalize, so the DB-test coverage question is
moot either way). Chose to keep it instead: removal would also require
rewriting several rationale comments in internal/senderidentity/worker.go's
two former call sites (which cite this method's guard by name while
explaining why they must never call it) and restructuring three
purpose-built regression tests in internal/senderidentity/worker_test.go
(TestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain,
TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain,
TestReapWorker_GenuineDeleteStillFinalizesTombstone) that poison a fake
implementation of this exact method and assert zero calls to it, existing
specifically to catch a reintroduced call. That's a larger, riskier change
than this method's own dead-code status warrants in a hygiene-only PR, and
risks silently weakening tests that were deliberately designed as
defense-in-depth against reintroducing the original incident.
Rewrote the doc comment on *Store.ForgetSendingIdentityManaged instead to
state plainly: it has no current callers, why it's kept anyway, and that
any future caller must not be an ownership-failure branch. Added a matching
one-line pointer on the Store interface entry in worker.go. No behavior
change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(design): fix drift in sender-identity-mailfrom.md
- accountIDFromCallerIdentity was already renamed to
identityFromCallerIdentity repo-wide (no occurrences of the old name
remain) - nothing to fix there, confirmed by grep.
- "deliberately out of scope for the BATCH A fix" used an internal
review-batch label meaningless in a permanent doc; reworded to
"deliberately out of scope for the adoption work above."
- The step-4 IAM-hardening paragraph narrated its own revision history ("An
earlier draft of this amendment claimed... that framing is now only half
true... the way the original doc implied... the way the original phrasing
suggested"); rewritten to state the current position directly, same
substance.
- Reconciled the numbered "Upgrading an existing installation" list with the
amendment above it, which described automatic per-domain adoption but
never fed back into the steps themselves:
- Added a lead-in before step 1 noting steps 1-3 (the original manual
export/review/tag pass) are no longer required for anything
canAdoptIdentity adopts automatically, and are now only useful for
pre-emptive tagging or exclusion before the IAM lockdown in step 4.
- Step 5 previously said an untagged legacy identity at that point "fails
closed... audit and tag it explicitly" - actively wrong now that
automatic adoption already tags matching identities inline. Rewrote it
to explain that a remaining untagged identity is now either genuinely
foreign or simply hasn't had a poll cycle yet, and to point at the
ALERT log line that distinguishes which.
- Added the ARN-partition derivation (arnPartition / identityFromCallerIdentity's
partition handling) to the Verification section, next to the account-id
resolution paragraph it belongs beside; it had unit coverage
(TestIdentityFromCallerIdentity,
TestSESProvider_AdoptIdentityUsesResolvedPartitionInARN) that the doc
never mentioned.
No code changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jace <jace@team.tokencanopy.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
v1.7.8 (#888) introduced an ownership tag (
e2a-managed=sender-identity-v1) on every SES sending identity e2a creates, and started treating any identity lacking that tag as foreign (ErrIdentityNotOwned, never mutated). Every identity created before that release is untagged, so on upgradeProvision/Statushit an untaggedGetEmailIdentityresponse, marked the domainsending_status=failed, and theErrIdentityNotOwnedhandler unconditionallyDELETEd the domain's row fromsender_identity_managed_domains. The periodic reaper only iterates that ledger, so the domain was never automatically revisited again. This was confirmed in production: the ledger emptied and affected domains stayedfailedindefinitely until rows were manually re-inserted.This PR fixes both halves.
Part A — adoption. An untagged identity is now provably e2a's own, and gets tagged in place before proceeding normally, when all of the following hold:
DkimAttributes.SigningAttributesOrigin == EXTERNAL) — only e2a's ownProvisionever supplies signing key materialDkimAttributes.Tokens) matches e2a's stored selector for that exact domainAny other combination — no key material, an AWS-managed/Easy-DKIM identity, or a selector mismatch — still returns
ErrIdentityNotOwneduntouched, exactly as before adoption existed. This negative case is security-critical: a too-loose rule would let e2a silently take over a different application's SES identity in a shared AWS account — the exact scenario the tag was introduced to prevent for self-hosters. Adoption is permanent behavior (not a one-time migration flag), so it also self-heals a tag that was removed out-of-band, which the existing code comments already flagged as a concern.Implemented in
SESProvider(internal/senderidentity/ses.go):canAdoptIdentity— the pure criteria checkadoptIdentity— callsTagResourceagainst the identity's ARN, built from region + the AWS account ID (resolved once via STSGetCallerIdentityat provider construction, since neitherGetEmailIdentitynorListEmailIdentitiesreturns an ARN)Provider.Statuswidens toStatus(ctx, domain, expectedSelector string)so it can make the same adoption judgementProvisiondoes — the worker already holds the selector (state.Selector) at both call sitesPart B — stop deleting the ledger row for a live domain.
ForgetSendingIdentityManaged(internal/identity/store.go) now leaves the row alone when the domain still exists indomainswith a non-nulluser_id, expressed as aNOT EXISTSguard inside theDELETEitself rather than a check-then-act at the call site —WithSendingIdentityMutationLock(held by bothErrIdentityNotOwnedhandlers inworker.go) and the domain-delete transaction's advisory locks use different lock names and don't exclude each other, so a separate existence check beforehand could race a concurrent domain delete. A genuinely deleted domain's row is still removed exactly as before, viaFinalizeSendingIdentityTombstonein the disjoint teardown branch (not touched by this change) — verified by both an existing DB test (TestManagedSendingIdentityLedgerSurvivesDomainDelete) and a new worker-level one.Correcting the task description
The background brief said
ErrIdentityNotOwnedhandling lives "around lines 283 and 597" ofworker.go— the actual call sites (onorigin/mainat the time of this branch) are lines 294 and 603 (store.ForgetSendingIdentityManaged(...)), a few lines off but the same two handlers. Everything else in the brief (the tag constants,ErrIdentityNotOwnedsemantics, theTokensfield carrying the BYODKIM selector for anEXTERNAL-origin identity, the ARN shapearn:aws:ses:<region>:<account>:identity/<domain>) checked out against the code and the AWS SDK types as described.Client surface checklist
Not applicable — this is internal SES-provisioning/reconciliation logic with no API, SDK, CLI, or MCP surface.
Operational risk
SESProvidernow callssts:GetCallerIdentityonce at construction (NewSESProviderFromConfig) to build the ARNTagResourceneeds. This is an unauthenticated-by-IAM-policy STS action (no permission grant required) that every valid AWS principal can call, so this should not newly fail in any environment where SES itself already works — but it is a new failure mode on the sender-identity startup path (alreadylog.Fatalfon any provider-construction error, unchanged behavior class).ses:TagResource, against a specific identity ARN. If the runtime IAM policy is scoped tightly (per the existing design doc's IAM guidance), it must already allowTagResourcefor the tagged-CreateEmailIdentitydependent-authorization requirement from the original v1.7.8 rollout — this PR doesn't need a new grant beyond what that doc already recommends.ForgetSendingIdentityManaged's query changes shape but stays a single indexedDELETE.Test plan
go build ./...go test ./internal/senderidentity/... ./internal/identity/...— all pass, including new adoption + ledger-retention testsgo test ./...— full suite green (EXIT:0, noFAIL)go vet ./...— clean except pre-existing, unrelated findings ininternal/agent/*_test.go(confirmed identical on a cleanorigin/maincheckout, not touched by this PR)New tests (table-driven, following
internal/senderidentity/*_test.goconventions):TestCanAdoptIdentity— the adoption criteria truth tableTestSESProvider_ProvisionAdoptsProvablyOwnIdentity/_StatusAdoptsProvablyOwnIdentity— matching selector + EXTERNAL origin adopts, tags, and proceeds normallyTestSESProvider_ProvisionRefusesMismatchedSelector/_StatusRefusesMismatchedSelector— the security-critical negative: mismatched selector staysErrIdentityNotOwned, no tag applied, no mutationTestSESProvider_ProvisionRefusesAWSManagedDkimOrigin— non-EXTERNALorigin stays refused even with a coincidentally matching tokenTestSESProvider_ProvisionAlreadyTaggedDoesNotRetag— no redundantTagResourcecallTestSESProvider_ProvisionAdoptionTagFailurePropagates— a transientTagResourceerror propagates for retry rather than falling back to a false not-ownedTestReconcileWorker_NotOwnedRetainsLedgerForLiveDomain/TestSyncWorker_NotOwnedRetainsLedgerForLiveDomain— ledger row survives an ownership failure on a live domainTestReapWorker_GenuineDeleteStillFinalizesTombstone— genuinely deleted domain's tombstone still finalizesTestForgetSendingIdentityManagedRetainsLedgerForLiveOwnedDomain(DB-backed,internal/identity) — the store-level guard directly🤖 Generated with Claude Code