Skip to content

N-11 phase 2 · Scope the SNS topic to this install with a filter secret #101

Description

@kiwifellows

Roadmap: N-11 (Now), phase 2 of 2 · Size: M · Milestone: Self-Hosted Design Partners
Depends on: #100 (phase 1) — independent changes, but phase 1 is the cheap half and should not wait on this one.

Problem

infra/cloud-stack/stackset/sns.topic.cfn.yaml grants sns:Publish to Principal: AWS: "*" on the parent account's teemops-sns topic. The narrowing condition beside it is commented out — and could never have worked:

  • sns:Publish supports no message-content condition keys. SNS defines only sns:Endpoint and sns:Protocol, both Subscribe-only. There is nothing for message attributes or body.
  • CloudFormation's custom-resource publish carries no message attributes at all. All three captured samples in references/samples/ put the entire request in the message body.

So the commented-out sns:MessageAttributes.* line is dead on arrival twice over and should be deleted rather than revived.

Proposed approach: install-scoped filter secret

Mint a GUID once at install time — TopsInstallId — static for the life of that installation, and thread it through three places:

  1. Parent SAM stack takes it as a parameter and sets it in the TopsSubscriber filter policy.
  2. Both child templates (iam.role.child.account.cfn.yaml and iam.role.audit.account.cfn.yaml) take it as a NoEcho parameter and pass it into TopsCustomNotifier, so it lands in the message body.
  3. Quick-create URL gains &param_InstallId=<guid>.
TopsSubscriber:
  Type: AWS::SNS::Subscription
  Properties:
    FilterPolicyScope: MessageBody
    FilterPolicy:
      ResourceType: ["Custom::TeemopsPingSNS"]
      ResourceProperties:
        TopsInstallId: !Ref TopsInstallId

This upgrades the subscription filter from a shape check — worthless, since the child template is served publicly from <bucket>/templates/* — to a shared secret check. It needs no AWS credentials in the web tier, no topic-policy mutation, no per-account state, and has no ceiling.

State it honestly: this is a speed bump, not an authentication boundary. The value is shared with every account admin onboarded, travels in a URL query string, and lands as a CloudFormation parameter readable via cloudformation:DescribeStacks — a permission the TOPS role itself grants. NoEcho: true masks it in describe output and is worth setting, but does not make it secret. Net effect: raises the bar from anyone on the internet to anyone who has ever been given an onboarding link.

The three things that will bite if not designed in now

  1. Silent failure is the main risk, not the attacker. A mismatched GUID — typo, stale bookmarked link, reinstall, restore-from-backup — means SNS drops the message with no trace. The child stack then hangs until CloudFormation's custom-resource timeout (~1 hour) and rolls back with an unactionable error, and TOPS has nothing to log because the message never arrived. Same failure class CLAUDE.md warns about for scan rules. Mitigation: a quarantine subscription with the inverse filter ({"anything-but": [...]}) so filtered-out traffic is visible and alarmable.
  2. Rotation must be a list from day oneTopsInstallId: ["<current>", "<previous>"]. A non-rotating secret whose leak is install-wide is worse than one that can be rolled. Trivial now, ugly to retrofit.
  3. Persistence and restore. The GUID belongs in generated/teemops.env with the other stack outputs, must be backed up, and must never be regenerated if present. If a re-run of install.sh mints a new one, every issued onboarding link breaks and every existing account's Delete ping is silently filtered — so unlinking stops working with no error.

Unverified — check before relying on it

Payload-based filtering requires a valid JSON body. CloudFormation publishes the request as a JSON string, which should parse, but this has not been tested against a real custom-resource message. A filter that matches nothing is indistinguishable from a working integration. Test on one real account first and watch NumberOfNotificationsFilteredOut-InvalidMessageBody.

Acceptance criteria

  • Given a fresh install, when the AWS step runs, then a TopsInstallId GUID is generated, persisted to generated/teemops.env, and used in the subscription filter policy
  • Given an install that already has a TopsInstallId, when install.sh --aws-only is re-run, then the existing value is reused and no onboarding link is invalidated
  • Given a quick-create URL, when an admin opens it, then the install id is present as a NoEcho parameter and reaches TOPS in the message body
  • Given a message carrying a wrong or absent install id, when it is published, then it is delivered to the quarantine queue and does not reach teemops_main
  • Given a message carrying a correct install id, when it is published, then linking completes exactly as it does today — proven end to end against a real AWS account
  • Given the filter policy, when it is written, then it accepts a list of ids so the value can be rotated without breaking in-flight onboarding
  • The dead sns:MessageAttributes.* comment is deleted, not amended

Considered and rejected

Option Why not
Account-ID allowlist in the topic policy (AWS's own APN guidance for this pattern) Caps the product at SNS's 200-principal / 100-statement / 30 KB policy quota, and requires sns:AddPermission credentials in php-fpm — a tier that deliberately holds none today
aws:PrincipalOrgID Not rejected — complementary. Worth adding as an opt-in install parameter for the common self-hosted case, where the scanned accounts are in one AWS Organization. Filed separately if this lands first
Shape-only filter policy The child template is public, so the shape is public

Full research, sources and the fourth option (pinning the role name so the message carries no authority at all): docs/features/sns-topic-publish-authorization.md.

Source: docs/roadmap.md — N-11.

Metadata

Metadata

Assignees

No one assigned

    Labels

    roadmap:nowSequenced path to the current milestonesecuritySecurity-relevant changesize/MAbout a week

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions