Skip to content

Design: forget stale accounts — deprovisioning lifecycle for onboarded BYOA accounts #457

Description

@scttfrdmn

Problem

Onboarding an account is one-way. There is no path by which spore.host ever concludes "this account is gone" — so the state it leaves behind is permanent, and one piece of it is a genuine security hazard.

Three places encode this, all by construction rather than oversight:

  • The account registry is append-only. spore-host/lambda/portal-phone-home/registry.go exposes PutAccount and GetAccount and nothing else — no delete, no status, no last-seen. A registration cannot be retired.
  • The reaper's account list is static config. lambda/ttl-reaper/main.go:985 derives each sweep subdomain from r.accounts, populated purely from REAPER_ROLE_ARNS. An account is swept because a human typed it into a deploy, and stops being swept because a human edited it out.
  • dns-updater persists nothing. It writes Route53 and forgets. Route53 is therefore the only durable record that an account ever existed.

Two distinct failure modes

A. Forgotten-but-still-listed. The account goes away; its ARN stays in REAPER_ROLE_ARNS. The reaper burns a failing assume-role every 10 minutes forever, and each failure increments sum.Errors (main.go:416,439) — the field that is supposed to mean investigate this. Permanent expected failures train the operator to ignore the error count.

B. Forgotten-and-never-listed — silent, and live today. Records exist under {base36}.spore.host for an account absent from REAPER_ROLE_ARNS. Nothing sweeps it, nothing errors, nothing reports it.

Concrete instance: win11-test.4zlw3a1t.spore.host13.223.189.34. Subdomain 4zlw3a1t decodes to account 390967728545, which is not in REAPER_ROLE_ARNS, has no cross-account role, and appears nowhere in any spore-host repo. The IP is back in the general us-east-1 EC2 pool and no longer responds — so this name will eventually resolve to an unrelated instance once AWS reassigns the address. Stale DNS pointing at a stranger, not merely a dead name. (Found while verifying #438; deliberately left in place.)

What can and cannot be expired

The thing you would most want a TTL on is not ours to expire. deployment/cloudformation/ttl-reaper-cross-account-role.yaml creates spawn-ttl-reaper-ec2 in the customer's account; spore.host holds only the trust relationship. We cannot delete it — only they can. An IAM role with no caller is also inert and free, so no cost pressure forces the issue.

What is ours, ranked by hazard:

Artifact Owner Hazard Wants
Route53 records under {base36}.{domain} spore.host Real — IP returns to the EC2 pool; name resolves to a stranger Shortest grace period, not the longest
Registry row (spore-portal-accounts) spore.host None (costs nothing; it is the audit trail) status + lastSeenAt, not deletion
REAPER_ROLE_ARNS entry spore.host (config) Noise — poisons Errors Auto-quiesce after sustained failure
spawn-ttl-reaper-ec2 role customer None Nothing — out of our control

Note the DNS row inverts the usual instinct: the security consequence argues for expiring records sooner, not keeping them around just in case.

The liveness signal already exists, for free

The reaper assumes every role every 10 minutes. That is already a paid-for liveness probe, and it distinguishes exactly the states that matter:

  • assume succeeds, zero managed instances for N days → dormant but reachable
  • assume fails, role not foundthe customer uninstalled

Role deletion is the natural uninstall gesture. No new API and no customer action beyond what they would already do.

Two traps that make the naive version dangerous

1. Correlated failure is indistinguishable from mass uninstall. The reaper's role ARN is …spawn-ttl-reaper-production-TTLReaperFunctionRole-ZJ84YZ2dCPei — a CloudFormation-generated physical ID. Recreating that stack changes the suffix and breaks every customer's trust policy simultaneously. A TTL that deprovisions on assume-role failure would then forget the entire customer base because we redeployed. Never act on a single failure; explicitly refuse to act when all accounts fail at once. Same instinct as the sweep's existing "never delete against a partial live set" guard (main.go:987-992).

2. We lose the ability to verify at the exact moment we would act. "Only the role is left, no other resources" requires a working assume-role to confirm — DescribeInstances is how we know the account is empty. Once the role is gone that check is impossible, so we would be deciding blind. win11-test is precisely this case: records present, emptiness unverifiable except by the weak inference that the IP stopped responding.

Proposed policy

Grace, then narrow, then never silently delete what cannot be verified.

  • Unreachable — assume-role fails for K consecutive runs while other accounts succeed: mark the registry row unreachable, stop counting it into Errors. No deletion.
  • Dormant — assume-role works and zero managed instances for N days: safe to expire that account's DNS records, because emptiness is provable.
  • Unmanaged subdomain — records under a {base36} we hold no credentials for: report, never delete. We cannot distinguish "abandoned" from "someone forgot to add this account to REAPER_ROLE_ARNS," and deleting in the second case breaks a working spore.
  • Explicit offboard — an operator/customer-initiated deprovision that may delete records, because intent is stated rather than inferred.

K and N are the open decisions. Suggest K=6 (one hour) and N=30 days, both configurable.

Suggested split

Two separable pieces; the first is worth doing regardless of how the policy lands.

  • Orphan-subdomain report in the reaper. (shipped in feat(ttl-reaper): report account subdomains with no credentials (#457) #458) One ListResourceRecordSets over the zone → distinct {base36} labels → decode (strconv.ParseUint(label, 36, 64), the inverse of dns.EncodeAccountID) → diff against the configured account set → log + count the unmanaged ones. Report-only, no deletion. Small, safe, and unit-testable without AWS in the same style as sweepableRecord. Would have surfaced 4zlw3a1t the day it appeared.
  • Registry lifecycle fields (shipped in feat(portal-phone-home): account lifecycle state machine (spawn#457) spore-host#490; cross-repo wiring to the reaper still open — see the comments) (status, lastSeenAt) + the state machine above. Touches the onboarding contract, so it deserves its own decision.

Caveat on liveness tests

Checking AWS Organizations membership is not a general staleness test. BYOA accounts belong to customers and are legitimately outside our org. AccountNotFoundException only proved staleness for 4zlw3a1t because that happened to be one of our own test accounts.


Filed from the #438 verification work; see #438 for the sweep's current safety contract.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:infraIAM, buckets, CloudFormation, releasesarea:lifecycleTTL / idle / on-complete / hibernation / reaperenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions