AWSTemplateFormatVersion: "2010-09-09"
Description: >
  QuietBill read-only billing access + proactive CloudTrail alerts.
  Creates a Cross-Account IAM Role and CloudTrail S3 > EventBridge > SNS pipeline for expensive resource detection.
  IMPORTANT: Deploy this stack with a name starting with "QuietBill-" (e.g. QuietBill-MyAccount).
  QuietBill uses the stack name prefix for automatic detection.

  TRUST BOUNDARY: This role is read-only for YOUR infrastructure.
  Write permissions exist ONLY for managing this stack's own resources
  (self-update, self-delete). All write actions are scoped exclusively
  to QuietBill-named resources — your other IAM roles, S3 buckets,
  Lambda functions, etc. are completely untouchable.

Parameters:
  ExternalID:
    Type: String
    Description: Unique External ID provided by QuietBill (protects against Confused Deputy attacks)
    MinLength: 8
  TrustedAccountID:
    Type: String
    Description: QuietBill AWS Account ID
    AllowedPattern: "[0-9]{12}"
  WebhookURL:
    Type: String
    Description: QuietBill webhook endpoint for proactive alerts
    AllowedPattern: "https://.+"
  OnboardingSessionToken:
    Type: String
    Description: Temporary token for automatic connection (generated by QuietBill app). Leave empty for manual setup.
    Default: ""
  EnableLambdaDurableMonitoring:
    Type: String
    Description: >
      Enable monitoring of Lambda Durable Functions (checkpoint/replay events).
      Requires Lambda data events in CloudTrail — may incur additional CloudTrail charges
      (~$0.10 per 100,000 data events).
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
  EnableCostAnomalyDetection:
    Type: String
    Description: >
      Enable AWS Cost Anomaly Detection — free ML-based service that detects unusual spend patterns
      and alerts QuietBill within hours.
    Default: "true"
    AllowedValues:
      - "true"
      - "false"

Conditions:
  LambdaDurableEnabled: !Equals [!Ref EnableLambdaDurableMonitoring, "true"]
  CostAnomalyEnabled: !Equals [!Ref EnableCostAnomalyDetection, "true"]

Resources:
  QuietBillRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Retain
    Properties:
      RoleName: !Sub "QuietBillReadOnly-${ExternalID}"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              # Only the QuietBill AWS account can assume this role — no other AWS account can use it.
              AWS: !Sub "arn:aws:iam::${TrustedAccountID}:root"
            Action: "sts:AssumeRole"
            Condition:
              StringEquals:
                # External ID prevents "confused deputy" attacks — even if someone knows the role ARN,
                # they cannot assume it without your unique External ID.
                "sts:ExternalId": !Ref ExternalID
          - Effect: Allow
            Principal:
              # CloudFormation needs to assume this role as a ServiceRole during stack
              # deletion. Without this, CFN uses the caller's cross-account session
              # token which is unreliable for resource-level operations.
              Service: cloudformation.amazonaws.com
            Action: "sts:AssumeRole"
      Policies:
        - PolicyName: QuietBillCostExplorerReadOnly
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # ════════════════════════════════════════════════════════════════════
              # SECTION 1: READ-ONLY (your infrastructure — QuietBill can only look)
              # ════════════════════════════════════════════════════════════════════
              # ── Cost & Usage read-only ──
              # Allows QuietBill to read your billing data. No write/modify/delete access.
              - Effect: Allow
                Action:
                  - "ce:GetCostAndUsage"
                  - "ce:GetCostForecast"
                  - "ce:GetDimensionValues"
                  - "ce:GetTags"
                Resource: "*"
              # ── CloudWatch metrics (read-only) ──
              # Used to check if RDS, NAT Gateways, and ElastiCache are idle.
              # Cannot create/modify alarms or dashboards.
              - Effect: Allow
                Action:
                  - "cloudwatch:GetMetricData"
                  - "cloudwatch:ListMetrics"
                Resource: "*"
              # ── Idle resource detection (all read-only Describe/List calls) ──
              # Used to find idle resources costing you money. Cannot start, stop,
              # create, modify, or delete any resource.
              - Effect: Allow
                Action:
                  - "ec2:DescribeVolumes"
                  - "ec2:DescribeSnapshots"
                  - "ec2:DescribeAddresses"
                  - "ec2:DescribeInstances"
                  - "ec2:DescribeNatGateways"
                  - "rds:DescribeDBInstances"
                  - "elasticloadbalancing:DescribeLoadBalancers"
                  - "elasticloadbalancing:DescribeTargetGroups"
                  - "elasticloadbalancing:DescribeTargetHealth"
                  - "ecr:DescribeRepositories"
                  - "ecr:GetLifecyclePolicy"
                  - "ecr:DescribeImages"
                  - "logs:DescribeLogGroups"
                  - "route53:ListHostedZones"
                  - "elasticache:DescribeCacheClusters"
                  - "ecs:ListClusters"
                  - "ecs:ListServices"
                  - "ecs:DescribeClusters"
                  - "ecs:DescribeServices"
                  - "ecs:DescribeTaskDefinition"
                  - "eks:ListClusters"
                  - "eks:ListNodegroups"
                  - "eks:DescribeNodegroup"
                  - "ec2:DescribeNetworkInterfaces"
                  - "events:ListRules"
                  - "s3:ListAllMyBuckets"
                  - "s3:GetBucketLocation"
                  - "s3:GetBucketVersioning"
                  - "s3:GetBucketLifecycleConfiguration"
                  - "lambda:ListFunctions"
                  - "dynamodb:ListTables"
                  - "dynamodb:DescribeTable"
                  - "sqs:GetQueueUrl"
                  - "sqs:GetQueueAttributes"
                  - "config:DescribeConfigurationRecorders"
                  - "backup:ListBackupVaults"
                  - "backup:ListBackupPlans"
                Resource: "*"
              # ════════════════════════════════════════════════════════════════════
              # SECTION 2: SELF-MANAGEMENT (this stack's own resources ONLY)
              # These permissions let the stack update/delete itself when QuietBill
              # releases new features. EVERY action below is scoped to a specific
              # QuietBill-named resource — your other resources are untouchable.
              # ════════════════════════════════════════════════════════════════════
              # ── Self-update (scoped to THIS stack only) ──
              # Allows QuietBill to update this stack when a new template version is available.
              # Scoped exclusively to this stack — cannot touch any other
              # CloudFormation stack in your account.
              - Effect: Allow
                Action:
                  - "cloudformation:DescribeStacks"
                  - "cloudformation:ListStackResources"
                  - "cloudformation:UpdateStack"
                  - "cloudformation:UpdateTerminationProtection"
                  - "cloudformation:DeleteStack"
                  - "cloudformation:GetTemplate"
                Resource: !Sub "arn:aws:cloudformation:*:${AWS::AccountId}:stack/${AWS::StackName}/*"
              # ── IAM: self-mutate own role for template self-updates ──
              # When a new template version adds/removes permissions in this inline policy,
              # CloudFormation needs to call PutRolePolicy on the main role.
              # Without this, self-updates that modify the role WILL FAIL with AccessDenied.
              # DeleteRole is needed for stack deletion (offboarding).
              # PassRole is needed to pass this role to CFN as a ServiceRole during deletion.
              - Effect: Allow
                Action:
                  - "iam:GetRole"
                  - "iam:GetRolePolicy"
                  - "iam:PutRolePolicy"
                  - "iam:DeleteRolePolicy"
                  - "iam:DeleteRole"
                  - "iam:PassRole"
                  - "iam:TagRole"
                  - "iam:UntagRole"
                  - "iam:ListRolePolicies"
                  - "iam:ListAttachedRolePolicies"
                Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/QuietBillReadOnly-${ExternalID}"
              # ── IAM permissions for stack self-update (scoped to stack-owned roles) ──
              # Required for CloudFormation to create/update the Lambda execution role
              # during stack updates. Scoped to roles with the stack name prefix.
              - Effect: Allow
                Action:
                  - "iam:GetRole"
                  - "iam:CreateRole"
                  - "iam:DeleteRole"
                  - "iam:PutRolePolicy"
                  - "iam:DeleteRolePolicy"
                  - "iam:AttachRolePolicy"
                  - "iam:DetachRolePolicy"
                  - "iam:PassRole"
                  - "iam:TagRole"
                  - "iam:UntagRole"
                  - "iam:ListRolePolicies"
                  - "iam:ListAttachedRolePolicies"
                Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*"
              # ── Lambda permissions for stack self-update ──
              # Required for CloudFormation to create/update the onboarding Lambda function.
              - Effect: Allow
                Action:
                  - "lambda:CreateFunction"
                  - "lambda:UpdateFunctionCode"
                  - "lambda:UpdateFunctionConfiguration"
                  - "lambda:DeleteFunction"
                  - "lambda:GetFunction"
                  - "lambda:GetFunctionConfiguration"
                  - "lambda:AddPermission"
                  - "lambda:RemovePermission"
                  - "lambda:InvokeFunction"
                Resource: !Sub "arn:aws:lambda:*:${AWS::AccountId}:function:QuietBill-*"
              # ── SNS management (scoped to QuietBill topic) ──
              # Required for stack self-updates that modify the alert topic policy
              # (e.g. enabling Cost Anomaly Detection adds a new publisher principal).
              # DeleteTopic needed for stack deletion during offboarding.
              - Effect: Allow
                Action:
                  - "sns:CreateTopic"
                  - "sns:DeleteTopic"
                  - "sns:GetTopicAttributes"
                  - "sns:SetTopicAttributes"
                  - "sns:Subscribe"
                  - "sns:Unsubscribe"
                  - "sns:GetSubscriptionAttributes"
                Resource: !Sub "arn:aws:sns:*:${AWS::AccountId}:${AWS::StackName}-*"
              # ── EventBridge management (scoped to QuietBill rules) ──
              # Required for self-updates that modify event patterns or targets.
              - Effect: Allow
                Action:
                  - "events:DescribeRule"
                  - "events:PutRule"
                  - "events:DeleteRule"
                  - "events:PutTargets"
                  - "events:RemoveTargets"
                  - "events:ListTargetsByRule"
                Resource: !Sub "arn:aws:events:*:${AWS::AccountId}:rule/QuietBillCloudTrailLogObjects"
              # ── CloudTrail management (scoped to QuietBill trails) ──
              # Required for self-updates that modify trail configuration or event selectors.
              - Effect: Allow
                Action:
                  - "cloudtrail:DescribeTrails"
                  - "cloudtrail:GetTrail"
                  - "cloudtrail:UpdateTrail"
                  - "cloudtrail:PutEventSelectors"
                  - "cloudtrail:GetEventSelectors"
                  - "cloudtrail:DeleteTrail"
                  - "cloudtrail:CreateTrail"
                  - "cloudtrail:StartLogging"
                  - "cloudtrail:StopLogging"
                Resource:
                  - !Sub "arn:aws:cloudtrail:*:${AWS::AccountId}:trail/QuietBillManagementTrail"
                  - !Sub "arn:aws:cloudtrail:*:${AWS::AccountId}:trail/QuietBillLambdaDurableTrail"
              # ── S3 bucket management (scoped to trail bucket) ──
              # Required for self-updates that modify bucket lifecycle or notification config.
              # DeleteBucket needed for stack deletion (only succeeds when bucket is empty).
              - Effect: Allow
                Action:
                  - "s3:CreateBucket"
                  - "s3:DeleteBucket"
                  - "s3:GetBucketPolicy"
                  - "s3:PutBucketPolicy"
                  - "s3:DeleteBucketPolicy"
                  - "s3:PutLifecycleConfiguration"
                  - "s3:GetLifecycleConfiguration"
                  - "s3:PutBucketNotification"
                  - "s3:GetBucketNotification"
                  - "s3:PutEncryptionConfiguration"
                  - "s3:GetEncryptionConfiguration"
                  - "s3:PutBucketPublicAccessBlock"
                  - "s3:GetBucketPublicAccessBlock"
                Resource: !GetAtt QuietBillTrailBucket.Arn
              # ── Cost Anomaly Detection lifecycle ──
              # Required when the stack enables/disables the anomaly monitor.
              # CE anomaly APIs do not support resource-level permissions.
              # ce:ListTagsForResource is needed by CloudFormation to resolve !GetAtt MonitorArn.
              - Effect: Allow
                Action:
                  - "ce:CreateAnomalyMonitor"
                  - "ce:CreateAnomalySubscription"
                  - "ce:DeleteAnomalyMonitor"
                  - "ce:DeleteAnomalySubscription"
                  - "ce:GetAnomalyMonitors"
                  - "ce:GetAnomalySubscriptions"
                  - "ce:ListTagsForResource"
                  - "ce:UpdateAnomalyMonitor"
                  - "ce:UpdateAnomalySubscription"
                Resource: "*"
              # ── CloudWatch Alarm lifecycle (runtime monitoring) ──
              # Required when the stack creates/updates/deletes CloudWatch alarms.
              # Scoped to QuietBill-prefixed alarm names only.
              - Effect: Allow
                Action:
                  - "cloudwatch:PutMetricAlarm"
                  - "cloudwatch:DeleteAlarms"
                  - "cloudwatch:DescribeAlarms"
                Resource: !Sub "arn:aws:cloudwatch:*:${AWS::AccountId}:alarm:${AWS::StackName}-*"
              # ── CloudTrail log access (read-only, scoped to trail bucket) ──
              # Reads CloudTrail logs for proactive alerts (e.g. expensive resource creation).
              # Scoped to the QuietBill trail bucket only — cannot read any other S3 bucket.
              - Effect: Allow
                Action:
                  - "s3:GetObject"
                Resource: !Sub "${QuietBillTrailBucket.Arn}/AWSLogs/${AWS::AccountId}/CloudTrail/*"
              # ── S3 bucket emptying for clean stack deletion ──
              # During offboarding, QuietBill empties the trail bucket before deleting
              # the stack. Without this, CloudFormation refuses to delete non-empty buckets
              # and the stack gets stuck in DELETE_FAILED.
              - Effect: Allow
                Action:
                  - "s3:ListBucket"
                Resource: !GetAtt QuietBillTrailBucket.Arn
              - Effect: Allow
                Action:
                  - "s3:DeleteObject"
                Resource: !Sub "${QuietBillTrailBucket.Arn}/*"

  # CloudTrail Trail — REQUIRED for EventBridge to receive "AWS API Call via CloudTrail" events.
  # Without an active trail, EventBridge never sees management events (Event History alone is not enough).
  QuietBillTrailBucket:
    Type: AWS::S3::Bucket
    Properties:
      NotificationConfiguration:
        EventBridgeConfiguration:
          EventBridgeEnabled: true
      LifecycleConfiguration:
        Rules:
          # QuietBill is a near-real-time alerting tool, not a compliance archive.
          # Keep a short buffer for delivery/replay debugging, then delete the logs.
          - Id: ExpireCloudTrailLogsAfterThreeDays
            Status: Enabled
            ExpirationInDays: 3
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  QuietBillTrailBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref QuietBillTrailBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AWSCloudTrailAclCheck
            Effect: Allow
            Principal:
              Service: cloudtrail.amazonaws.com
            Action: "s3:GetBucketAcl"
            Resource: !GetAtt QuietBillTrailBucket.Arn
          - Sid: AWSCloudTrailWrite
            Effect: Allow
            Principal:
              Service: cloudtrail.amazonaws.com
            Action: "s3:PutObject"
            Resource: !Sub "${QuietBillTrailBucket.Arn}/AWSLogs/${AWS::AccountId}/*"
            Condition:
              StringEquals:
                "s3:x-amz-acl": "bucket-owner-full-control"

  QuietBillTrail:
    Type: AWS::CloudTrail::Trail
    DependsOn: QuietBillTrailBucketPolicy
    Properties:
      TrailName: QuietBillManagementTrail
      S3BucketName: !Ref QuietBillTrailBucket
      IsLogging: true
      # QuietBill promises proactive alerts across the customer's AWS account,
      # not only the region where the stack was launched.
      IsMultiRegionTrail: true
      EnableLogFileValidation: false
      # Needed for global services we already claim to monitor (Route53, CloudFront).
      IncludeGlobalServiceEvents: true
      EventSelectors:
        - ReadWriteType: WriteOnly
          IncludeManagementEvents: true

  # Optional: Lambda Durable Functions data event trail (checkpoint/replay monitoring)
  QuietBillLambdaDurableTrail:
    Type: AWS::CloudTrail::Trail
    Condition: LambdaDurableEnabled
    DependsOn: QuietBillTrailBucketPolicy
    Properties:
      TrailName: QuietBillLambdaDurableTrail
      S3BucketName: !Ref QuietBillTrailBucket
      IsLogging: true
      IsMultiRegionTrail: true
      EnableLogFileValidation: false
      # AWS requires IncludeGlobalServiceEvents: true for multi-region trails
      IncludeGlobalServiceEvents: true
      AdvancedEventSelectors:
        - Name: LambdaDurableCheckpoints
          FieldSelectors:
            - Field: eventCategory
              Equals:
                - Data
            - Field: resources.type
              Equals:
                - AWS::Lambda::Function
            - Field: eventName
              Equals:
                - CheckpointDurableExecution
                - GetDurableExecutionState

  # SNS Topic — EventBridge publishes S3 object-created notifications here, QuietBill subscribes via HTTPS
  QuietBillAlertTopic:
    Type: AWS::SNS::Topic

  # Allow EventBridge to publish to SNS
  QuietBillAlertTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !Ref QuietBillAlertTopic
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowEventBridgePublish
            Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: "sns:Publish"
            Resource: !Ref QuietBillAlertTopic
          - !If
            - CostAnomalyEnabled
            - Sid: AllowCostAnomalyPublish
              Effect: Allow
              Principal:
                Service: costalerts.amazonaws.com
              Action: "sns:Publish"
              Resource: !Ref QuietBillAlertTopic
            - !Ref "AWS::NoValue"
          - Sid: AllowCloudWatchAlarmPublish
            Effect: Allow
            Principal:
              Service: cloudwatch.amazonaws.com
            Action: "sns:Publish"
            Resource: !Ref QuietBillAlertTopic

  # HTTPS subscription — delivers events to QuietBill webhook
  QuietBillWebhookSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref QuietBillAlertTopic
      Protocol: https
      Endpoint: !Ref WebhookURL

  # EventBridge rule — fires when CloudTrail writes a new management-event log object.
  # We ingest the log file itself, which makes proactive alerts region-safe.
  QuietBillEventRule:
    Type: AWS::Events::Rule
    DependsOn: QuietBillRole
    Properties:
      Name: QuietBillCloudTrailLogObjects
      State: ENABLED
      EventPattern:
        source:
          - aws.s3
        detail-type:
          - Object Created
        detail:
          bucket:
            name:
              - !Ref QuietBillTrailBucket
          object:
            key:
              - prefix: !Sub "AWSLogs/${AWS::AccountId}/CloudTrail/"
          reason:
            - PutObject
          requester:
            - cloudtrail.amazonaws.com
      Targets:
        - Arn: !Ref QuietBillAlertTopic
          Id: QuietBillSNSTarget

  # ── Auto-connect: sends Role ARN back to QuietBill after stack creation ──
  # The Lambda fires once, POSTs the ARN to our backend, and always reports
  # SUCCESS to CloudFormation — the stack never rolls back due to our webhook
  # being unreachable.  Frontend falls back to manual ARN paste if needed.

  OnboardingLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  OnboardingCallbackFn:
    Type: AWS::Lambda::Function
    Properties:
      Description: "QuietBill onboarding callback — reports Role ARN back to QuietBill after stack creation. Stays idle after setup ($0/mo)."
      Runtime: nodejs24.x
      Architectures:
        - arm64
      Handler: index.handler
      Timeout: 30
      Role: !GetAtt OnboardingLambdaRole.Arn
      Code:
        ZipFile: |
          async function cfnRespond(event, context, status) {
            const body = JSON.stringify({
              Status: status,
              PhysicalResourceId: event.PhysicalResourceId || context.logStreamName,
              StackId: event.StackId,
              RequestId: event.RequestId,
              LogicalResourceId: event.LogicalResourceId,
              Data: {},
            });
            await fetch(event.ResponseURL, {
              method: "PUT",
              headers: { "Content-Type": "" },
              body,
              signal: AbortSignal.timeout(10000),
            });
          }

          exports.handler = async (event, context) => {
            try {
              const props = event.ResourceProperties;
              const base = props.WebhookUrl.substring(0, props.WebhookUrl.lastIndexOf("/"));

              if (event.RequestType === "Create") {
                const token = props.SessionToken || "";
                if (token) {
                  const payload = JSON.stringify({ sessionToken: token, roleArn: props.RoleArn, region: process.env.AWS_REGION, enableCostAnomalyDetection: props.EnableCostAnomalyDetection === "true" });
                  await fetch(`${base}/aws-onboarding`, {
                    method: "POST",
                    headers: { "Content-Type": "application/json" },
                    body: payload,
                    signal: AbortSignal.timeout(15000),
                  });
                }
              } else if (event.RequestType === "Delete") {
                const payload = JSON.stringify({ event: "stack-deleted", roleArn: props.RoleArn, region: process.env.AWS_REGION });
                await fetch(`${base}/aws-stack-event`, {
                  method: "POST",
                  headers: { "Content-Type": "application/json" },
                  body: payload,
                  signal: AbortSignal.timeout(15000),
                });
              }
            } catch (e) {
              console.log("Callback failed (non-fatal):", e.message);
            }
            await cfnRespond(event, context, "SUCCESS");
          };

  OnboardingAutoConnect:
    Type: Custom::QuietBillOnboarding
    DependsOn: QuietBillRole
    Properties:
      ServiceToken: !GetAtt OnboardingCallbackFn.Arn
      RoleArn: !GetAtt QuietBillRole.Arn
      SessionToken: !Ref OnboardingSessionToken
      WebhookUrl: !Ref WebhookURL
      EnableCostAnomalyDetection: !Ref EnableCostAnomalyDetection

  # ── Cost Anomaly Detection (opt-in, free AWS ML service) ──
  # Messages are published to the same SNS topic as CloudTrail alerts.
  QuietBillCostAnomalyMonitor:
    Type: AWS::CE::AnomalyMonitor
    Condition: CostAnomalyEnabled
    DependsOn: QuietBillRole
    Properties:
      MonitorName: QuietBillAnomalyMonitor
      MonitorType: DIMENSIONAL
      MonitorDimension: SERVICE

  QuietBillCostAnomalySubscription:
    Type: AWS::CE::AnomalySubscription
    Condition: CostAnomalyEnabled
    Properties:
      SubscriptionName: QuietBillAnomalyAlerts
      MonitorArnList:
        - !GetAtt QuietBillCostAnomalyMonitor.MonitorArn
      Subscribers:
        - Type: SNS
          Address: !Ref QuietBillAlertTopic
      Frequency: IMMEDIATE
      ThresholdExpression: >-
        {"Dimensions":{"Key":"ANOMALY_TOTAL_IMPACT_ABSOLUTE","MatchOptions":["GREATER_THAN_OR_EQUAL"],"Values":["5"]}}

Outputs:
  RoleArn:
    Description: Copy this ARN and paste it into QuietBill
    Value: !GetAtt QuietBillRole.Arn
  ExternalID:
    Description: Unique connection identifier (used by QuietBill for stack discovery)
    Value: !Ref ExternalID
  TemplateVersion:
    Description: QuietBill template version (used for automatic update detection)
    Value: "23"
  TemplateSourceUrl:
    Description: "Audit any update before approving — see Value for template URL"
    Value: "https://quietbill.dev/cfn-template.yaml"
