AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CostPulse - cost monitoring setup (template version 2).
  Creates a read-only IAM role so CostPulse can read your AWS cost data, and
  (optionally) provisions the Cost & Usage Report 2.0 export it reads from.
  No write access to any of your resources is granted. Only billing and cost
  metadata is accessible.

# ==============================================================================
# IMPORTANT - deploy this stack in us-east-1, in your AWS Organizations
# management (payer) account.
#
#   * AWS delivers billing data only to the payer account.
#   * The Data Exports (CUR 2.0) API and its CloudFormation resource are only
#     available in us-east-1.
#
# The "Launch stack" button in the CostPulse UI already selects us-east-1 for
# you. If you are deploying by hand, pick us-east-1 in the region selector.
# ==============================================================================

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: CostPulse connection (pre-filled - do not change)
        Parameters:
          - ExternalId
          - CostPulsePrincipalArn
      - Label:
          default: Cost & Usage Report
        Parameters:
          - CurSetupMode
          - ExistingCurBucketName
          - ExistingCurPrefix
    ParameterLabels:
      CurSetupMode:
        default: How should CostPulse get your cost data?
      ExistingCurBucketName:
        default: Existing CUR bucket name (only if reusing an export)
      ExistingCurPrefix:
        default: Existing CUR prefix (only if reusing an export)

# -- Parameters ----------------------------------------------------------------

Parameters:
  ExternalId:
    Type: String
    MinLength: 8
    MaxLength: 128
    Description: >
      Your account's unique token. Find it at https://costpulse.io/connect/aws
      (sign in first; Marketplace buyers see it right after their subscription
      links). Required - it prevents confused deputy attacks where another AWS
      customer could trick CostPulse into reading your billing data. It is
      specific to your CostPulse account and cannot be guessed or shared.

  CostPulsePrincipalArn:
    Type: String
    Default: 'arn:aws:iam::922981237283:root'
    Description: >
      CostPulse AWS account principal. Pre-filled - do not change unless
      instructed by CostPulse support.

  CurSetupMode:
    Type: String
    Default: CreateNew
    AllowedValues:
      - CreateNew
      - UseExisting
    Description: >
      CreateNew (recommended) - this stack creates a dedicated S3 bucket and a
      CUR 2.0 export for CostPulse. Nothing else to configure.
      UseExisting - point CostPulse at a billing export you already have: a
      CUR 2.0 or a FOCUS 1.x export (both created under Billing -> Data
      Exports). Legacy Cost & Usage Reports use a different column format.

  ExistingCurBucketName:
    Type: String
    Default: ''
    Description: >
      Only used when CurSetupMode = UseExisting. The S3 bucket where AWS
      already delivers your CUR 2.0 or FOCUS export. Leave blank when creating
      a new one.

  ExistingCurPrefix:
    Type: String
    Default: ''
    Description: >
      Only used when CurSetupMode = UseExisting. The S3 key prefix for your
      existing export (e.g. "cur"). Leave blank if it is at the bucket root.

# -- Conditions ----------------------------------------------------------------

Conditions:
  CreateNewCur: !Equals [!Ref CurSetupMode, 'CreateNew']
  UseExistingCur: !Equals [!Ref CurSetupMode, 'UseExisting']
  HasExistingPrefix: !And
    - !Condition UseExistingCur
    - !Not [!Equals [!Ref ExistingCurPrefix, '']]

# -- Rules ---------------------------------------------------------------------
# Fail fast at stack-creation time rather than silently delivering no data.

Rules:
  ExistingBucketRequired:
    RuleCondition: !Equals [!Ref CurSetupMode, 'UseExisting']
    Assertions:
      - Assert: !Not [!Equals [!Ref ExistingCurBucketName, '']]
        AssertDescription: >
          ExistingCurBucketName is required when CurSetupMode is UseExisting.

# -- Resources -----------------------------------------------------------------

Resources:

  # -- CUR 2.0 export (only when CurSetupMode = CreateNew) ---------------------

  CurBucket:
    Type: AWS::S3::Bucket
    Condition: CreateNewCur
    # Keep the cost data if the stack is ever deleted - it is the customer's.
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub 'costpulse-cur-${AWS::AccountId}'
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          # CUR 2.0 with OVERWRITE_REPORT rewrites the current month in place.
          # Old billing periods are never re-read by CostPulse, so expire them
          # to keep storage cost near zero.
          - Id: ExpireOldCurData
            Status: Enabled
            ExpirationInDays: 120
      Tags:
        - Key: ManagedBy
          Value: CostPulse

  # Lets the AWS Data Exports service write CUR files into the bucket.
  # SourceAccount/SourceArn scope this to exports owned by THIS account only.
  CurBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Condition: CreateNewCur
    Properties:
      Bucket: !Ref CurBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowDataExportsWrite
            Effect: Allow
            Principal:
              Service:
                - bcm-data-exports.amazonaws.com
                - billingreports.amazonaws.com
            Action:
              - s3:PutObject
              - s3:GetBucketPolicy
            Resource:
              - !GetAtt CurBucket.Arn
              - !Sub '${CurBucket.Arn}/*'
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              StringLike:
                aws:SourceArn:
                  - !Sub 'arn:aws:cur:us-east-1:${AWS::AccountId}:definition/*'
                  - !Sub 'arn:aws:bcm-data-exports:us-east-1:${AWS::AccountId}:export/*'

  # CUR 2.0 export. The column list matches exactly what CostPulse's ingestion
  # pipeline reads - creating the export here means the schema can never drift.
  CurExport:
    Type: AWS::BCMDataExports::Export
    Condition: CreateNewCur
    DependsOn: CurBucketPolicy
    Properties:
      Export:
        Name: !Sub 'costpulse-cur-${AWS::AccountId}'
        Description: CostPulse CUR 2.0 export - read by CostPulse anomaly detection.
        DataQuery:
          QueryStatement: >-
            SELECT bill_billing_period_start_date, bill_payer_account_id,
            identity_line_item_id, identity_time_interval,
            line_item_line_item_description, line_item_line_item_type,
            line_item_operation, line_item_product_code, line_item_resource_id,
            line_item_unblended_cost, line_item_usage_account_id,
            line_item_usage_amount, line_item_usage_end_date,
            line_item_usage_start_date, line_item_usage_type,
            pricing_unit,
            product_product_family, product_region_code, product_servicecode,
            resource_tags
            FROM COST_AND_USAGE_REPORT
          TableConfigurations:
            COST_AND_USAGE_REPORT:
              TIME_GRANULARITY: HOURLY
              INCLUDE_RESOURCES: 'TRUE'
              INCLUDE_MANUAL_DISCOUNT_COMPATIBILITY: 'FALSE'
              INCLUDE_SPLIT_COST_ALLOCATION_DATA: 'FALSE'
        DestinationConfigurations:
          S3Destination:
            S3Bucket: !Ref CurBucket
            S3Prefix: cur
            S3Region: !Ref AWS::Region
            S3OutputConfigurations:
              Compression: PARQUET
              Format: PARQUET
              OutputType: CUSTOM
              Overwrite: OVERWRITE_REPORT
        RefreshCadence:
          Frequency: SYNCHRONOUS

  # -- Read-only role CostPulse assumes ----------------------------------------

  CostPulseReadOnlyRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'costpulse-readonly-${AWS::AccountId}'
      Description: >
        Grants CostPulse read-only access to your cost data.
        Managed by CloudFormation - do not edit manually.

      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Ref CostPulsePrincipalArn
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                sts:ExternalId: !Ref ExternalId

      Policies:
        - PolicyName: CostPulseBillingReadOnly
          PolicyDocument:
            Version: '2012-10-17'
            Statement:

              # -- CUR S3 read -------------------------------------------------
              # List the bucket so we can discover new CUR files.
              # GetBucketLocation lets CostPulse build a correctly-regioned S3
              # client - the CUR bucket is in us-east-1, CostPulse is not.
              #
              # Written as three whole statements chosen by Fn::If, rather than
              # one statement with a conditional Condition block. AWS
              # Marketplace's Quick Launch validator rejects intrinsic
              # functions INSIDE an IAM policy Condition, even though
              # CloudFormation itself accepts them and this template has
              # deployed that way for months:
              #
              #   TEMPLATE_PARSING_ERROR - Cloudformation intrinsic functions
              #   like Fn::If cannot be used inside IAM policy Condition blocks
              #
              # Each branch below therefore carries a STATIC Condition (or
              # none). Same three outcomes as before, same permissions.
              - !If
                - HasExistingPrefix
                # Reusing a customer's export, scoped to their prefix.
                - Sid: ListCURBucket
                  Effect: Allow
                  Action:
                    - s3:ListBucket
                    - s3:GetBucketLocation
                  Resource: !Sub 'arn:aws:s3:::${ExistingCurBucketName}'
                  Condition:
                    StringLike:
                      s3:prefix:
                        - !Sub '${ExistingCurPrefix}/*'
                        - !Sub '${ExistingCurPrefix}/'
                - !If
                  - CreateNewCur
                  # The bucket this stack just created.
                  - Sid: ListCURBucket
                    Effect: Allow
                    Action:
                      - s3:ListBucket
                      - s3:GetBucketLocation
                    Resource: !GetAtt CurBucket.Arn
                  # Reusing a customer's export with no prefix, so the whole
                  # bucket is in scope. Unchanged from the previous behaviour.
                  - Sid: ListCURBucket
                    Effect: Allow
                    Action:
                      - s3:ListBucket
                      - s3:GetBucketLocation
                    Resource: !Sub 'arn:aws:s3:::${ExistingCurBucketName}'

              # Read the CUR Parquet files themselves.
              - Sid: ReadCURObjects
                Effect: Allow
                Action:
                  - s3:GetObject
                Resource: !If
                  - CreateNewCur
                  - !Sub '${CurBucket.Arn}/cur/*'
                  - !If
                    - HasExistingPrefix
                    - !Sub 'arn:aws:s3:::${ExistingCurBucketName}/${ExistingCurPrefix}/*'
                    - !Sub 'arn:aws:s3:::${ExistingCurBucketName}/*'

              # -- Cost Explorer read ------------------------------------------
              # When you connect, CostPulse calls ce:GetCostAndUsage to backfill
              # about 90 days of daily spend, so anomaly detection works
              # immediately instead of waiting up to 24h for the first CUR file
              # to land. Read-only; no ability to create, modify, or delete
              # anything.
              - Sid: CostExplorerRead
                Effect: Allow
                Action:
                  - ce:GetCostAndUsage
                Resource: '*'

              # -- Bedrock CloudWatch metrics ----------------------------------
              # CostPulse queries only metrics in AWS/Bedrock, for per-model
              # invocation and token counts, so it can attribute Bedrock spend
              # to individual foundation models (Claude, Titan, Nova, ...).
              # The grant itself is NOT limited to that namespace: these two
              # actions cannot be scoped by namespace in IAM, so with
              # Resource '*' the role can read every CloudWatch metric in the
              # account. These metrics are published by AWS automatically, so
              # there is nothing for you to enable. Metrics only: no access to
              # CloudWatch Logs or prompt content.
              - Sid: BedrockMetricsRead
                Effect: Allow
                Action:
                  - cloudwatch:GetMetricData
                  - cloudwatch:ListMetrics
                Resource: '*'

      Tags:
        - Key: ManagedBy
          Value: CostPulse
        - Key: Purpose
          Value: BillingReadOnly

# -- Outputs -------------------------------------------------------------------

Outputs:
  # Bump on every change to what this template grants or creates, and update
  # TEMPLATE_VERSION in frontend/src/pages/IamPermissions.jsx to match
  # (frontend/tests/iam-page.test.mjs checks they agree). Version 1 was every
  # template before this marker existed.
  TemplateVersion:
    Description: >
      Version of the CostPulse template this stack was deployed from. Compare
      it with https://costpulse.io/iam-permissions to see whether an update
      is available.
    Value: '2'

  RoleArn:
    Description: >
      Paste this ARN into the CostPulse "Connect AWS Account" form.
    Value: !GetAtt CostPulseReadOnlyRole.Arn
    Export:
      Name: CostPulseRoleArn

  CurBucketName:
    Description: >
      The S3 bucket CostPulse will read your Cost & Usage Report from.
      Paste this into the CostPulse "Connect AWS Account" form.
    Value: !If
      - CreateNewCur
      - !Ref CurBucket
      - !Ref ExistingCurBucketName

  CurPrefix:
    Description: >
      The S3 prefix CostPulse will read your Cost & Usage Report from.
      Paste this into the CostPulse "Connect AWS Account" form.
    Value: !If
      - CreateNewCur
      - 'cur'
      - !Ref ExistingCurPrefix

  FirstDataDelivery:
    Description: >
      When to expect CUR data. AWS can take up to 24 hours to deliver the first
      file to a brand-new export. CostPulse backfills your recent daily spend
      from Cost Explorer as soon as you connect, so anomaly detection starts
      working immediately - CUR data adds hourly and resource-level detail once
      it lands.
    Value: !If
      - CreateNewCur
      - 'New export created - first CUR file arrives within 24h. Cost Explorer backfill starts immediately on connect.'
      - 'Reusing an existing export - data available on next delivery.'
