AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  AI For You Workbench - production per-tenant deployment on AWS (Marketplace).
  Provisions a dedicated VPC (private app + isolated data subnets, NAT egress),
  encrypted Multi-AZ Postgres (pgvector) + Redis, an ECS Fargate cluster running
  the api / worker / web roles from two container images, a public ALB, a KMS
  CMK, Cognito SSO, and all secrets in Secrets Manager. Database migrations run
  automatically in-stack on create/update. Sizing is fully parameterized. This
  is the production template: there is no cert-free / no-auth sandbox tier and
  no /dev routes are ever enabled.

# ---------------------------------------------------------------------------
# Parameter groups (console rendering)
# ---------------------------------------------------------------------------
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: "Deployment" }
        Parameters: [Environment, TenantSlug, DeploymentTier]
      - Label: { default: "Container images (ECR)" }
        Parameters: [AppImage, WebImage]
      - Label: { default: "Networking" }
        Parameters: [VpcCidr, AllowedIngressCidr, CertificateArn]
      - Label: { default: "Database (RDS Postgres)" }
        Parameters: [DBInstanceClass, DBAllocatedStorage, DBMaxAllocatedStorage, DBName, DBUsername, DBBackupRetentionDays, DBDeletionProtection]
      - Label: { default: "Cache (ElastiCache Redis)" }
        Parameters: [CacheNodeType, CacheNodeCount]
      - Label: { default: "ECS Fargate sizing" }
        Parameters: [ApiCpu, ApiMemory, ApiDesiredCount, WebCpu, WebMemory, WebDesiredCount, WorkerCpu, WorkerMemory, WorkerDesiredCount]
      - Label: { default: "Identity (Cognito SSO)" }
        Parameters: [PublicHostname, OidcDefaultTenant, CognitoMfa]
      - Label: { default: "LLM providers" }
        Parameters: [EmbeddingModel, OpenAiApiKey, BedrockRegion]

Parameters:
  Environment:
    Type: String
    Default: production
    AllowedValues: [production, staging]
    Description: APP_ENV for the application. /dev routes are never registered here.

  TenantSlug:
    Type: String
    Default: default
    AllowedPattern: "[a-z0-9-]{1,32}"
    Description: Short tenant identifier used in resource names and the secret-manager path.

  DeploymentTier:
    Type: String
    Default: prod
    AllowedValues: [prod, dev]
    Description: >-
      prod = full HA (Multi-AZ RDS, Multi-AZ cache w/ failover, one NAT per AZ).
      dev = cost-lean single-AZ (single-AZ RDS, single-node cache, one shared
      NAT). Both tiers run identically otherwise: real Cognito authentication,
      tasks in private subnets with NAT egress, and real LLM inference. A
      hostname + ACM cert is required for a working login in either tier.

  AppImage:
    Type: String
    Default: 709825985650.dkr.ecr.us-east-1.amazonaws.com/business-compass/aifu-app:1.0.2
    Description: >-
      ECR image URI for the shared Python image (api / worker / migrate roles).
      Defaults to the published AWS Marketplace image; leave as-is for a
      Marketplace launch.

  WebImage:
    Type: String
    Default: 709825985650.dkr.ecr.us-east-1.amazonaws.com/business-compass/aifu-web:1.0.2
    Description: >-
      ECR image URI for the Next.js web image. Defaults to the published AWS
      Marketplace image; leave as-is for a Marketplace launch.

  VpcCidr:
    Type: String
    Default: 10.20.0.0/16
    AllowedPattern: "([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}"

  AllowedIngressCidr:
    Type: String
    Default: 0.0.0.0/0
    AllowedPattern: "([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}"
    Description: >-
      CIDR permitted to reach the public ALB. Defaults to the open internet
      (0.0.0.0/0), appropriate for a public workbench behind Cognito auth. To
      restrict access to your campus/VPN range, set it here (e.g. 203.0.113.0/24).

  CertificateArn:
    Type: String
    Default: ""
    Description: >-
      Optional ACM certificate ARN. When set, the ALB serves HTTPS (TLS 1.3
      policy) and redirects HTTP→HTTPS. When empty, the ALB serves HTTP only
      (acceptable for a bootstrap/private deployment; set a cert for §11.1).

  DBInstanceClass:
    Type: String
    Default: db.r6g.large
    AllowedValues:
      - db.t4g.micro    # ~$12/mo — dev / smallest
      - db.t4g.small
      - db.t4g.medium
      - db.t3.medium
      - db.r6g.large    # prod default
      - db.r6g.xlarge
      - db.m6g.large
    Description: RDS instance class. Use db.t4g.micro for dev; db.r6g.large+ for prod.
  DBAllocatedStorage:
    Type: Number
    Default: 100
    MinValue: 20
  DBMaxAllocatedStorage:
    Type: Number
    Default: 1000
    Description: Upper bound for RDS storage autoscaling.
  DBName:
    Type: String
    Default: aifu
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9_]{0,62}"
  DBUsername:
    Type: String
    Default: aifu
    AllowedPattern: "[a-zA-Z][a-zA-Z0-9_]{0,62}"
  DBBackupRetentionDays:
    Type: Number
    Default: 7
    MinValue: 1
    MaxValue: 35
  DBDeletionProtection:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]

  CacheNodeType:
    Type: String
    Default: cache.r6g.large
    AllowedValues:
      - cache.t4g.micro   # ~$11/mo — dev / smallest
      - cache.t4g.small
      - cache.t4g.medium
      - cache.r6g.large   # prod default
      - cache.r6g.xlarge
      - cache.m6g.large
    Description: ElastiCache node type. Use cache.t4g.micro for dev; cache.r6g.large+ for prod.
  CacheNodeCount:
    Type: Number
    Default: 2
    MinValue: 2
    MaxValue: 6
    Description: Total nodes (1 primary + N replicas). ≥2 is required for Multi-AZ automatic failover.

  # Fargate task sizing. CPU is in vCPU units (1024 = 1 vCPU). Memory (MiB) must
  # be a valid pairing for the chosen CPU (e.g. 256→512/1024/2048; 512→1024–4096;
  # 1024→2048–8192) — the dropdowns constrain typos, not the CPU↔mem pairing.
  ApiCpu:
    Type: String
    Default: "1024"
    AllowedValues: ["256", "512", "1024", "2048", "4096"]
    Description: API vCPU units (1024 = 1 vCPU).
  ApiMemory:
    Type: String
    Default: "2048"
    AllowedValues: ["512", "1024", "2048", "3072", "4096", "8192"]
    Description: API memory (MiB).
  ApiDesiredCount: { Type: Number, Default: 2, MinValue: 1 }
  WebCpu:
    Type: String
    Default: "512"
    AllowedValues: ["256", "512", "1024", "2048", "4096"]
    Description: Web vCPU units.
  WebMemory:
    Type: String
    Default: "1024"
    AllowedValues: ["512", "1024", "2048", "3072", "4096", "8192"]
    Description: Web memory (MiB).
  WebDesiredCount: { Type: Number, Default: 2, MinValue: 1 }
  WorkerCpu:
    Type: String
    Default: "1024"
    AllowedValues: ["256", "512", "1024", "2048", "4096"]
    Description: Worker vCPU units.
  WorkerMemory:
    Type: String
    Default: "2048"
    AllowedValues: ["512", "1024", "2048", "3072", "4096", "8192"]
    Description: Worker memory (MiB).
  WorkerDesiredCount: { Type: Number, Default: 1, MinValue: 1 }

  PublicHostname:
    Type: String
    Default: ""
    Description: >-
      Public hostname users reach the app at (e.g. workbench.acme.edu), pointed
      at the ALB with an ACM cert (CertificateArn) covering it. Used for the OIDC
      redirect and the Cognito app-client callback. If blank, falls back to the
      ALB DNS name — but Cognito requires HTTPS, so a real hostname + cert is
      needed for a working login.
  OidcDefaultTenant:
    Type: String
    Default: ""
    Description: Fallback tenant UUID for single-tenant deployments (no tenant claim).
  CognitoMfa:
    Type: String
    Default: "ON"
    AllowedValues: ["ON", "OPTIONAL"]
    Description: >-
      MFA policy for the Cognito pool (§8.1). ON = every user must enroll a TOTP
      authenticator. The app trusts the IdP for MFA (OIDC_REQUIRE_MFA is off
      in-app), per the auth-rbac model.

  EmbeddingModel:
    Type: String
    Default: text-embedding-3-small
  OpenAiApiKey:
    Type: String
    Default: ""
    NoEcho: true
    Description: Optional. Stored in Secrets Manager; injected only if set.
  BedrockRegion:
    Type: String
    Default: us-east-1
    Description: Region for Bedrock chat models. Access is via the ECS task role (no static keys).

Conditions:
  # prod = full HA (Multi-AZ RDS + cache, one NAT gateway per AZ). dev = single-AZ
  # with a single shared NAT. Both run real auth + real LLM in private subnets.
  IsProd: !Equals [!Ref DeploymentTier, prod]
  HasCert: !Not [!Equals [!Ref CertificateArn, ""]]
  HasHostname: !Not [!Equals [!Ref PublicHostname, ""]]
  HasOpenAiApiKey: !Not [!Equals [!Ref OpenAiApiKey, ""]]

Resources:

  # =========================================================================
  # Networking — dedicated VPC [REQ §9.2 walled garden]
  # =========================================================================
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-vpc" }]

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-igw" }]
  IgwAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref InternetGateway

  # Public subnets (ALB + NAT)
  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 6, 8]]
      MapPublicIpOnLaunch: true
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-public-a" }]
  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 6, 8]]
      MapPublicIpOnLaunch: true
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-public-b" }]

  # Private app subnets (ECS tasks; egress via NAT)
  AppSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [2, !Cidr [!Ref VpcCidr, 6, 8]]
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-app-a" }]
  AppSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [3, !Cidr [!Ref VpcCidr, 6, 8]]
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-app-b" }]

  # Private data subnets (RDS + ElastiCache; NO egress route = isolated)
  DataSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [4, !Cidr [!Ref VpcCidr, 6, 8]]
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-data-a" }]
  DataSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [5, !Cidr [!Ref VpcCidr, 6, 8]]
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-data-b" }]

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-public-rt" }]
  PublicDefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: IgwAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref PublicRouteTable, SubnetId: !Ref PublicSubnetA }
  PublicAssocB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref PublicRouteTable, SubnetId: !Ref PublicSubnetB }

  # prod: one NAT gateway per AZ [REQ §16.1 HA]. dev: a single shared NAT.
  NatEipA:
    Type: AWS::EC2::EIP
    Properties: { Domain: vpc }
  NatEipB:
    Type: AWS::EC2::EIP
    Condition: IsProd
    Properties: { Domain: vpc }
  NatGwA:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatEipA.AllocationId
      SubnetId: !Ref PublicSubnetA
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-nat-a" }]
  NatGwB:
    Type: AWS::EC2::NatGateway
    Condition: IsProd
    Properties:
      AllocationId: !GetAtt NatEipB.AllocationId
      SubnetId: !Ref PublicSubnetB
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-nat-b" }]

  AppRouteTableA:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-app-rt-a" }]
  AppRouteA:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref AppRouteTableA
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGwA
  AppAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref AppRouteTableA, SubnetId: !Ref AppSubnetA }

  AppRouteTableB:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-app-rt-b" }]
  AppRouteB:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref AppRouteTableB
      DestinationCidrBlock: 0.0.0.0/0
      # dev has no NatGwB — AZ-b egress falls back to the single shared NAT.
      NatGatewayId: !If [IsProd, !Ref NatGwB, !Ref NatGwA]
  AppAssocB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref AppRouteTableB, SubnetId: !Ref AppSubnetB }

  # Isolated route table for data subnets (no default route)
  DataRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref Vpc
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-data-rt" }]
  DataAssocA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref DataRouteTable, SubnetId: !Ref DataSubnetA }
  DataAssocB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: { RouteTableId: !Ref DataRouteTable, SubnetId: !Ref DataSubnetB }

  # S3 gateway endpoint (free; keeps ECR layer + knowledge-bucket traffic off NAT)
  S3GatewayEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds: [!Ref AppRouteTableA, !Ref AppRouteTableB]

  # =========================================================================
  # Security groups
  # =========================================================================
  AlbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Public ALB ingress
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !Ref AllowedIngressCidr
          Description: HTTPS
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: !Ref AllowedIngressCidr
          Description: HTTP (redirected to HTTPS when a cert is configured)
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-alb-sg" }]

  WebSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Web (Next.js) tasks
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3000
          ToPort: 3000
          SourceSecurityGroupId: !Ref AlbSg
          Description: From ALB
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-web-sg" }]

  AppSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: API + worker tasks
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 8000
          ToPort: 8000
          SourceSecurityGroupId: !Ref WebSg
          Description: API from web (Service Connect)
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-app-sg" }]
  # Allow Service Connect intra-SG traffic (api↔worker sidecars share AppSg)
  AppSgSelfIngress:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref AppSg
      IpProtocol: tcp
      FromPort: 8000
      ToPort: 8000
      SourceSecurityGroupId: !Ref AppSg
      Description: Intra-app Service Connect

  DbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: RDS Postgres
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          SourceSecurityGroupId: !Ref AppSg
          Description: Postgres from app tasks
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-db-sg" }]

  CacheSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ElastiCache Redis
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 6379
          ToPort: 6379
          SourceSecurityGroupId: !Ref AppSg
          Description: Redis from app tasks
      Tags: [{ Key: Name, Value: !Sub "aifu-${TenantSlug}-cache-sg" }]

  # =========================================================================
  # KMS CMK [REQ §11.1 AES-256] [REQ §11.2 key management]
  # =========================================================================
  KmsKey:
    Type: AWS::KMS::Key
    Properties:
      Description: !Sub "aifu ${TenantSlug} data-encryption CMK"
      EnableKeyRotation: true
      KeyPolicy:
        Version: "2012-10-17"
        Statement:
          - Sid: RootAccountAdmin
            Effect: Allow
            Principal: { AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" }
            Action: "kms:*"
            Resource: "*"
          - Sid: AllowCloudWatchLogs
            Effect: Allow
            Principal: { Service: !Sub "logs.${AWS::Region}.amazonaws.com" }
            Action: [kms:Encrypt, kms:Decrypt, kms:ReEncrypt*, kms:GenerateDataKey*, kms:Describe*]
            Resource: "*"
            Condition:
              ArnLike:
                kms:EncryptionContext:aws:logs:arn: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:*"
  KmsAlias:
    Type: AWS::KMS::Alias
    Properties:
      AliasName: !Sub "alias/aifu-${TenantSlug}"
      TargetKeyId: !Ref KmsKey

  # =========================================================================
  # Secrets Manager [hard rule 8] — never in the template body
  # =========================================================================
  DbPasswordSecret:
    Type: AWS::SecretsManager::Secret
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Name: !Sub "aifu/${Environment}/${TenantSlug}/db-password"
      Description: RDS master password
      KmsKeyId: !Ref KmsKey
      GenerateSecretString:
        PasswordLength: 40
        ExcludePunctuation: true   # URL-safe: no @ : / ? # etc. in the DSN

  RedisAuthSecret:
    Type: AWS::SecretsManager::Secret
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Name: !Sub "aifu/${Environment}/${TenantSlug}/redis-auth"
      Description: ElastiCache AUTH token
      KmsKeyId: !Ref KmsKey
      GenerateSecretString:
        PasswordLength: 40
        ExcludePunctuation: true

  SessionSecret:
    Type: AWS::SecretsManager::Secret
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Name: !Sub "aifu/${Environment}/${TenantSlug}/session-secret"
      Description: App session signing secret
      KmsKeyId: !Ref KmsKey
      GenerateSecretString:
        PasswordLength: 64
        ExcludePunctuation: true

  OidcClientSecretSecret:
    Type: AWS::SecretsManager::Secret
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Name: !Sub "aifu/${Environment}/${TenantSlug}/oidc-client-secret"
      Description: OIDC client secret — the Cognito app-client secret
      KmsKeyId: !Ref KmsKey
      SecretString: !GetAtt UserPoolClient.ClientSecret

  OpenAiApiKeySecret:
    Type: AWS::SecretsManager::Secret
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      Name: !Sub "aifu/${Environment}/${TenantSlug}/openai-api-key"
      Description: OpenAI API key for embeddings (placeholder until set post-deploy)
      KmsKeyId: !Ref KmsKey
      SecretString: !If [HasOpenAiApiKey, !Ref OpenAiApiKey, "REPLACE_ME"]

  # =========================================================================
  # S3 knowledge bucket [REQ §11.1 at rest] [REQ §9.2 isolation]
  # =========================================================================
  KnowledgeBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub "aifu-${TenantSlug}-knowledge-${AWS::AccountId}-${AWS::Region}"
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
              KMSMasterKeyID: !Ref KmsKey
            BucketKeyEnabled: true
      VersioningConfiguration: { Status: Enabled }
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  KnowledgeBucketTlsPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref KnowledgeBucket
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: DenyInsecureTransport
            Effect: Deny
            Principal: "*"
            Action: "s3:*"
            Resource:
              - !GetAtt KnowledgeBucket.Arn
              - !Sub "${KnowledgeBucket.Arn}/*"
            Condition: { Bool: { "aws:SecureTransport": "false" } }

  # =========================================================================
  # RDS Postgres 16 (pgvector) [REQ §16.1 Multi-AZ] [REQ §11.1 encrypted + TLS]
  # =========================================================================
  DbSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: !Sub "aifu ${TenantSlug} data subnets"
      SubnetIds: [!Ref DataSubnetA, !Ref DataSubnetB]

  DbParameterGroup:
    Type: AWS::RDS::DBParameterGroup
    Properties:
      Description: !Sub "aifu ${TenantSlug} postgres16 params"
      Family: postgres16
      Parameters:
        rds.force_ssl: "1"   # [REQ §11.1] reject non-TLS connections

  DbInstance:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    UpdateReplacePolicy: Snapshot
    Properties:
      DBInstanceIdentifier: !Sub "aifu-${TenantSlug}"
      Engine: postgres
      EngineVersion: "16.14"
      DBInstanceClass: !Ref DBInstanceClass
      AllocatedStorage: !Ref DBAllocatedStorage
      MaxAllocatedStorage: !Ref DBMaxAllocatedStorage
      StorageType: gp3
      DBName: !Ref DBName
      MasterUsername: !Ref DBUsername
      MasterUserPassword: !Sub "{{resolve:secretsmanager:${DbPasswordSecret}:SecretString}}"
      DBSubnetGroupName: !Ref DbSubnetGroup
      DBParameterGroupName: !Ref DbParameterGroup
      VPCSecurityGroups: [!Ref DbSg]
      MultiAZ: !If [IsProd, true, false]
      StorageEncrypted: true
      KmsKeyId: !Ref KmsKey
      BackupRetentionPeriod: !Ref DBBackupRetentionDays
      DeletionProtection: !Ref DBDeletionProtection
      EnablePerformanceInsights: true
      PerformanceInsightsKMSKeyId: !Ref KmsKey
      PubliclyAccessible: false
      CopyTagsToSnapshot: true
      AutoMinorVersionUpgrade: true

  # =========================================================================
  # ElastiCache Redis 7 [REQ §16.1 failover] [REQ §11.1 at-rest + in-transit]
  # =========================================================================
  CacheSubnetGroup:
    Type: AWS::ElastiCache::SubnetGroup
    Properties:
      Description: !Sub "aifu ${TenantSlug} cache subnets"
      SubnetIds: [!Ref DataSubnetA, !Ref DataSubnetB]

  CacheReplicationGroup:
    Type: AWS::ElastiCache::ReplicationGroup
    Properties:
      ReplicationGroupDescription: !Sub "aifu ${TenantSlug} redis"
      Engine: redis
      EngineVersion: "7.1"
      CacheNodeType: !Ref CacheNodeType
      # dev: single node, no failover. prod: CacheNodeCount (≥2) w/ Multi-AZ.
      NumCacheClusters: !If [IsProd, !Ref CacheNodeCount, 1]
      AutomaticFailoverEnabled: !If [IsProd, true, false]
      MultiAZEnabled: !If [IsProd, true, false]
      CacheSubnetGroupName: !Ref CacheSubnetGroup
      SecurityGroupIds: [!Ref CacheSg]
      AtRestEncryptionEnabled: true
      KmsKeyId: !Ref KmsKey
      TransitEncryptionEnabled: true
      AuthToken: !Sub "{{resolve:secretsmanager:${RedisAuthSecret}:SecretString}}"
      SnapshotRetentionLimit: 5

  # =========================================================================
  # IAM roles
  # =========================================================================
  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "aifu-${TenantSlug}-exec"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
      Policies:
        - PolicyName: secrets-and-kms
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: ReadSecrets
                Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource:
                  - !Ref DbPasswordSecret
                  - !Ref RedisAuthSecret
                  - !Ref SessionSecret
                  - !Ref OidcClientSecretSecret
                  - !Ref OpenAiApiKeySecret
              - Sid: DecryptSecretsCmk
                Effect: Allow
                Action: kms:Decrypt
                Resource: !GetAtt KmsKey.Arn
                Condition:
                  StringEquals:
                    kms:ViaService: !Sub "secretsmanager.${AWS::Region}.amazonaws.com"

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "aifu-${TenantSlug}-task"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: ecs-tasks.amazonaws.com }
            Action: sts:AssumeRole
      Policies:
        - PolicyName: app-runtime
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: KnowledgeBucket
                Effect: Allow
                Action: [s3:GetObject, s3:PutObject, s3:DeleteObject, s3:ListBucket]
                Resource:
                  - !GetAtt KnowledgeBucket.Arn
                  - !Sub "${KnowledgeBucket.Arn}/*"
              - Sid: StackCmkForS3   # [REQ §11.2] S3 SSE-KMS uses the stack CMK
                Effect: Allow
                Action: [kms:GenerateDataKey, kms:Encrypt, kms:Decrypt, kms:DescribeKey]
                Resource: !GetAtt KmsKey.Arn
              # [REQ §11.2] The app auto-provisions a per-tenant DEK CMK on first
              # use (compliance/kms/client.py: describe→else create+alias). Those
              # keys are created at runtime with unknown ARNs, and the create /
              # describe flow runs BEFORE the alias exists, so these ops can't be
              # alias-scoped without breaking the not-found detection. Granted on
              # "*" (the task role IS the app); scoping is a hardening follow-on.
              # Without this, EVERY encrypted-column write fails.
              - Sid: TenantDekLifecycle
                Effect: Allow
                Action:
                  - kms:CreateKey
                  - kms:CreateAlias
                  - kms:DescribeKey
                  - kms:Encrypt
                  - kms:Decrypt
                  - kms:GenerateDataKey
                  - kms:ReEncrypt*
                Resource: "*"
              - Sid: BedrockInvoke   # [REQ §2.1] chat models via task role, no static keys
                Effect: Allow
                Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream]
                Resource: "*"
              - Sid: MarketplaceMetering   # verify the buyer's subscription at startup
                Effect: Allow
                Action: aws-marketplace:RegisterUsage
                Resource: "*"
              - Sid: CognitoAdmin   # [REQ §8.1] admin-initiated invites on THIS pool only
                Effect: Allow
                Action:
                  - cognito-idp:AdminCreateUser
                  - cognito-idp:AdminAddUserToGroup
                  - cognito-idp:AdminDeleteUser
                  - cognito-idp:AdminEnableUser
                  - cognito-idp:AdminDisableUser
                  - cognito-idp:AdminGetUser
                Resource: !GetAtt UserPool.Arn

  # =========================================================================
  # ECS cluster + Service Connect namespace + logs
  # =========================================================================
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "aifu-${TenantSlug}"
      ClusterSettings: [{ Name: containerInsights, Value: enabled }]

  ServiceNamespace:
    Type: AWS::ServiceDiscovery::PrivateDnsNamespace
    Properties:
      Name: !Sub "aifu-${TenantSlug}.local"
      Vpc: !Ref Vpc

  ApiLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aifu/${TenantSlug}/api"
      RetentionInDays: 30
      KmsKeyId: !GetAtt KmsKey.Arn
  WebLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aifu/${TenantSlug}/web"
      RetentionInDays: 30
      KmsKeyId: !GetAtt KmsKey.Arn
  WorkerLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aifu/${TenantSlug}/worker"
      RetentionInDays: 30
      KmsKeyId: !GetAtt KmsKey.Arn
  MigrateLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aifu/${TenantSlug}/migrate"
      RetentionInDays: 30
      KmsKeyId: !GetAtt KmsKey.Arn

  # =========================================================================
  # Task definitions
  #   DATABASE_URL / REDIS_URL are assembled in the container command from
  #   Secrets Manager values so the shared image stays generic. ${!VAR} is a
  #   literal shell var in !Sub; ${Resource} is a CFN substitution.
  # =========================================================================
  ApiTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "aifu-${TenantSlug}-api"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref ApiCpu
      Memory: !Ref ApiMemory
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
        - Name: api
          Image: !Ref AppImage
          EntryPoint: [/bin/sh, -c]
          Command:
            - !Sub >-
              export DATABASE_URL="postgresql+asyncpg://${DBUsername}:${!DB_PASSWORD}@${DbInstance.Endpoint.Address}:5432/${DBName}";
              export REDIS_URL="rediss://:${!REDIS_AUTH}@${CacheReplicationGroup.PrimaryEndPoint.Address}:6379/0";
              export ARQ_REDIS_URL="rediss://:${!REDIS_AUTH}@${CacheReplicationGroup.PrimaryEndPoint.Address}:6379/1";
              exec uvicorn api.main:create_app --factory --host 0.0.0.0 --port 8000
          PortMappings:
            - Name: api
              ContainerPort: 8000
              Protocol: tcp
              AppProtocol: http
          Environment:
            - { Name: APP_ENV, Value: !Ref Environment }
            - { Name: LOG_FORMAT, Value: json }
            - { Name: LOG_LEVEL, Value: INFO }
            - { Name: KMS_ENDPOINT_URL, Value: !Sub "https://kms.${AWS::Region}.amazonaws.com" }
            - { Name: KMS_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_ENDPOINT_URL, Value: !Sub "https://s3.${AWS::Region}.amazonaws.com" }
            - { Name: S3_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_BUCKET, Value: !Ref KnowledgeBucket }
            - { Name: EMBEDDING_MODEL, Value: !Ref EmbeddingModel }
            - { Name: AWS_BEDROCK_REGION, Value: !Ref BedrockRegion }
            - { Name: MARKETPLACE_PRODUCT_CODE, Value: cr7uupqfi5s9afghr5pt7iy0g }
            - { Name: MARKETPLACE_METERING_REGION, Value: !Ref "AWS::Region" }
            - { Name: LLM_USE_REAL, Value: "true" }
            - { Name: OIDC_ISSUER, Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}" }
            - { Name: OIDC_CLIENT_ID, Value: !Ref UserPoolClient }
            - Name: OIDC_REDIRECT_URI
              Value: !If
                - HasHostname
                - !Sub "https://${PublicHostname}/auth/callback"
                - !Sub "https://${Alb.DNSName}/auth/callback"
            - { Name: OIDC_DEFAULT_TENANT, Value: !Ref OidcDefaultTenant }
            # Cognito enforces MFA at the pool; the app trusts the IdP.
            - { Name: OIDC_REQUIRE_MFA, Value: "false" }
            - { Name: OIDC_GROUPS_CLAIM, Value: "cognito:groups" }
            - { Name: OIDC_SCOPES, Value: "openid email profile" }
            - { Name: COGNITO_USER_POOL_ID, Value: !Ref UserPool }
            - { Name: COGNITO_REGION, Value: !Ref "AWS::Region" }
          Secrets:
            - { Name: DB_PASSWORD, ValueFrom: !Ref DbPasswordSecret }
            - { Name: REDIS_AUTH, ValueFrom: !Ref RedisAuthSecret }
            - { Name: SESSION_SECRET, ValueFrom: !Ref SessionSecret }
            - { Name: OIDC_CLIENT_SECRET, ValueFrom: !Ref OidcClientSecretSecret }
            - { Name: OPENAI_API_KEY, ValueFrom: !Ref OpenAiApiKeySecret }
          HealthCheck:
            Command: [CMD-SHELL, "curl -fsS http://localhost:8000/healthz || exit 1"]
            Interval: 30
            Timeout: 5
            Retries: 3
            StartPeriod: 30
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref ApiLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: api

  WebTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "aifu-${TenantSlug}-web"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref WebCpu
      Memory: !Ref WebMemory
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
        - Name: web
          Image: !Ref WebImage
          PortMappings:
            - Name: web
              ContainerPort: 3000
              Protocol: tcp
              AppProtocol: http
          Environment:
            - { Name: NEXT_PUBLIC_APP_ENV, Value: production }
            - { Name: NEXT_PUBLIC_API_BASE_URL, Value: "http://api:8000" }
            - { Name: PORT, Value: "3000" }
            - { Name: HOSTNAME, Value: "0.0.0.0" }
          HealthCheck:
            Command: [CMD-SHELL, "curl -fsS http://localhost:3000/ || exit 1"]
            Interval: 30
            Timeout: 5
            Retries: 3
            StartPeriod: 20
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref WebLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: web

  WorkerTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "aifu-${TenantSlug}-worker"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref WorkerCpu
      Memory: !Ref WorkerMemory
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
        - Name: worker
          Image: !Ref AppImage
          EntryPoint: [/bin/sh, -c]
          Command:
            - !Sub >-
              export DATABASE_URL="postgresql+asyncpg://${DBUsername}:${!DB_PASSWORD}@${DbInstance.Endpoint.Address}:5432/${DBName}";
              export REDIS_URL="rediss://:${!REDIS_AUTH}@${CacheReplicationGroup.PrimaryEndPoint.Address}:6379/0";
              export ARQ_REDIS_URL="rediss://:${!REDIS_AUTH}@${CacheReplicationGroup.PrimaryEndPoint.Address}:6379/1";
              exec python -m worker
          Environment:
            - { Name: APP_ENV, Value: !Ref Environment }
            - { Name: LOG_FORMAT, Value: json }
            - { Name: LOG_LEVEL, Value: INFO }
            - { Name: KMS_ENDPOINT_URL, Value: !Sub "https://kms.${AWS::Region}.amazonaws.com" }
            - { Name: KMS_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_ENDPOINT_URL, Value: !Sub "https://s3.${AWS::Region}.amazonaws.com" }
            - { Name: S3_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_BUCKET, Value: !Ref KnowledgeBucket }
            - { Name: EMBEDDING_MODEL, Value: !Ref EmbeddingModel }
            - { Name: AWS_BEDROCK_REGION, Value: !Ref BedrockRegion }
            - { Name: MARKETPLACE_PRODUCT_CODE, Value: cr7uupqfi5s9afghr5pt7iy0g }
            - { Name: MARKETPLACE_METERING_REGION, Value: !Ref "AWS::Region" }
            - { Name: LLM_USE_REAL, Value: "true" }
            - { Name: OIDC_ISSUER, Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}" }
            - { Name: OIDC_CLIENT_ID, Value: !Ref UserPoolClient }
            - Name: OIDC_REDIRECT_URI
              Value: !If
                - HasHostname
                - !Sub "https://${PublicHostname}/auth/callback"
                - !Sub "https://${Alb.DNSName}/auth/callback"
            - { Name: OIDC_DEFAULT_TENANT, Value: !Ref OidcDefaultTenant }
            - { Name: OIDC_REQUIRE_MFA, Value: "false" }
            - { Name: OIDC_GROUPS_CLAIM, Value: "cognito:groups" }
            - { Name: OIDC_SCOPES, Value: "openid email profile" }
            - { Name: COGNITO_USER_POOL_ID, Value: !Ref UserPool }
            - { Name: COGNITO_REGION, Value: !Ref "AWS::Region" }
          Secrets:
            - { Name: DB_PASSWORD, ValueFrom: !Ref DbPasswordSecret }
            - { Name: REDIS_AUTH, ValueFrom: !Ref RedisAuthSecret }
            - { Name: OPENAI_API_KEY, ValueFrom: !Ref OpenAiApiKeySecret }
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref WorkerLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: worker

  # Runs `alembic upgrade head`. Invoked automatically on every stack create/
  # update by the Migration custom resource below (and re-runnable manually via
  # `aws ecs run-task` using the MigrateTaskDefArn output). Not a service.
  MigrateTaskDef:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "aifu-${TenantSlug}-migrate"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: "512"
      Memory: "1024"
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
        - Name: migrate
          Image: !Ref AppImage
          EntryPoint: [/bin/sh, -c]
          Command:
            - !Sub >-
              export DATABASE_URL="postgresql+asyncpg://${DBUsername}:${!DB_PASSWORD}@${DbInstance.Endpoint.Address}:5432/${DBName}";
              exec alembic upgrade head
          Environment:
            - { Name: APP_ENV, Value: !Ref Environment }
            - { Name: LOG_FORMAT, Value: json }
            - { Name: LOG_LEVEL, Value: INFO }
            - { Name: KMS_ENDPOINT_URL, Value: !Sub "https://kms.${AWS::Region}.amazonaws.com" }
            - { Name: KMS_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_ENDPOINT_URL, Value: !Sub "https://s3.${AWS::Region}.amazonaws.com" }
            - { Name: S3_REGION, Value: !Ref "AWS::Region" }
            - { Name: S3_BUCKET, Value: !Ref KnowledgeBucket }
            - { Name: EMBEDDING_MODEL, Value: !Ref EmbeddingModel }
            - { Name: AWS_BEDROCK_REGION, Value: !Ref BedrockRegion }
            - { Name: MARKETPLACE_PRODUCT_CODE, Value: cr7uupqfi5s9afghr5pt7iy0g }
            - { Name: MARKETPLACE_METERING_REGION, Value: !Ref "AWS::Region" }
            - { Name: LLM_USE_REAL, Value: "true" }
            - { Name: OIDC_ISSUER, Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}" }
            - { Name: OIDC_CLIENT_ID, Value: !Ref UserPoolClient }
            - Name: OIDC_REDIRECT_URI
              Value: !If
                - HasHostname
                - !Sub "https://${PublicHostname}/auth/callback"
                - !Sub "https://${Alb.DNSName}/auth/callback"
            - { Name: OIDC_DEFAULT_TENANT, Value: !Ref OidcDefaultTenant }
            - { Name: OIDC_REQUIRE_MFA, Value: "false" }
            - { Name: OIDC_GROUPS_CLAIM, Value: "cognito:groups" }
            - { Name: OIDC_SCOPES, Value: "openid email profile" }
            - { Name: COGNITO_USER_POOL_ID, Value: !Ref UserPool }
            - { Name: COGNITO_REGION, Value: !Ref "AWS::Region" }
          Secrets:
            - { Name: DB_PASSWORD, ValueFrom: !Ref DbPasswordSecret }
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref MigrateLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: migrate

  # =========================================================================
  # Automatic DB migration — a CloudFormation custom resource runs the migrate
  # task (alembic upgrade head) on every stack create/update, blocking the app
  # services until it exits 0. This makes the stack one-click for Marketplace:
  # no manual `aws ecs run-task` step. The task's stdout still lands in the
  # /aifu/<tenant>/migrate log group. Re-runs whenever AppImage or the migrate
  # task definition changes.
  # =========================================================================
  MigrationFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "aifu-${TenantSlug}-migrate-cr"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      Policies:
        - PolicyName: run-migrate-task
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: Logs
                Effect: Allow
                Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents]
                Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/aifu-${TenantSlug}-migrate-cr:*"
              - Sid: RunMigrateTask   # scoped to the migrate task def in THIS cluster
                Effect: Allow
                Action: ecs:RunTask
                Resource: !Sub "arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/aifu-${TenantSlug}-migrate:*"
                Condition:
                  ArnEquals: { "ecs:cluster": !GetAtt Cluster.Arn }
              - Sid: DescribeTasks   # read-only poll for task completion
                Effect: Allow
                Action: ecs:DescribeTasks
                Resource: "*"
              - Sid: PassTaskRoles   # RunTask passes the task exec + task roles
                Effect: Allow
                Action: iam:PassRole
                Resource: [!GetAtt ExecutionRole.Arn, !GetAtt TaskRole.Arn]
                Condition:
                  StringEquals: { "iam:PassedToService": ecs-tasks.amazonaws.com }

  MigrationFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "aifu-${TenantSlug}-migrate-cr"
      Description: !Sub "Runs the ${TenantSlug} DB migration task on stack create/update"
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 256
      Role: !GetAtt MigrationFunctionRole.Arn
      Code:
        ZipFile: |
          import json, time, urllib.request
          import boto3

          ecs = boto3.client("ecs")

          def send(event, context, status, data, physical_id, reason=""):
              body = json.dumps({
                  "Status": status,
                  "Reason": reason or f"See CloudWatch log stream: {context.log_stream_name}",
                  "PhysicalResourceId": physical_id,
                  "StackId": event["StackId"],
                  "RequestId": event["RequestId"],
                  "LogicalResourceId": event["LogicalResourceId"],
                  "NoEcho": False,
                  "Data": data,
              }).encode("utf-8")
              req = urllib.request.Request(
                  event["ResponseURL"], data=body, method="PUT",
                  headers={"content-type": "", "content-length": str(len(body))},
              )
              urllib.request.urlopen(req)  # noqa: S310 — presigned S3 URL from CFN

          def handler(event, context):
              physical_id = event.get("PhysicalResourceId") or f"{event['LogicalResourceId']}-migration"
              try:
                  if event["RequestType"] == "Delete":
                      send(event, context, "SUCCESS", {}, physical_id)
                      return
                  p = event["ResourceProperties"]
                  run = ecs.run_task(
                      cluster=p["Cluster"],
                      taskDefinition=p["TaskDefinition"],
                      launchType="FARGATE",
                      count=1,
                      networkConfiguration={"awsvpcConfiguration": {
                          "subnets": p["Subnets"],
                          "securityGroups": [p["SecurityGroup"]],
                          "assignPublicIp": "DISABLED",
                      }},
                  )
                  if not run.get("tasks"):
                      send(event, context, "FAILED", {}, physical_id,
                           reason=f"run_task failed: {run.get('failures')}")
                      return
                  arn = run["tasks"][0]["taskArn"]
                  deadline = time.time() + 840  # leave margin under the 900s timeout
                  while time.time() < deadline:
                      time.sleep(10)
                      desc = ecs.describe_tasks(cluster=p["Cluster"], tasks=[arn]).get("tasks", [])
                      if not desc or desc[0]["lastStatus"] != "STOPPED":
                          continue
                      t = desc[0]
                      conts = t.get("containers", [])
                      code = conts[0].get("exitCode") if conts else None
                      if code == 0:
                          send(event, context, "SUCCESS", {"TaskArn": arn}, physical_id)
                      else:
                          send(event, context, "FAILED", {}, physical_id,
                               reason=f"migrate exitCode={code} stoppedReason={t.get('stoppedReason')}")
                      return
                  send(event, context, "FAILED", {}, physical_id, reason="timeout waiting for migrate task")
              except Exception as e:  # never leave CFN hanging — always respond
                  send(event, context, "FAILED", {}, physical_id, reason=str(e))

  Migration:
    Type: Custom::DatabaseMigration
    # NAT egress (ECR pull + Secrets Manager) and the DB must exist first.
    DependsOn: [DbInstance, AppRouteA, AppRouteB, S3GatewayEndpoint]
    Properties:
      ServiceToken: !GetAtt MigrationFunction.Arn
      Cluster: !Ref Cluster
      TaskDefinition: !Ref MigrateTaskDef   # ARN carries the revision -> re-runs on change
      Subnets: [!Ref AppSubnetA, !Ref AppSubnetB]
      SecurityGroup: !Ref AppSg
      Image: !Ref AppImage   # a new image also forces a re-run on update

  # =========================================================================
  # ECS services
  # =========================================================================
  ApiService:
    Type: AWS::ECS::Service
    DependsOn: [DbInstance, CacheReplicationGroup, Migration]
    Properties:
      ServiceName: !Sub "aifu-${TenantSlug}-api"
      Cluster: !Ref Cluster
      TaskDefinition: !Ref ApiTaskDef
      DesiredCount: !Ref ApiDesiredCount
      LaunchType: FARGATE
      DeploymentConfiguration:
        MinimumHealthyPercent: 100
        MaximumPercent: 200
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets: [!Ref AppSubnetA, !Ref AppSubnetB]
          SecurityGroups: [!Ref AppSg]
          AssignPublicIp: DISABLED
      ServiceConnectConfiguration:
        Enabled: true
        Namespace: !GetAtt ServiceNamespace.Arn
        Services:
          - PortName: api
            DiscoveryName: api
            ClientAliases:
              - Port: 8000
                DnsName: api

  WorkerService:
    Type: AWS::ECS::Service
    DependsOn: [DbInstance, CacheReplicationGroup, Migration]
    Properties:
      ServiceName: !Sub "aifu-${TenantSlug}-worker"
      Cluster: !Ref Cluster
      TaskDefinition: !Ref WorkerTaskDef
      DesiredCount: !Ref WorkerDesiredCount
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets: [!Ref AppSubnetA, !Ref AppSubnetB]
          SecurityGroups: [!Ref AppSg]
          AssignPublicIp: DISABLED
      ServiceConnectConfiguration:
        Enabled: true
        Namespace: !GetAtt ServiceNamespace.Arn

  WebService:
    Type: AWS::ECS::Service
    DependsOn: [HttpListener, ApiService]
    Properties:
      ServiceName: !Sub "aifu-${TenantSlug}-web"
      Cluster: !Ref Cluster
      TaskDefinition: !Ref WebTaskDef
      DesiredCount: !Ref WebDesiredCount
      LaunchType: FARGATE
      HealthCheckGracePeriodSeconds: 60
      DeploymentConfiguration:
        MinimumHealthyPercent: 100
        MaximumPercent: 200
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets: [!Ref AppSubnetA, !Ref AppSubnetB]
          SecurityGroups: [!Ref WebSg]
          AssignPublicIp: DISABLED
      LoadBalancers:
        - TargetGroupArn: !Ref WebTargetGroup
          ContainerName: web
          ContainerPort: 3000
      ServiceConnectConfiguration:
        Enabled: true
        Namespace: !GetAtt ServiceNamespace.Arn

  # =========================================================================
  # Public Application Load Balancer [REQ §11.1 TLS in transit]
  # =========================================================================
  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub "aifu-${TenantSlug}"
      Scheme: internet-facing
      Type: application
      Subnets: [!Ref PublicSubnetA, !Ref PublicSubnetB]
      SecurityGroups: [!Ref AlbSg]

  WebTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub "aifu-${TenantSlug}-web"
      VpcId: !Ref Vpc
      TargetType: ip
      Protocol: HTTP
      Port: 3000
      HealthCheckProtocol: HTTP
      HealthCheckPath: /
      Matcher: { HttpCode: "200-399" }
      HealthCheckIntervalSeconds: 30
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 5
      TargetGroupAttributes:
        - { Key: deregistration_delay.timeout_seconds, Value: "30" }

  # HTTP listener always forwards to the TG (keeps the TG attached so the ECS
  # service can start). When a cert is present, a priority-1 rule redirects all
  # HTTP traffic to HTTPS.
  HttpListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref WebTargetGroup

  HttpToHttpsRedirect:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Condition: HasCert
    Properties:
      ListenerArn: !Ref HttpListener
      Priority: 1
      Conditions:
        - Field: path-pattern
          PathPatternConfig: { Values: ["/*"] }
      Actions:
        - Type: redirect
          RedirectConfig:
            Protocol: HTTPS
            Port: "443"
            StatusCode: HTTP_301

  HttpsListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Condition: HasCert
    Properties:
      LoadBalancerArn: !Ref Alb
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates:
        - CertificateArn: !Ref CertificateArn
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref WebTargetGroup

  # =========================================================================
  # Cognito — the OIDC IdP [REQ §8.1 / §15.1]
  #   Admin-only user creation (invites); Cognito emails the invitation. Users
  #   authenticate via the hosted UI (OIDC code flow); groups → app roles.
  # =========================================================================
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub "aifu-${TenantSlug}"
      UsernameAttributes: [email]
      AutoVerifiedAttributes: [email]
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true   # no self-signup — invite-only (§8.1)
        InviteMessageTemplate:
          EmailSubject: "Your AI For You Workbench invitation"
          EmailMessage: >-
            You have been invited to the AI For You Workbench. Your username is
            {username} and your temporary password is {####}. Sign in to finish
            setting up your account.
      Policies:
        PasswordPolicy:
          MinimumLength: 12
          RequireUppercase: true
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
      MfaConfiguration: !Ref CognitoMfa
      EnabledMfas: [SOFTWARE_TOKEN_MFA]
      AccountRecoverySetting:
        RecoveryMechanisms:
          - { Name: verified_email, Priority: 1 }
      Schema:
        - Name: department
          AttributeDataType: String
          Mutable: true
          Required: false
      UserPoolTags: { app: aifu, tenant: !Ref TenantSlug }

  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      UserPoolId: !Ref UserPool
      Domain: !Sub "aifu-${TenantSlug}-${AWS::AccountId}"

  # One group per app role — the group NAME is the role. Cognito emits it in the
  # "cognito:groups" claim, which group_mappings.yaml resolves back to the role.
  GroupStudent:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: student, Precedence: 60 }
  GroupStaff:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: staff, Precedence: 50 }
  GroupFaculty:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: faculty, Precedence: 40 }
  GroupResearcher:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: researcher, Precedence: 30 }
  GroupDepartmentAdmin:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: department_admin, Precedence: 20 }
  GroupSystemAdmin:
    Type: AWS::Cognito::UserPoolGroup
    Properties: { UserPoolId: !Ref UserPool, GroupName: system_admin, Precedence: 10 }

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: !Sub "aifu-${TenantSlug}-web"
      UserPoolId: !Ref UserPool
      GenerateSecret: true   # confidential client — server-side code exchange
      AllowedOAuthFlows: [code]
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthScopes: [openid, email, profile]
      SupportedIdentityProviders: [COGNITO]
      ExplicitAuthFlows: [ALLOW_REFRESH_TOKEN_AUTH, ALLOW_USER_SRP_AUTH]
      PreventUserExistenceErrors: ENABLED
      EnableTokenRevocation: true
      AccessTokenValidity: 60
      IdTokenValidity: 60
      RefreshTokenValidity: 30
      TokenValidityUnits: { AccessToken: minutes, IdToken: minutes, RefreshToken: days }
      CallbackURLs:
        - !If
          - HasHostname
          - !Sub "https://${PublicHostname}/auth/callback"
          - !Sub "https://${Alb.DNSName}/auth/callback"
      LogoutURLs:
        - !If
          - HasHostname
          - !Sub "https://${PublicHostname}/"
          - !Sub "https://${Alb.DNSName}/"

Outputs:
  AlbUrl:
    Description: Public entry point. Point your DNS / OIDC redirect URI here.
    Value: !Sub
      - "${Scheme}://${Dns}"
      - Scheme: !If [HasCert, https, http]
        Dns: !GetAtt Alb.DNSName
  VpcId:
    Value: !Ref Vpc
  ClusterName:
    Value: !Ref Cluster
  DbEndpoint:
    Value: !GetAtt DbInstance.Endpoint.Address
  RedisPrimaryEndpoint:
    Value: !GetAtt CacheReplicationGroup.PrimaryEndPoint.Address
  KnowledgeBucketName:
    Value: !Ref KnowledgeBucket
  KmsKeyArn:
    Value: !GetAtt KmsKey.Arn
  MigrateTaskDefArn:
    Description: >-
      Migrations run automatically in-stack (Migration custom resource). This is
      exposed only for optional manual re-runs via `aws ecs run-task`.
    Value: !Ref MigrateTaskDef
  MigrateRunTaskSubnets:
    Description: Private app subnets for a manual migrate run-task.
    Value: !Sub "${AppSubnetA},${AppSubnetB}"
  MigrateRunTaskSecurityGroup:
    Value: !Ref AppSg
  MigrateRunTaskAssignPublicIp:
    Description: assignPublicIp for a manual migrate run-task (private subnets → DISABLED).
    Value: DISABLED
  CognitoUserPoolId:
    Value: !Ref UserPool
  CognitoClientId:
    Value: !Ref UserPoolClient
  CognitoHostedUiUrl:
    Description: Cognito hosted-UI base (the OIDC authorize/login endpoints live here).
    Value: !Sub "https://${UserPoolDomain}.auth.${AWS::Region}.amazoncognito.com"
  CognitoIssuer:
    Description: OIDC issuer — its /.well-known/openid-configuration drives discovery.
    Value: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
