AWSTemplateFormatVersion: "2010-09-09"
Description: |
  KANU AI Platform - AWS Marketplace SaaS

  Subnet Modes: auto-cidr (default), manual-cidr (specify 4 CIDRs), auto-cidr (auto-find CIDRs)
  UsePrivateSubnets=true: NAT Gateway ~$32/mo, better security
  UsePrivateSubnets=false: No NAT, public IPs, secured by WAF

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "AWS Marketplace Deployment Parameters"
        Parameters: # This parameter isn't used for now, but is planned to be used for secret sharing in the future for self-service deployment
          - KANUDeploymentParameters
      - Label:
          default: "VPC Configuration"
        Parameters:
          - VpcId
          - SubnetAllocationMode
          - UsePrivateSubnets
      - Label:
          default: "Existing Subnets (use-existing mode)"
        Parameters:
          - PublicSubnetIds
          - PrivateSubnetIds
      - Label:
          default: "Manual CIDR Allocation (manual-cidr mode)"
        Parameters:
          - PublicSubnet1CIDR
          - PublicSubnet2CIDR
          - PrivateSubnet1CIDR
          - PrivateSubnet2CIDR
      - Label:
          default: "API Keys"
        Parameters:
          - AnthropicApiKey
      - Label:
          default: "Auto-Update Configuration"
        Parameters:
          - EnableAutoUpdate
      - Label:
          default: "Orchestrator memory and skills"
        Parameters:
          - EnableMemoryInjection
          - EnableSkillInjection

    ParameterLabels:
      KANUDeploymentParameters:
        default: "KANU Deployment Secrets (Auto-filled by AWS Marketplace)"
      VpcId:
        default: "VPC ID"
      SubnetAllocationMode:
        default: "Subnet Configuration Mode"
      UsePrivateSubnets:
        default: "Use Private Subnets"
      PublicSubnetIds:
        default: "Existing Public Subnet IDs"
      PrivateSubnetIds:
        default: "Existing Private Subnet IDs"
      PublicSubnet1CIDR:
        default: "Public Subnet 1 CIDR"
      PublicSubnet2CIDR:
        default: "Public Subnet 2 CIDR"
      PrivateSubnet1CIDR:
        default: "Private Subnet 1 CIDR"
      PrivateSubnet2CIDR:
        default: "Private Subnet 2 CIDR"
      EnvironmentName:
        default: "Environment Name"
      AnthropicApiKey:
        default: "Anthropic API Key"
      DeveloperUserArn:
        default: "Developer IAM User ARN (dev only)"
      EnableAutoUpdate:
        default: "Enable Automatic Updates"
      EnableMemoryInjection:
        default: "Inject MEMORY.md bundles into orchestrator prompts"
      EnableSkillInjection:
        default: "Install approved skills and enable Skill tool in orchestrator"

Parameters:
  # AWS Marketplace Quick Launch deployment parameter
  # This is auto-populated by AWS Marketplace and contains all API keys/secrets
  KANUDeploymentParameters:
    Type: String
    Description: AWS Marketplace deployment parameters secret ARN (auto-populated)
    NoEcho: true

  DeploymentMode:
    Type: String
    Default: "SelfHosted"
    AllowedValues: ["SelfHosted", "SelfServiceAndSelfHosted"]
    Description: |
      Deployment mode:
      - SelfHosted: Customer deployments with only SelfHosted Cognito group (default powerUser access)
      - SelfServiceAndSelfHosted: Internal deployments with Admin, Member, and SelfHosted groups (full multi-tenant features)

  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: VPC ID where the application will be deployed

  SubnetAllocationMode:
    Type: String
    Default: "auto-cidr"
    AllowedValues: ["use-existing", "manual-cidr", "auto-cidr"]
    Description: |
      Subnet configuration mode:
      - auto-cidr: Automatically find available CIDR ranges in VPC
      - use-existing: Use existing subnet IDs (provide PublicSubnetIds/PrivateSubnetIds)
      - manual-cidr: Create subnets with custom CIDR ranges (provide 4 CIDRs below)

  PublicSubnetIds:
    Type: CommaDelimitedList
    Default: ""
    Description: "(use-existing mode) Existing public subnet IDs, comma-separated (e.g., subnet-xxx,subnet-yyy)"

  PrivateSubnetIds:
    Type: CommaDelimitedList
    Default: ""
    Description: "(use-existing mode) Existing private subnet IDs, comma-separated (e.g., subnet-xxx,subnet-yyy)"

  PublicSubnet1CIDR:
    Type: String
    Default: ""
    Description: "(manual-cidr mode) CIDR for public subnet 1 (e.g., 10.0.1.0/24)"
    AllowedPattern: '^(|(\d{1,3}\.){3}\d{1,3}/\d{1,2})$'

  PublicSubnet2CIDR:
    Type: String
    Default: ""
    Description: "(manual-cidr mode) CIDR for public subnet 2 (e.g., 10.0.2.0/24)"
    AllowedPattern: '^(|(\d{1,3}\.){3}\d{1,3}/\d{1,2})$'

  PrivateSubnet1CIDR:
    Type: String
    Default: ""
    Description: "(manual-cidr mode) CIDR for private subnet 1 (e.g., 10.0.10.0/24)"
    AllowedPattern: '^(|(\d{1,3}\.){3}\d{1,3}/\d{1,2})$'

  PrivateSubnet2CIDR:
    Type: String
    Default: ""
    Description: "(manual-cidr mode) CIDR for private subnet 2 (e.g., 10.0.11.0/24)"
    AllowedPattern: '^(|(\d{1,3}\.){3}\d{1,3}/\d{1,2})$'

  EnvironmentName:
    Type: String
    Description: "Fixed environment name for tagging and resource names"
    Default: development
    AllowedValues:
      - development

  UsePrivateSubnets:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: "Use private subnets for ECS tasks (creates NAT Gateway, ~$32/month cost)"

  CreateVPCEndpoints:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: "Create a VPC Interface Endpoint for Bedrock Runtime to bypass NAT gateway. Safe for shared VPCs — skips if endpoint already exists. Set to false only if you manage the endpoint yourself."

  EnableAutoUpdate:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: "Enable automatic daily updates. When enabled, the platform checks for new versions daily at 11 AM UTC and automatically updates if a new version is available."

  EnableMemoryInjection:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: "When true, the Python orchestrator loads org/user/repo MEMORY.md bundles into agent system prompts at task start."

  EnableSkillInjection:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: "When true, the Python orchestrator installs approved skills into the workspace and enables Claude SDK skill discovery and the Skill tool."

  # Custom Domain Configuration (Optional)
  # When configured, CloudFront will accept requests on this domain in addition to the default *.cloudfront.net domain
  CustomDomainName:
    Type: String
    Default: ""
    Description: "(Optional) Custom domain name for the application (e.g., beta.getkanu.com). Requires CustomDomainCertificateArn."

  CustomDomainCertificateArn:
    Type: String
    Default: ""
    Description: "(Optional) ACM certificate ARN in us-east-1 for the custom domain. Must cover the CustomDomainName."

  # Source images to copy from KANU's ECR to customer's ECR during deployment.
  # The deploy workflow pins these to specific SHA tags (e.g., sha-abc123def...).
  # Images are copied to customer's local ECR repositories for faster task startup.
  WebAppImage:
    Type: String
    Default: "086715933510.dkr.ecr.us-east-1.amazonaws.com/kanu-webapp:sha-a2d588c4db7c7d925dfc5d7a5d7b026d1d186b5c"
    Description: "Source image URI to copy to customer ECR. Set automatically by the deploy workflow."

  PythonOrchestratorImage:
    Type: String
    Default: "086715933510.dkr.ecr.us-east-1.amazonaws.com/kanu-python-orchestrator:sha-a2d588c4db7c7d925dfc5d7a5d7b026d1d186b5c"
    Description: "Source image URI to copy to customer ECR. Set automatically by the deploy workflow."

  PlatformVersion:
    Type: String
    Default: "sha-a2d588c4db7c7d925dfc5d7a5d7b026d1d186b5c"
    Description: "Platform version identifier (e.g., sha-abc123). Set automatically by the deploy workflow."

  AnthropicApiKey:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Anthropic API key for Claude Code generation tasks"

  # Developer IAM user ARN for local development
  # When provided, the SelfHosted and OrgScoped roles will trust this user,
  # allowing local Python orchestrator to assume these roles in DEV_MODE.
  DeveloperUserArn:
    Type: String
    Default: ""
    Description: "(dev only) IAM user ARN to add to role trust policies for local dev mode testing"

  # Stripe Billing Configuration (SelfServiceAndSelfHosted mode only)
  # These enable usage-based billing for self-service customers
  StripeSecretKey:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Stripe secret API key (sk_live_... or sk_test_...)"

  StripePublishableKey:
    Type: String
    Default: ""
    Description: "Stripe publishable API key (pk_live_... or pk_test_...)"

  StripeWebhookSecret:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Stripe webhook signing secret (whsec_...)"

  StripePriceId:
    Type: String
    Default: ""
    Description: "Stripe metered price ID (price_...)"

  # reCAPTCHA v3 Configuration (optional - feature is disabled if not configured)
  RecaptchaSiteKey:
    Type: String
    Default: ""
    Description: "Google reCAPTCHA v3 site key (optional - for bot protection on signup/login)"

  RecaptchaSecretKey:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Google reCAPTCHA v3 secret key (optional - for server-side verification)"

  # GitHub App Integration (SelfServiceAndSelfHosted mode — Kanu prod)
  # These are stored in Secrets Manager and injected into the webapp at runtime.
  # Self-hosted instances do NOT need these — they use the proxy instead.
  GitHubAppId:
    Type: String
    Default: ""
    Description: "GitHub App ID (SelfServiceAndSelfHosted mode only)"

  GitHubAppClientId:
    Type: String
    Default: ""
    Description: "GitHub App OAuth Client ID (SelfServiceAndSelfHosted mode only)"

  GitHubAppClientSecret:
    Type: String
    Default: ""
    NoEcho: true
    Description: "GitHub App OAuth Client Secret (SelfServiceAndSelfHosted mode only)"

  # NOTE: GitHubAppPrivateKey is NOT a CloudFormation parameter.
  # PEM keys can exceed the 4096-char CFN parameter limit, so the deploy
  # workflow writes the key directly to Secrets Manager as:
  #   ${AWS::StackName}-github-app-private-key
  # The ECS task definition references it by this well-known name.

  GitHubAppName:
    Type: String
    Default: ""
    Description: "GitHub App Name for installation URLs (SelfServiceAndSelfHosted mode only)"

  # GitHub Auth Proxy (self-hosted — delegates all GitHub ops to Kanu prod)
  GitHubAuthProxyUrl:
    Type: String
    Default: ""
    Description: "URL of the centralized GitHub auth proxy (e.g., https://app.kanu.ai)"

  GitHubProxySecret:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Shared HMAC secret for authenticating with the GitHub auth proxy"

  # GitLab OAuth Integration (Optional - enables "Connect GitLab" flow)
  # Create a GitLab OAuth Application at https://gitlab.com/-/user_settings/applications
  # Scopes: api, read_user, read_repository
  # Redirect URI: ${APP_URL}/api/auth/gitlab/callback
  GitLabAppClientId:
    Type: String
    Default: ""
    Description: "GitLab OAuth Application ID (optional — enables Connect GitLab)"

  GitLabAppClientSecret:
    Type: String
    Default: ""
    NoEcho: true
    Description: "GitLab OAuth Application Secret (optional — enables Connect GitLab)"

  # GitLab Auth Proxy (self-hosted — delegates all GitLab ops to Kanu prod)
  GitLabAuthProxyUrl:
    Type: String
    Default: ""
    Description: "URL of the centralized GitLab auth proxy (e.g., https://app.kanu.ai)"

  GitLabProxySecret:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Shared HMAC secret for authenticating with the GitLab auth proxy"

# Static constants for AWS-managed resources
# These ARNs/IDs are universal across all AWS accounts (note: "aws" instead of account ID in ARNs)
# https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_managed-vs-inline.html#aws-managed-policies
Mappings:
  KANUDomains:
    Hostnames:
      Beta: "beta.getkanu.com"
      Production: "build.getkanu.com"
  AWSManagedPolicies:
    IAM:
      # Basic execution role for Lambda functions (CloudWatch Logs permissions)
      LambdaBasicExecution: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
      # ECS task execution role policy (ECR pull, CloudWatch Logs)
      ECSTaskExecution: "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
    CloudFront:
      # Cache Policy: CachingDisabled - disables caching entirely
      # Required for SSE streaming to prevent response buffering
      # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
      CachingDisabled: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
      # Origin Request Policy: AllViewerExceptHostHeader
      # Forwards all headers, cookies, and query strings (except Host) to origin
      # Required for SSE to forward auth cookies
      AllViewerExceptHostHeader: "b689b0a8-53d0-40ab-baf2-68738e2966ac"

Conditions:
  UsePrivateSubnetsCondition: !Equals [!Ref UsePrivateSubnets, "true"]
  UseExistingSubnets: !Equals [!Ref SubnetAllocationMode, "use-existing"]
  CreateSubnetsManualCIDR: !Equals [!Ref SubnetAllocationMode, "manual-cidr"]
  CreateSubnetsAutoCIDR: !Equals [!Ref SubnetAllocationMode, "auto-cidr"]
  CreateSubnets:
    !Or [!Condition CreateSubnetsManualCIDR, !Condition CreateSubnetsAutoCIDR]
  # IncludeMultiTenantGroups: Create Admin/Member groups for multi-tenant self-service mode
  # SelfHosted group is always created regardless of deployment mode
  IncludeMultiTenantGroups:
    !Equals [!Ref DeploymentMode, "SelfServiceAndSelfHosted"]
  HasAnthropicApiKey: !Not [!Equals [!Ref AnthropicApiKey, ""]]
  # HasGitHubApp: When true, configure direct GitHub App integration (Kanu prod)
  HasGitHubApp: !Not [!Equals [!Ref GitHubAppId, ""]]
  # HasGitHubProxy: When true, configure GitHub auth proxy (self-hosted)
  HasGitHubProxy: !Not [!Equals [!Ref GitHubAuthProxyUrl, ""]]
  # HasGitLab: When true, configure direct GitLab OAuth integration (Kanu prod)
  HasGitLab: !Not [!Equals [!Ref GitLabAppClientId, ""]]
  # HasGitLabProxy: When true, configure GitLab auth proxy (self-hosted)
  HasGitLabProxy: !Not [!Equals [!Ref GitLabAuthProxyUrl, ""]]
  # HasGitLabProxySecret: When true, store the GITLAB_PROXY_SECRET in Secrets Manager
  HasGitLabProxySecret: !Not [!Equals [!Ref GitLabProxySecret, ""]]
  # HasDeveloperUser: When true, add developer IAM user to role trust policies
  # This enables local Python orchestrator testing in DEV_MODE
  HasDeveloperUser: !Not [!Equals [!Ref DeveloperUserArn, ""]]
  # HasStripeConfig: When true, configure Stripe billing for self-service mode
  HasStripeConfig: !Not [!Equals [!Ref StripeSecretKey, ""]]
  # HasRecaptchaConfig: When true, enable reCAPTCHA v3 bot protection on auth flows
  HasRecaptchaConfig: !And
    - !Not [!Equals [!Ref RecaptchaSiteKey, ""]]
    - !Not [!Equals [!Ref RecaptchaSecretKey, ""]]
  # EnableStripeBilling: Create Stripe billing Lambda only when BOTH conditions are met:
  # 1. Multi-tenant mode enabled (SelfServiceAndSelfHosted)
  # 2. Stripe credentials provided (HasStripeConfig)
  # This prevents creating a Lambda that references non-existent secrets
  EnableStripeBilling: !And
    - !Condition IncludeMultiTenantGroups
    - !Condition HasStripeConfig
  # CreateVPCInterfaceEndpoints: Create a Bedrock Runtime VPC endpoint to bypass NAT gateway.
  # The Lambda auto-detects if one already exists and skips creation if so.
  # Set CreateVPCEndpoints=false only if you manage the Bedrock endpoint yourself.
  CreateVPCInterfaceEndpoints: !And
    - !Condition UsePrivateSubnetsCondition
    - !Equals [!Ref CreateVPCEndpoints, "true"]
  # HasCustomDomain: When true, use custom domain name instead of CloudFront domain
  # Requires BOTH CustomDomainName and CustomDomainCertificateArn to be provided
  HasCustomDomain: !And
    - !Not [!Equals [!Ref CustomDomainName, ""]]
    - !Not [!Equals [!Ref CustomDomainCertificateArn, ""]]
  # IsProductionDomain: When true, this is the production domain (build.getkanu.com)
  # Used for stricter rate limiting on production
  IsProductionDomain: !And
    - !Condition HasCustomDomain
    - !Equals [
        !Ref CustomDomainName,
        !FindInMap [KANUDomains, Hostnames, Production],
      ]
  # EnableAutoUpdateCondition: When true, create EventBridge scheduled rule for auto-update
  EnableAutoUpdateCondition: !Equals [!Ref EnableAutoUpdate, "true"]
  # IsSmsMfaEnabled: When true, enable SMS MFA infrastructure (Cognito SMS role, SMS config)
  IsSmsMfaEnabled: !Or
    - !And
      - !Condition HasCustomDomain
      - !Equals [!Ref CustomDomainName, !FindInMap [KANUDomains, Hostnames, Beta]]
    - !And
      - !Condition HasCustomDomain
      - !Equals [!Ref CustomDomainName, !FindInMap [KANUDomains, Hostnames, Production]]

Resources:
  # ==============================================
  # NETWORK LAYER - Subnet Allocation
  # ==============================================

  # Lambda to find available CIDR ranges (auto-cidr mode)
  CIDRAllocatorFunction:
    Type: AWS::Lambda::Function
    Condition: CreateSubnetsAutoCIDR
    Properties:
      FunctionName: !Sub "${AWS::StackName}-cidr-allocator"
      Runtime: python3.12
      Handler: index.handler
      Timeout: 30
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Role: !GetAtt CIDRAllocatorFunctionRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import ipaddress

          def handler(event, context):
              try:
                  vpc_id = event['ResourceProperties']['VpcId']
                  physical_id = f"cidr-allocator-{vpc_id}"
                  
                  if event['RequestType'] == 'Delete':
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
                      return

                  ec2 = boto3.client('ec2')

                  # Get ALL VPC CIDR blocks (primary + secondary)
                  vpc_response = ec2.describe_vpcs(VpcIds=[vpc_id])
                  vpc_cidr_blocks = []

                  # Add primary CIDR
                  vpc_cidr_blocks.append(vpc_response['Vpcs'][0]['CidrBlock'])

                  # Add secondary CIDRs if they exist
                  for assoc in vpc_response['Vpcs'][0].get('CidrBlockAssociationSet', []):
                      if assoc['CidrBlockState']['State'] == 'associated':
                          cidr = assoc['CidrBlock']
                          if cidr not in vpc_cidr_blocks:
                              vpc_cidr_blocks.append(cidr)

                  # Sort by size (largest first) to prefer larger CIDR blocks
                  vpc_networks = sorted(
                      [ipaddress.ip_network(cidr) for cidr in vpc_cidr_blocks],
                      key=lambda net: net.num_addresses,
                      reverse=True
                  )

                  # Get existing Internet Gateway for this VPC
                  igw_response = ec2.describe_internet_gateways(Filters=[{'Name': 'attachment.vpc-id', 'Values': [vpc_id]}])
                  if not igw_response['InternetGateways']:
                      raise Exception(f'VPC {vpc_id} does not have an Internet Gateway. For auto-cidr mode, the VPC must have an existing IGW. Either: 1) Create an IGW for this VPC, or 2) Use manual-cidr mode instead.')

                  existing_igw_id = igw_response['InternetGateways'][0]['InternetGatewayId']

                  # Get existing subnets
                  subnets_response = ec2.describe_subnets(Filters=[{'Name': 'vpc-id', 'Values': [vpc_id]}])
                  existing_cidrs = [ipaddress.ip_network(s['CidrBlock']) for s in subnets_response['Subnets']]

                  # Find 4 available subnets - try /24, then /25, then /26 if needed
                  available_cidrs = []
                  subnet_size = None

                  # Try progressively smaller subnet sizes until we find 4 available
                  for prefix_length in [24, 25, 26, 27]:
                      if len(available_cidrs) >= 4:
                          break

                      available_cidrs = []  # Reset and try this size
                      for vpc_network in vpc_networks:
                          if len(available_cidrs) >= 4:
                              break

                          # Skip if this CIDR is too small to subdivide to this prefix
                          if vpc_network.prefixlen > prefix_length:
                              continue

                          # Try to allocate subnets from this CIDR block
                          try:
                              for subnet in vpc_network.subnets(new_prefix=prefix_length):
                                  # Check if this subnet overlaps with any existing subnet
                                  overlaps = any(subnet.overlaps(existing) for existing in existing_cidrs)
                                  if not overlaps:
                                      available_cidrs.append(str(subnet))
                                      if len(available_cidrs) == 4:
                                          subnet_size = f"/{prefix_length}"
                                          break
                          except ValueError as e:
                              # Can't subdivide this block to this size, skip it
                              print(f"Cannot subdivide {vpc_network} to /{prefix_length}: {str(e)}")
                              continue

                      if len(available_cidrs) >= 4:
                          subnet_size = f"/{prefix_length}"
                          print(f"Successfully allocated 4 {subnet_size} subnets: {available_cidrs}")
                          break

                  if len(available_cidrs) < 4:
                      cidr_summary = ', '.join([f"{net} (/{net.prefixlen})" for net in vpc_networks])
                      raise Exception(f'Could not find 4 available subnets in VPC. VPC has {len(vpc_cidr_blocks)} CIDR block(s): {cidr_summary}. Tried /24, /25, /26, /27 subnet sizes. Found only {len(available_cidrs)} available subnet(s). Solution: Add a larger secondary CIDR block (e.g., /16 or /20) to the VPC, or reduce the number of required subnets.')

                  response_data = {
                      'PublicSubnet1CIDR': available_cidrs[0],
                      'PublicSubnet2CIDR': available_cidrs[1],
                      'PrivateSubnet1CIDR': available_cidrs[2],
                      'PrivateSubnet2CIDR': available_cidrs[3],
                      'ExistingIGW': existing_igw_id
                  }

                  cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, physical_id)
              except Exception as e:
                  print(f"Error: {str(e)}")
                  # Use event physical resource id if available (for updates/deletes), otherwise generate one
                  physical_id = event.get('PhysicalResourceId', f"cidr-allocator-{event['ResourceProperties'].get('VpcId', 'unknown')}")
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, physical_id)

  CIDRAllocatorFunctionRole:
    Type: AWS::IAM::Role
    Condition: CreateSubnetsAutoCIDR
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: DescribeVPCAndSubnets
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeVpcs
                  - ec2:DescribeSubnets
                  - ec2:DescribeInternetGateways
                Resource: "*"

  CIDRAllocator:
    Type: Custom::CIDRAllocator
    Condition: CreateSubnetsAutoCIDR
    Properties:
      ServiceToken: !GetAtt CIDRAllocatorFunction.Arn
      VpcId: !Ref VpcId

  # Public Subnet 1
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !If
        - CreateSubnetsManualCIDR
        - !Ref PublicSubnet1CIDR
        - !GetAtt CIDRAllocator.PublicSubnet1CIDR
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-public-subnet-1"

  # Public Subnet 2
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !If
        - CreateSubnetsManualCIDR
        - !Ref PublicSubnet2CIDR
        - !GetAtt CIDRAllocator.PublicSubnet2CIDR
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-public-subnet-2"

  # Private Subnet 1
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !If
        - CreateSubnetsManualCIDR
        - !Ref PrivateSubnet1CIDR
        - !GetAtt CIDRAllocator.PrivateSubnet1CIDR
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-private-subnet-1"

  # Private Subnet 2
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !If
        - CreateSubnetsManualCIDR
        - !Ref PrivateSubnet2CIDR
        - !GetAtt CIDRAllocator.PrivateSubnet2CIDR
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-private-subnet-2"

  # Internet Gateway (only create for manual-cidr mode - auto-cidr uses existing)
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Condition: CreateSubnetsManualCIDR
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-igw"

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Condition: CreateSubnetsManualCIDR
    Properties:
      VpcId: !Ref VpcId
      InternetGatewayId: !Ref InternetGateway

  # Public Route Table
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-public-rt"

  PublicRoute:
    Type: AWS::EC2::Route
    Condition: CreateSubnets
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !If
        - CreateSubnetsAutoCIDR
        - !GetAtt CIDRAllocator.ExistingIGW
        - !Ref InternetGateway

  PublicSubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: CreateSubnets
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  PublicSubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: CreateSubnets
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  # ==============================================
  # DATA LAYER - DynamoDB Tables
  # ==============================================

  # ==============================================
  # TASKS TABLE - ABAC-enabled with LeadingKeys
  # ==============================================
  # PK: pk (format: org_id#task_id) enables IAM LeadingKeys condition
  # IAM policy can restrict access to: dynamodb:LeadingKeys = ${aws:PrincipalTag/org_id}#*
  TasksTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-tasks-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#task_id (composite for ABAC LeadingKeys)
          AttributeType: S
        - AttributeName: task_id # For reverse lookup GSI
          AttributeType: S
        - AttributeName: user_id
          AttributeType: S
        - AttributeName: company_id
          AttributeType: S
        - AttributeName: created_at
          AttributeType: S
        - AttributeName: org_id # For org-level queries
          AttributeType: S
      KeySchema:
        - AttributeName: pk # org_id#task_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        # Reverse lookup by task_id (when org_id is not known)
        - IndexName: task-id-index
          KeySchema:
            - AttributeName: task_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
        # Note: user-index (without sort key) is deprecated - use user-created-index instead
        - IndexName: user-index
          KeySchema:
            - AttributeName: user_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
        # New GSI with created_at sort key for proper chronological ordering
        - IndexName: user-created-index
          KeySchema:
            - AttributeName: user_id
              KeyType: HASH
            - AttributeName: created_at
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: company-index
          KeySchema:
            - AttributeName: company_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
        # GSI for org-level queries with chronological ordering
        - IndexName: org-created-index
          KeySchema:
            - AttributeName: org_id
              KeyType: HASH
            - AttributeName: created_at
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-tasks-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # TASK DECISIONS TABLE - ABAC-enabled with LeadingKeys
  # ==============================================
  # PK: pk (format: org_id#task_id), SK: decision_id
  # Groups decisions by task for efficient queries, enables ABAC via LeadingKeys
  TaskDecisionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-task-decisions-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#task_id (composite for ABAC LeadingKeys)
          AttributeType: S
        - AttributeName: decision_id # Sort key for individual decision lookup
          AttributeType: S
      KeySchema:
        - AttributeName: pk # org_id#task_id
          KeyType: HASH
        - AttributeName: decision_id
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-task-decisions-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # TASK ACTIVITY LOGS TABLE - ABAC-enabled with LeadingKeys
  # ==============================================
  # PK: pk (format: org_id#task_id), SK: timestamp
  # Enables ABAC via LeadingKeys while maintaining chronological ordering
  TaskActivityLogsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-task-activity-logs-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#task_id (composite for ABAC LeadingKeys)
          AttributeType: S
        - AttributeName: timestamp
          AttributeType: S
      KeySchema:
        - AttributeName: pk # org_id#task_id
          KeyType: HASH
        - AttributeName: timestamp
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-task-activity-logs-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # CHAT MESSAGES TABLE - ABAC-enabled with LeadingKeys
  # ==============================================
  # PK: pk (format: org_id#task_id), SK: sent_at
  # Enables ABAC via LeadingKeys while maintaining chronological message ordering
  ChatMessagesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-chat-messages-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#task_id (composite for ABAC LeadingKeys)
          AttributeType: S
        - AttributeName: sent_at
          AttributeType: S
      KeySchema:
        - AttributeName: pk # org_id#task_id
          KeyType: HASH
        - AttributeName: sent_at
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-chat-messages-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # USAGE METRICS TABLE - ABAC-enabled with LeadingKeys
  # ==============================================
  # Tracks token usage and costs per user/org for billing and analytics
  # PK: pk (format: org_id#YYYY-MM), SK: timestamp#user_id#session_id
  # Partitioned by org and month for efficient org-level queries
  UsageMetricsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-usage-metrics-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#YYYY-MM (composite for ABAC + monthly partitioning)
          AttributeType: S
        - AttributeName: sk # timestamp#user_id#session_id
          AttributeType: S
        - AttributeName: user_id # For user-level queries via GSI
          AttributeType: S
        - AttributeName: org_id # For org-level queries via GSI
          AttributeType: S
        - AttributeName: timestamp # For time-range queries
          AttributeType: S
      KeySchema:
        - AttributeName: pk # org_id#YYYY-MM
          KeyType: HASH
        - AttributeName: sk # timestamp#user_id#session_id
          KeyType: RANGE
      GlobalSecondaryIndexes:
        # Query by user across all months
        - IndexName: user-metrics-index
          KeySchema:
            - AttributeName: user_id
              KeyType: HASH
            - AttributeName: timestamp
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        # Query by org across all months (for org-level aggregation)
        - IndexName: org-metrics-index
          KeySchema:
            - AttributeName: org_id
              KeyType: HASH
            - AttributeName: timestamp
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-usage-metrics-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Deployment coordination locks for cross-agent resource locking
  # Prevents concurrent CDK/Terraform deployments from conflicting on same AWS resources
  DeploymentLocksTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-deployment-locks-${AWS::StackName}"
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
        - AttributeName: sk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH
        - AttributeName: sk
          KeyType: RANGE
      BillingMode: PAY_PER_REQUEST
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-deployment-locks"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # ECR REPOSITORIES (Customer-Local Image Storage)
  # ==============================================
  # Images are copied from KANU's ECR to these customer-owned repositories
  # during stack deployment. This provides:
  # - Faster task startup (no cross-account pulls)
  # - No runtime dependency on KANU's ECR availability
  # - Customer control over their images

  WebAppRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub "${AWS::StackName}-repo-webapp"
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
        LifecyclePolicyText: |
          {
            "rules": [
              {
                "rulePriority": 1,
                "description": "Keep only the latest 5 images",
                "selection": {
                  "tagStatus": "any",
                  "countType": "imageCountMoreThan",
                  "countNumber": 5
                },
                "action": {
                  "type": "expire"
                }
              }
            ]
          }
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-repo-webapp"
        - Key: Environment
          Value: !Ref EnvironmentName

  OrchestratorRepository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub "${AWS::StackName}-repo-orchestrator"
      ImageScanningConfiguration:
        ScanOnPush: true
      EncryptionConfiguration:
        EncryptionType: AES256
      LifecyclePolicy:
        LifecyclePolicyText: |
          {
            "rules": [
              {
                "rulePriority": 1,
                "description": "Keep only the latest 5 images",
                "selection": {
                  "tagStatus": "any",
                  "countType": "imageCountMoreThan",
                  "countNumber": 5
                },
                "action": {
                  "type": "expire"
                }
              }
            ]
          }
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-repo-orchestrator"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # IMAGE COPY INFRASTRUCTURE (CodeBuild + Lambda)
  # ==============================================
  # Copies images from KANU's ECR to customer's ECR during deployment

  ImageCopyBuildRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-image-copy-build-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: codebuild.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ECRPullFromSource
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ecr:GetAuthorizationToken
                Resource: "*"
              # Allow pulling from KANU prod ECR (cross-account for self-hosted customers)
              - Effect: Allow
                Action:
                  - ecr:BatchGetImage
                  - ecr:GetDownloadUrlForLayer
                  - ecr:BatchCheckLayerAvailability
                Resource:
                  - "arn:aws:ecr:us-east-1:086715933510:repository/kanu-webapp"
                  - "arn:aws:ecr:us-east-1:086715933510:repository/kanu-python-orchestrator"
              # Allow pulling from any kanu-* repo in same account (for dev/internal deployments)
              - Effect: Allow
                Action:
                  - ecr:BatchGetImage
                  - ecr:GetDownloadUrlForLayer
                  - ecr:BatchCheckLayerAvailability
                Resource:
                  - !Sub "arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/kanu-*"
        - PolicyName: ECRPushToCustomer
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ecr:BatchCheckLayerAvailability
                  - ecr:GetDownloadUrlForLayer
                  - ecr:BatchGetImage
                  - ecr:PutImage
                  - ecr:InitiateLayerUpload
                  - ecr:UploadLayerPart
                  - ecr:CompleteLayerUpload
                Resource:
                  - !GetAtt WebAppRepository.Arn
                  - !GetAtt OrchestratorRepository.Arn
        - PolicyName: CloudWatchLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/${AWS::StackName}-image-copy*"
        - PolicyName: SSMVersionParameter
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ssm:PutParameter
                Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/platform-version"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-image-copy-build-role"

  ImageCopyProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Name: !Sub "${AWS::StackName}-image-copy"
      Description: "Copies KANU images from source ECR to customer's ECR"
      ServiceRole: !GetAtt ImageCopyBuildRole.Arn
      TimeoutInMinutes: 15
      Environment:
        Type: LINUX_CONTAINER
        ComputeType: BUILD_GENERAL1_SMALL
        Image: aws/codebuild/standard:7.0
        PrivilegedMode: true
        EnvironmentVariables:
          - Name: STACK_NAME
            Value: !Ref AWS::StackName
          - Name: SRC_WEBAPP_IMAGE
            Value: !Ref WebAppImage
          - Name: SRC_ORCHESTRATOR_IMAGE
            Value: !Ref PythonOrchestratorImage
          - Name: CUSTOMER_REGISTRY
            Value: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com"
          - Name: DST_WEBAPP_REPO
            Value: !Ref WebAppRepository
          - Name: DST_ORCHESTRATOR_REPO
            Value: !Ref OrchestratorRepository
      Artifacts:
        Type: NO_ARTIFACTS
      Source:
        Type: NO_SOURCE
        BuildSpec: |
          version: 0.2
          phases:
            pre_build:
              commands:
                - echo "Source webapp image - $SRC_WEBAPP_IMAGE"
                - echo "Source orchestrator image - $SRC_ORCHESTRATOR_IMAGE"
                - echo "Destination registry - $CUSTOMER_REGISTRY"
                - echo "Destination webapp repo - $DST_WEBAPP_REPO"
                - echo "Destination orchestrator repo - $DST_ORCHESTRATOR_REPO"
                - export SRC_REGISTRY=$(echo $SRC_WEBAPP_IMAGE | cut -d'/' -f1)
                - echo "Source registry - $SRC_REGISTRY"
                - export SRC_REGION=$(echo $SRC_REGISTRY | sed 's/.*\.ecr\.\([^.]*\)\.amazonaws\.com/\1/')
                - echo "Source region - $SRC_REGION"
                - echo "Logging into source ECR..."
                - aws ecr get-login-password --region $SRC_REGION | docker login --username AWS --password-stdin $SRC_REGISTRY
                - echo "Logging into destination ECR..."
                - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $CUSTOMER_REGISTRY
            build:
              commands:
                - echo "Copying webapp image..."
                - docker pull $SRC_WEBAPP_IMAGE
                - docker tag $SRC_WEBAPP_IMAGE $CUSTOMER_REGISTRY/$DST_WEBAPP_REPO:latest
                - docker push $CUSTOMER_REGISTRY/$DST_WEBAPP_REPO:latest
                - echo "Copying orchestrator image..."
                - docker pull $SRC_ORCHESTRATOR_IMAGE
                - docker tag $SRC_ORCHESTRATOR_IMAGE $CUSTOMER_REGISTRY/$DST_ORCHESTRATOR_REPO:latest
                - docker push $CUSTOMER_REGISTRY/$DST_ORCHESTRATOR_REPO:latest
            post_build:
              commands:
                - echo "Extracting version from source image tag..."
                - export VERSION_TAG=$(echo $SRC_WEBAPP_IMAGE | grep -oE 'sha-[a-f0-9]+' || echo "unknown")
                - echo "Platform version - $VERSION_TAG"
                - echo "Writing version to SSM Parameter Store..."
                - aws ssm put-parameter --name "/$STACK_NAME/platform-version" --value "$VERSION_TAG" --type String --overwrite
                - echo "Image copy complete"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-image-copy"

  ImageCopyLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-image-copy-lambda-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: CodeBuildAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - codebuild:StartBuild
                  - codebuild:BatchGetBuilds
                Resource: !GetAtt ImageCopyProject.Arn
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-image-copy-lambda-role"

  ImageCopyLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-image-copy"
      Description: "Triggers CodeBuild to copy images and waits for completion"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt ImageCopyLambdaRole.Arn
      Timeout: 900
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Environment:
        Variables:
          CODEBUILD_PROJECT: !Ref ImageCopyProject
      Code:
        ZipFile: |
          import boto3
          import json
          import time
          import os
          import urllib.request

          codebuild = boto3.client('codebuild')

          def send_response(event, context, status, data=None, reason=None):
              response_body = {
                  'Status': status,
                  'Reason': reason or f'See CloudWatch Log Stream: {context.log_stream_name}',
                  'PhysicalResourceId': event.get('PhysicalResourceId', context.log_stream_name),
                  'StackId': event['StackId'],
                  'RequestId': event['RequestId'],
                  'LogicalResourceId': event['LogicalResourceId'],
                  'Data': data or {}
              }
              
              req = urllib.request.Request(
                  event['ResponseURL'],
                  data=json.dumps(response_body).encode('utf-8'),
                  headers={'Content-Type': 'application/json'},
                  method='PUT'
              )
              urllib.request.urlopen(req)

          def handler(event, context):
              print(f"Event: {json.dumps(event)}")
              
              request_type = event['RequestType']
              
              # Delete doesn't need to copy images
              if request_type == 'Delete':
                  send_response(event, context, 'SUCCESS')
                  return
              
              # Create or Update - copy images
              try:
                  project_name = os.environ['CODEBUILD_PROJECT']
                  
                  # Start build (source images are configured in CodeBuild env vars)
                  print(f"Starting CodeBuild project: {project_name}")
                  response = codebuild.start_build(projectName=project_name)
                  build_id = response['build']['id']
                  print(f"Build started: {build_id}")
                  
                  # Wait for build to complete (poll every 10 seconds)
                  # Reserve 30 seconds for sending CloudFormation response
                  min_remaining_ms = 30000
                  while True:
                      # Check if we're running out of time before sleeping
                      remaining_ms = context.get_remaining_time_in_millis()
                      if remaining_ms < min_remaining_ms:
                          print(f"Lambda timeout approaching ({remaining_ms}ms remaining), sending FAILED response")
                          send_response(event, context, 'FAILED', reason=f'Lambda timeout exceeded while waiting for build {build_id}. Check CodeBuild logs.')
                          return
                      
                      time.sleep(10)
                      builds = codebuild.batch_get_builds(ids=[build_id])
                      build = builds['builds'][0]
                      status = build['buildStatus']
                      print(f"Build status: {status}")
                      
                      if status == 'SUCCEEDED':
                          send_response(event, context, 'SUCCESS', {'BuildId': build_id})
                          return
                      elif status in ['FAILED', 'FAULT', 'TIMED_OUT', 'STOPPED']:
                          send_response(event, context, 'FAILED', reason=f'Build {status}: {build_id}')
                          return
                      # IN_PROGRESS - continue waiting
                      
              except Exception as e:
                  print(f"Error: {str(e)}")
                  send_response(event, context, 'FAILED', reason=str(e))
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-image-copy-lambda"

  ImageCopyCustomResource:
    Type: Custom::ImageCopy
    DependsOn:
      - WebAppRepository
      - OrchestratorRepository
      - ImageCopyProject
    Properties:
      ServiceToken: !GetAtt ImageCopyLambda.Arn
      # Timeout for CloudFormation to wait for response (default is 1 hour)
      # Set to 10 minutes - image copy typically completes in 2-5 minutes
      ServiceTimeout: 600
      # Include source images to trigger update when they change
      WebAppImage: !Ref WebAppImage
      OrchestratorImage: !Ref PythonOrchestratorImage

  GitTokenEncryptionKey:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub "${AWS::StackName}-git-encryption-key"
      Description: "AES-256 encryption key for GitLab/GitHub PAT tokens (32-byte hex key)"
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: "key"
        PasswordLength: 64
        # Generate hex-only string (0-9, a-f) for AES-256 compatibility
        ExcludeCharacters: 'ghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?/\~`" '''
        RequireEachIncludedType: false
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-git-encryption-key"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Anthropic API Key secret for Claude Code generation (only created if key is provided)
  AnthropicApiKeySecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasAnthropicApiKey
    Properties:
      Name: !Sub "${AWS::StackName}-anthropic-api-key"
      Description: "Anthropic API key for Claude Code generation tasks"
      SecretString: !Ref AnthropicApiKey
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-anthropic-api-key"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Stripe API secrets for billing (only created if Stripe is configured)
  StripeApiKeySecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasStripeConfig
    Properties:
      Name: !Sub "${AWS::StackName}-stripe-api-key"
      Description: "Stripe secret API key for billing"
      SecretString: !Ref StripeSecretKey
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-stripe-api-key"
        - Key: Environment
          Value: !Ref EnvironmentName

  StripeWebhookSecretSecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasStripeConfig
    Properties:
      Name: !Sub "${AWS::StackName}-stripe-webhook-secret"
      Description: "Stripe webhook signing secret"
      SecretString: !Ref StripeWebhookSecret
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-stripe-webhook-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  # reCAPTCHA secret key (only created if reCAPTCHA is configured)
  RecaptchaSecretKeySecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasRecaptchaConfig
    Properties:
      Name: !Sub "${AWS::StackName}-recaptcha-secret-key"
      Description: "Google reCAPTCHA v3 secret key for server-side verification"
      SecretString: !Ref RecaptchaSecretKey
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-recaptcha-secret-key"
        - Key: Environment
          Value: !Ref EnvironmentName

  # GitHub App secrets (only created when direct GitHub App integration is configured)
  GitHubAppClientSecretSecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasGitHubApp
    Properties:
      Name: !Sub "${AWS::StackName}-github-app-client-secret"
      Description: "GitHub App OAuth Client Secret"
      SecretString: !Ref GitHubAppClientSecret
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-github-app-client-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  # NOTE: GitHubAppPrivateKeySecret is NOT managed by CloudFormation.
  # The deploy workflow creates/updates it directly in Secrets Manager as:
  #   ${StackName}-github-app-private-key
  # This bypasses the 4096-char CloudFormation parameter limit for PEM keys.

  # GitHub Auth Proxy secret (only created when proxy mode is configured)
  GitHubProxySecretSecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasGitHubProxy
    Properties:
      Name: !Sub "${AWS::StackName}-github-proxy-secret"
      Description: "Shared HMAC secret for GitHub auth proxy communication"
      SecretString: !Ref GitHubProxySecret
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-github-proxy-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  GitLabAppClientSecretSecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasGitLab
    Properties:
      Name: !Sub "${AWS::StackName}-gitlab-app-client-secret"
      Description: "GitLab OAuth Application Secret"
      SecretString: !Ref GitLabAppClientSecret
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-gitlab-app-client-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  # HMAC secret for GitLab OAuth proxy communication.
  # Used for both credential helper ↔ webapp and self-hosted ↔ Kanu prod auth.
  # Provided via the GitLabProxySecret CloudFormation parameter (set in GitHub secrets).
  GitLabProxySecretSecret:
    Type: AWS::SecretsManager::Secret
    Condition: HasGitLabProxySecret
    Properties:
      Name: !Sub "${AWS::StackName}-gitlab-proxy-secret"
      Description: "HMAC secret for GitLab OAuth proxy communication"
      SecretString: !Ref GitLabProxySecret
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-gitlab-proxy-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Origin verification secret to prevent direct ALB access
  OriginVerifySecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub "${AWS::StackName}-origin-verify"
      Description: "Secret header value to verify requests come from CloudFront (prevents direct ALB access)"
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: "secret"
        PasswordLength: 64
        RequireEachIncludedType: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-origin-verify"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ExternalId secret for SelfHosted role assumption
  # Prevents unauthorized processes from assuming the SelfHosted PowerUser/ReadOnly roles
  # even if they obtain the GenerationTaskRole (defense-in-depth alongside trust policy scoping)
  SelfHostedAssumeSecret:
    Type: AWS::SecretsManager::Secret
    # Bootstrap dependency: older deployed AutoUpdateCfnServiceRole may still deny
    # secretsmanager:GetRandomPassword. Updating the role first removes the deny so
    # GenerateSecretString succeeds on the already-assumed CloudFormation session.
    DependsOn: AutoUpdateCfnServiceRole
    Properties:
      Name: !Sub "${AWS::StackName}-selfhosted-assume-secret"
      Description: "ExternalId for SelfHosted role assumption — only KANU orchestrator tasks launched by the webapp know this value"
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: "secret"
        PasswordLength: 64
        ExcludePunctuation: true
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-selfhosted-assume-secret"
        - Key: Environment
          Value: !Ref EnvironmentName

  # KMS Key for encrypting sensitive data (ExternalId secrets in DynamoDB)
  # Note: ECS task role permissions are granted via IAM policy, not resource-based policy
  SecretsEncryptionKey:
    Type: AWS::KMS::Key
    Properties:
      Description: !Sub "KMS key for encrypting KANU platform secrets (${AWS::StackName})"
      EnableKeyRotation: true
      KeyPolicy:
        Version: "2012-10-17"
        Statement:
          # Allow root account full access (required)
          - Sid: RootAccountFullAccess
            Effect: Allow
            Principal:
              AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root"
            Action: "kms:*"
            Resource: "*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-secrets-key"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Alias for the KMS key (makes it easier to reference)
  SecretsEncryptionKeyAlias:
    Type: AWS::KMS::Alias
    Properties:
      AliasName: !Sub "alias/${AWS::StackName}-secrets"
      TargetKeyId: !Ref SecretsEncryptionKey

  # ==============================================
  # BEDROCK GUARDRAILS
  # ==============================================

  # Bedrock Guardrail for content safety and security validation
  # Protects against: harmful content, malicious code, resource abuse
  KanuBedrockGuardrail:
    Type: AWS::Bedrock::Guardrail
    Properties:
      Name: !Sub "${AWS::StackName}-security-guardrail"
      Description: "Content safety guardrail for KANU AI platform - blocks harmful content and security threats"
      BlockedInputMessaging: "Your request has been blocked by content safety filters. Please ensure your request follows our guidelines and try again."
      BlockedOutputsMessaging: "The generated content has been blocked by safety filters. Please try rephrasing your request."

      # Content filters for harmful content (built-in ML models)
      # NOTE: SEXUAL, VIOLENCE, INSULTS removed - they triggered false positives
      # on code containing variable names, comments, or game/security-related logic.
      ContentPolicyConfig:
        FiltersConfig:
          # Hate speech and discrimination
          - Type: HATE
            InputStrength: HIGH
            OutputStrength: HIGH

          # Misconduct (illegal activities, scams)
          - Type: MISCONDUCT
            InputStrength: MEDIUM
            OutputStrength: MEDIUM

      # Custom denied topics for KANU-specific threats
      TopicPolicyConfig:
        TopicsConfig:
          # Malicious code and malware
          - Name: MaliciousCode
            Definition: "Creating viruses, malware, ransomware, backdoors, or harmful software"
            Examples:
              - "create a virus to delete files"
              - "build ransomware that encrypts data"
              - "write a keylogger script"
              - "make a backdoor for remote access"
              - "create a botnet command and control"
            Type: DENY

      # Sensitive information filters (PII detection)
      SensitiveInformationPolicyConfig:
        PiiEntitiesConfig:
          # Credit card numbers
          - Type: CREDIT_DEBIT_CARD_NUMBER
            Action: BLOCK

          # Social Security Numbers
          - Type: US_SOCIAL_SECURITY_NUMBER
            Action: BLOCK

          # Email addresses (anonymize but don't block)
          # - Type: EMAIL
          #   Action: ANONYMIZE

          # # Phone numbers (anonymize but don't block)
          # - Type: PHONE
          #   Action: ANONYMIZE

          # Driver's licenses
          - Type: DRIVER_ID
            Action: BLOCK

          # Passport numbers
          - Type: US_PASSPORT_NUMBER
            Action: BLOCK

      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-bedrock-guardrail"
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: ManagedBy
          Value: CloudFormation

  # Create a version of the guardrail for production use
  KanuBedrockGuardrailVersion:
    Type: AWS::Bedrock::GuardrailVersion
    Properties:
      GuardrailIdentifier: !GetAtt KanuBedrockGuardrail.GuardrailId
      Description: !Sub "Production version v6 - deployed ${AWS::StackName}"

  UserRepoConfigsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-user-repo-configs-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: user_id
          AttributeType: S
      KeySchema:
        - AttributeName: user_id
          KeyType: HASH
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-user-repo-configs-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: EncryptionNote
          Value: "PAT-tokens-encrypted-with-AES-256-GCM"

  # ==============================================
  # SELF-SERVICE - Cognito User Pool (Conditional)
  # ==============================================

  # SMS MFA conditionally enabled for beta and production domains
  # This role is only created when IsSmsMfaEnabled condition is true
  CognitoSmsRole:
    Type: AWS::IAM::Role
    Condition: IsSmsMfaEnabled
    Properties:
      RoleName: !Sub "${AWS::StackName}-cognito-sms-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: cognito-idp.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                "sts:ExternalId": !Sub "${AWS::StackName}-cognito-sms"
                "aws:SourceAccount": !Ref "AWS::AccountId"
              ArnLike:
                "aws:SourceArn": !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/*"
      Policies:
        - PolicyName: CognitoSnsPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sns:Publish
                  - sns:GetSMSAttributes
                  - sns:SetSMSAttributes
                  - sns:CheckIfPhoneNumberIsOptedOut
                Resource: "*"
                Condition:
                  StringEquals:
                    "aws:RequestedRegion": !Ref "AWS::Region"
              # Pinpoint SMS Voice V2 permissions - required because AWS routes
              # Cognito SMS delivery through Pinpoint SMS V2 under the hood
              - Effect: Allow
                Action:
                  - sms-voice:SendTextMessage
                  - sms-voice:DescribePhoneNumbers
                  - sms-voice:DescribePools
                  - sms-voice:DescribeAccountAttributes
                Resource: "*"
                Condition:
                  StringEquals:
                    "aws:RequestedRegion": !Ref "AWS::Region"
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: ManagedBy
          Value: KANU

  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    DependsOn:
      - CloudFrontDistribution
    Properties:
      UserPoolName: !Sub "${AWS::StackName}-users"
      MfaConfiguration: "OPTIONAL"
      EnabledMfas: !If
        - IsSmsMfaEnabled
        - - SOFTWARE_TOKEN_MFA
          - SMS_MFA
        - - SOFTWARE_TOKEN_MFA
      SmsConfiguration: !If
        - IsSmsMfaEnabled
        - SnsCallerArn: !GetAtt CognitoSmsRole.Arn
          ExternalId: !Sub "${AWS::StackName}-cognito-sms"
          SnsRegion: !Ref AWS::Region
        - !Ref "AWS::NoValue"
      AutoVerifiedAttributes: !If
        - IsSmsMfaEnabled
        - - email
          - phone_number
        - - email
      SmsVerificationMessage: !If
        - IsSmsMfaEnabled
        - "Your KANU verification code is {####}. Reply STOP to opt out."
        - !Ref "AWS::NoValue"
      SmsAuthenticationMessage: !If
        - IsSmsMfaEnabled
        - "Your KANU sign-in code is {####}. Reply STOP to opt out."
        - !Ref "AWS::NoValue"
      UsernameAttributes:
        - email
      Schema:
        - Name: email
          Required: true
          Mutable: false
        # phone_number is optional; verified via SMS when user sets up SMS MFA (IsSmsMfaEnabled only)
        - Name: phone_number
          AttributeDataType: String
          Required: false
          Mutable: true
        - Name: org_id
          AttributeDataType: String
          Mutable: true
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: verified_email
            Priority: 1
          # verified_phone_number requires SmsConfiguration
      Policies:
        PasswordPolicy:
          MinimumLength: 12
          RequireLowercase: true
          RequireUppercase: true
          RequireNumbers: true
          RequireSymbols: true
      AdminCreateUserConfig:
        # SelfHosted: admin-only signup (users are invited by admin)
        # SelfServiceAndSelfHosted: allow self-service signup via the SignUp API
        AllowAdminCreateUserOnly: !If [IncludeMultiTenantGroups, false, true]
        InviteMessageTemplate:
          EmailSubject: "You've been invited to KANU"
          EmailMessage: !Sub
            - |
              <!DOCTYPE html>
              <html>
              <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
              </head>
              <body style="margin: 0; padding: 0; background-color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
                <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f4f4f5;">
                  <tr>
                    <td align="center" style="padding: 40px 20px;">
                      <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);">
                        <!-- Header -->
                        <tr>
                          <td style="padding: 32px 32px 24px 32px; text-align: center; border-bottom: 1px solid #e4e4e7;">
                            <h1 style="margin: 0; font-size: 28px; font-weight: 700; color: #18181b; letter-spacing: -0.5px;">KANU</h1>
                          </td>
                        </tr>
                        <!-- Content -->
                        <tr>
                          <td style="padding: 32px;">
                            <h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited!</h2>
                            <p style="margin: 0 0 24px 0; font-size: 15px; line-height: 1.6; color: #52525b;">
                              You have been invited to join KANU, the AI-powered infrastructure automation platform.
                            </p>
                            <!-- Credentials Box -->
                            <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f4f4f5; border-radius: 8px; margin-bottom: 24px;">
                              <tr>
                                <td style="padding: 20px;">
                                  <p style="margin: 0 0 12px 0; font-size: 13px; font-weight: 600; color: #71717a; text-transform: uppercase; letter-spacing: 0.5px;">Your Login Credentials</p>
                                  <p style="margin: 0 0 8px 0; font-size: 14px; color: #52525b;"><strong style="color: #18181b;">Username:</strong> {username}</p>
                                  <p style="margin: 0 0 8px 0; font-size: 14px; color: #52525b;"><strong style="color: #18181b;">Temporary Password:</strong> {####}</p>
                                </td>
                              </tr>
                            </table>
                            <!-- CTA Button -->
                            <table role="presentation" width="100%" cellspacing="0" cellpadding="0">
                              <tr>
                                <td align="center">
                                  <a href="https://${AppDomain}" style="display: inline-block; padding: 14px 32px; background-color: #2563eb; color: #ffffff; text-decoration: none; font-size: 15px; font-weight: 600; border-radius: 8px;">Sign In to KANU</a>
                                </td>
                              </tr>
                            </table>
                            <p style="margin: 24px 0 0 0; font-size: 13px; line-height: 1.5; color: #71717a; text-align: center;">
                              You'll be prompted to change your password on first login.
                            </p>
                          </td>
                        </tr>
                        <!-- Footer -->
                        <tr>
                          <td style="padding: 24px 32px; border-top: 1px solid #e4e4e7; text-align: center;">
                            <p style="margin: 0; font-size: 12px; color: #a1a1aa;">
                              This invitation was sent by your organization administrator.
                            </p>
                          </td>
                        </tr>
                      </table>
                    </td>
                  </tr>
                </table>
              </body>
              </html>
            - AppDomain:
                !If [
                  HasCustomDomain,
                  !Ref CustomDomainName,
                  !GetAtt CloudFrontDistribution.DomainName,
                ]
      UserPoolTags:
        Environment: !Ref EnvironmentName
        ManagedBy: KANU

  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: !Sub "${AWS::StackName}-webapp-client"
      UserPoolId: !Ref CognitoUserPool
      GenerateSecret: true
      # Token validity settings for security
      # Short access tokens ensure removed/disabled users are locked out quickly
      # Refresh tokens allow seamless re-authentication without user interaction
      AccessTokenValidity: 15 # 15 minutes - balance between security and refresh frequency
      IdTokenValidity: 15 # 15 minutes - contains user claims (groups, org_id)
      RefreshTokenValidity: 7 # 7 days - user re-logins weekly
      TokenValidityUnits:
        AccessToken: minutes
        IdToken: minutes
        RefreshToken: days
      ExplicitAuthFlows:
        - ALLOW_USER_PASSWORD_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
        - ALLOW_USER_SRP_AUTH
      PreventUserExistenceErrors: ENABLED
      ReadAttributes:
        - email
        - email_verified
        - name
        - given_name
        - family_name
        - phone_number
        - phone_number_verified
        - custom:org_id
      WriteAttributes:
        - email
        - name
        - given_name
        - family_name
        - phone_number
        - custom:org_id

  # Admin and Member groups only created in SelfServiceAndSelfHosted mode
  CognitoAdminGroup:
    Type: AWS::Cognito::UserPoolGroup
    Condition: IncludeMultiTenantGroups
    Properties:
      GroupName: Admin
      UserPoolId: !Ref CognitoUserPool
      Description: Organization administrators with full access
      Precedence: 1

  # KanuInternal group for KANU team members with access to internal tools
  CognitoKanuInternalGroup:
    Type: AWS::Cognito::UserPoolGroup
    Condition: IncludeMultiTenantGroups
    Properties:
      GroupName: KanuInternal
      UserPoolId: !Ref CognitoUserPool
      Description: KANU internal team with access to promo codes and internal tools
      Precedence: 0

  CognitoMemberGroup:
    Type: AWS::Cognito::UserPoolGroup
    Condition: IncludeMultiTenantGroups
    Properties:
      GroupName: Member
      UserPoolId: !Ref CognitoUserPool
      Description: Organization members with limited access
      Precedence: 10

  # SelfHosted group is always created (both deployment modes)
  CognitoSelfHostedGroup:
    Type: AWS::Cognito::UserPoolGroup
    Properties:
      GroupName: SelfHosted
      UserPoolId: !Ref CognitoUserPool
      Description: Self-hosted users with default poweruser access
      Precedence: 5

  # ==============================================
  # SELF-SERVICE - DynamoDB Tables
  # ==============================================

  OrganizationsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-organizations-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: org_id
          AttributeType: S
      KeySchema:
        - AttributeName: org_id
          KeyType: HASH
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-organizations-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  SelfServiceUsersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-users-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: user_id
          AttributeType: S
        - AttributeName: org_id
          AttributeType: S
      KeySchema:
        - AttributeName: user_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: org_id-index
          KeySchema:
            - AttributeName: org_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-users-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  AwsAccountsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-aws-accounts-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: account_id
          AttributeType: S
        - AttributeName: org_id
          AttributeType: S
      KeySchema:
        - AttributeName: account_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: org_id-index
          KeySchema:
            - AttributeName: org_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-aws-accounts-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  AwsAccountRolesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-aws-account-roles-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: role_id
          AttributeType: S
        - AttributeName: account_id
          AttributeType: S
      KeySchema:
        - AttributeName: role_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: account_id-index
          KeySchema:
            - AttributeName: account_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-aws-account-roles-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  UserPermissionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-user-permissions-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: permission_id
          AttributeType: S
        - AttributeName: user_id
          AttributeType: S
        - AttributeName: role_id
          AttributeType: S
      KeySchema:
        - AttributeName: permission_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: user_id-index
          KeySchema:
            - AttributeName: user_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
        - IndexName: role_id-index
          KeySchema:
            - AttributeName: role_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-user-permissions-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # SELF-SERVICE BILLING - DynamoDB Tables (SelfServiceAndSelfHosted only)
  # ==============================================
  # These tables track usage for Stripe billing in self-service mode.
  # Marketplace deployments (SelfHosted) don't have these tables - customers
  # use AWS native cost tracking instead.

  # Organization billing settings and Stripe customer reference
  BillingTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "kanu-billing-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: org_id
          AttributeType: S
      KeySchema:
        - AttributeName: org_id
          KeyType: HASH
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-billing-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Monthly usage aggregation per organization
  MonthlyUsageTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "kanu-monthly-usage-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: org_id
          AttributeType: S
        - AttributeName: period
          AttributeType: S
      KeySchema:
        - AttributeName: org_id
          KeyType: HASH
        - AttributeName: period
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-monthly-usage-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Promotional codes for credit grants
  # Admins create promo codes that customers can redeem for Stripe credits
  PromoCodesTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "kanu-promo-codes-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: code
          AttributeType: S
      KeySchema:
        - AttributeName: code
          KeyType: HASH
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-promo-codes-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Track which organizations have redeemed which promo codes
  # Prevents double-redemption per organization
  PromoRedemptionsTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "kanu-promo-redemptions-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: code
          AttributeType: S
        - AttributeName: org_id
          AttributeType: S
      KeySchema:
        - AttributeName: code
          KeyType: HASH
        - AttributeName: org_id
          KeyType: RANGE
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-promo-redemptions-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  RateLimitCountersTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "${AWS::StackName}-rate-limit-counters"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk
          AttributeType: S
      KeySchema:
        - AttributeName: pk
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: expires_at
        Enabled: true
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      Tags:
        - Key: Stack
          Value: !Ref AWS::StackName
        - Key: Purpose
          Value: rate-limiting

  # Infrastructure scan cache for request deduplication across webapp instances
  # Caches paginated resource results to avoid redundant AWS API calls
  # Uses composite key (cache_key + sk) to support chunked storage for large accounts:
  #   - sk: "meta" - Metadata item with status, total_chunks, timestamp
  #   - sk: "chunk#00000", "chunk#00001", etc. - Chunked resource data (~400 resources per chunk)
  InfraScannerCacheTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "kanu-infra-scanner-cache-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: cache_key
          AttributeType: S
        - AttributeName: sk
          AttributeType: S
      KeySchema:
        - AttributeName: cache_key
          KeyType: HASH
        - AttributeName: sk
          KeyType: RANGE
      TimeToLiveSpecification:
        AttributeName: expires_at
        Enabled: true
      Tags:
        - Key: Name
          Value: !Sub "kanu-infra-scanner-cache-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Per-task token usage details with DynamoDB Streams for Stripe billing
  # PK: pk (format: org_id#task_id#uuid) enables:
  #   - ABAC via IAM LeadingKeys condition (org_id prefix)
  #   - Multiple billing records per task (for incremental billing on cancel/retry)
  # The UUID suffix ensures each billing write creates a new record that triggers
  # the DynamoDB Stream -> Lambda -> Stripe billing flow.
  TaskUsageTable:
    Type: AWS::DynamoDB::Table
    Condition: IncludeMultiTenantGroups
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      TableName: !Sub "kanu-task-usage-${AWS::StackName}"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: pk # org_id#task_id#uuid (composite for ABAC + uniqueness)
          AttributeType: S
        - AttributeName: org_id # For GSI queries by organization
          AttributeType: S
        - AttributeName: task_id # For GSI queries by task
          AttributeType: S
        - AttributeName: recorded_at
          AttributeType: N
      KeySchema:
        - AttributeName: pk # org_id#task_id#uuid
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: org-usage-index
          KeySchema:
            - AttributeName: org_id
              KeyType: HASH
            - AttributeName: recorded_at
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: task-usage-index
          KeySchema:
            - AttributeName: task_id
              KeyType: HASH
            - AttributeName: recorded_at
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      StreamSpecification:
        StreamViewType: NEW_IMAGE
      Tags:
        - Key: Name
          Value: !Sub "kanu-task-usage-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # SELF-SERVICE BILLING - Stripe Lambda (SelfServiceAndSelfHosted only)
  # ==============================================
  # Lambda triggered by DynamoDB Streams to send usage to Stripe for billing.

  # IAM Role for Stripe Billing Lambda
  # Only created when both multi-tenant mode AND Stripe credentials are configured
  StripeBillingLambdaRole:
    Type: AWS::IAM::Role
    Condition: EnableStripeBilling
    Properties:
      RoleName: !Sub "${AWS::StackName}-stripe-billing-lambda"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: DynamoDBStreamAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetRecords
                  - dynamodb:GetShardIterator
                  - dynamodb:DescribeStream
                  - dynamodb:ListStreams
                Resource: !GetAtt TaskUsageTable.StreamArn
        - PolicyName: BillingTableRead
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                Resource: !GetAtt BillingTable.Arn
        - PolicyName: SecretsManagerAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${AWS::StackName}-stripe-api-key*"

  # Stripe Billing Lambda Function
  # Only created when both multi-tenant mode AND Stripe credentials are configured
  StripeBillingFunction:
    Type: AWS::Lambda::Function
    Condition: EnableStripeBilling
    Properties:
      FunctionName: !Sub "${AWS::StackName}-stripe-billing"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt StripeBillingLambdaRole.Arn
      Timeout: 30
      MemorySize: 256
      Environment:
        Variables:
          BILLING_TABLE_NAME: !Ref BillingTable
          STRIPE_SECRET_NAME: !Sub "${AWS::StackName}-stripe-api-key"
          STRIPE_METER_EVENT_NAME: "kanu_token_usage"
      Code:
        ZipFile: |
          """
          Lambda function to send task usage to Stripe for self-service customers.
          Triggered by DynamoDB Streams when new records are written to task-usage table.
          Only sends to Stripe if the org has billing_enabled=true in billing table.
          """
          import json
          import os
          import urllib.request
          import urllib.error
          import urllib.parse
          import boto3
          from decimal import Decimal

          dynamodb = boto3.resource('dynamodb')
          secrets_client = boto3.client('secretsmanager')

          BILLING_TABLE_NAME = os.environ.get('BILLING_TABLE_NAME')
          STRIPE_SECRET_NAME = os.environ.get('STRIPE_SECRET_NAME')
          STRIPE_METER_EVENT_NAME = os.environ.get('STRIPE_METER_EVENT_NAME', 'kanu_token_usage')

          # Cache Stripe API key
          _stripe_api_key = None

          def get_stripe_api_key():
              global _stripe_api_key
              if _stripe_api_key:
                  return _stripe_api_key

              response = secrets_client.get_secret_value(SecretId=STRIPE_SECRET_NAME)
              secret = response['SecretString']

              # Secret can be JSON with apiKey field or plain string
              try:
                  parsed = json.loads(secret)
                  _stripe_api_key = parsed.get('apiKey') or parsed.get('api_key') or secret
              except json.JSONDecodeError:
                  _stripe_api_key = secret

              return _stripe_api_key

          def get_billing_settings(org_id):
              """Check if org has billing enabled and get Stripe customer ID."""
              table = dynamodb.Table(BILLING_TABLE_NAME)
              try:
                  response = table.get_item(
                      Key={'org_id': org_id},
                      ProjectionExpression='org_id, stripe_customer_id, billing_enabled'
                  )
                  return response.get('Item')
              except Exception as e:
                  print(f"Failed to get billing settings for org {org_id}: {e}")
                  return None

          def send_to_stripe(api_key, customer_id, usage):
              """Send usage to Stripe meter API."""
              cost_usd = float(usage.get('total_cost_usd', '0'))
              # Convert to micro-dollars: 1,000,000 units = $1 (matches Stripe price of $0.000001/unit)
              micro_dollars = int(cost_usd * 1_000_000)

              if micro_dollars <= 0:
                  print(f"Skipping zero-cost usage for task {usage.get('task_id')}")
                  return

              # Use pk (org_id#task_id#uuid) as identifier for idempotency
              # This ensures each billing record gets its own Stripe meter event
              # Falls back to billing_id or task_id for backwards compatibility with old records
              identifier = usage.get('pk') or usage.get('billing_id') or usage.get('task_id')

              url = 'https://api.stripe.com/v1/billing/meter_events'
              data = urllib.parse.urlencode({
                  'event_name': STRIPE_METER_EVENT_NAME,
                  'payload[stripe_customer_id]': customer_id,
                  'payload[tokens]': str(micro_dollars),
                  'identifier': identifier,
              }).encode()

              req = urllib.request.Request(url, data=data, method='POST')
              req.add_header('Authorization', f'Bearer {api_key}')
              req.add_header('Content-Type', 'application/x-www-form-urlencoded')

              try:
                  with urllib.request.urlopen(req) as response:
                      print(f"Sent to Stripe: id={identifier}, task={usage.get('task_id')}, org={usage.get('org_id')}, cost=${cost_usd}, micro_dollars={micro_dollars}")
              except urllib.error.HTTPError as e:
                  error_body = e.read().decode()
                  print(f"Stripe API error: {e.code} - {error_body}")
                  raise

          def process_record(record):
              """Process a single DynamoDB stream record."""
              if record.get('eventName') != 'INSERT':
                  return

              new_image = record.get('dynamodb', {}).get('NewImage', {})
              if not new_image:
                  return

              # Convert DynamoDB format to Python dict
              usage = {}
              for key, value in new_image.items():
                  if 'S' in value:
                      usage[key] = value['S']
                  elif 'N' in value:
                      usage[key] = value['N']

              org_id = usage.get('org_id')
              if not org_id:
                  print(f"Task {usage.get('task_id')} has no org_id, skipping")
                  return

              billing = get_billing_settings(org_id)
              if not billing:
                  print(f"No billing settings for org {org_id}, skipping")
                  return

              if not billing.get('billing_enabled'):
                  print(f"Billing not enabled for org {org_id}, skipping")
                  return

              customer_id = billing.get('stripe_customer_id')
              if not customer_id:
                  print(f"Org {org_id} has billing_enabled but no stripe_customer_id")
                  return

              api_key = get_stripe_api_key()
              send_to_stripe(api_key, customer_id, usage)

          def handler(event, context):
              print(f"Processing {len(event.get('Records', []))} task usage records")

              successes = 0
              failures = 0

              for record in event.get('Records', []):
                  try:
                      process_record(record)
                      successes += 1
                  except Exception as e:
                      print(f"Failed to process record: {e}")
                      failures += 1

              print(f"Processed {successes}/{successes + failures} records successfully")

              # Don't raise - partial failures shouldn't retry the whole batch
              return {'processed': successes, 'failed': failures}
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-stripe-billing"
        - Key: Environment
          Value: !Ref EnvironmentName

  # CloudWatch Log Group for Stripe Billing Lambda
  StripeBillingLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: EnableStripeBilling
    Properties:
      LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-stripe-billing"
      RetentionInDays: 30

  # Event Source Mapping to trigger Lambda from DynamoDB Stream
  StripeBillingEventSourceMapping:
    Type: AWS::Lambda::EventSourceMapping
    Condition: EnableStripeBilling
    Properties:
      EventSourceArn: !GetAtt TaskUsageTable.StreamArn
      FunctionName: !GetAtt StripeBillingFunction.Arn
      StartingPosition: LATEST
      BatchSize: 10
      MaximumRetryAttempts: 3
      FilterCriteria:
        Filters:
          - Pattern: '{"eventName": ["INSERT"]}'

  # ==============================================
  # STORAGE - S3 Buckets
  # ==============================================

  CodeStorageBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "kanu-code-storage-${AWS::AccountId}-${AWS::StackName}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      # CORS is required for browser-based presigned URL uploads (file attachments).
      # The frontend (served via CloudFront) PUTs files directly to S3 using presigned URLs.
      # Without CORS, the browser's preflight OPTIONS check is rejected by S3.
      # Origins are restricted to the actual application domain(s) for defense-in-depth.
      CorsConfiguration:
        CorsRules:
          - AllowedOrigins: !If
              - HasCustomDomain
              - - !Sub "https://${CustomDomainName}"
                - !Sub "https://${CloudFrontDistribution.DomainName}"
              - - !Sub "https://${CloudFrontDistribution.DomainName}"
            AllowedMethods:
              - PUT
              - GET
            AllowedHeaders:
              - "*"
            MaxAge: 3600
      VersioningConfiguration:
        Status: Enabled
      LifecycleConfiguration:
        Rules:
          - Id: DeleteOldVersions
            Status: Enabled
            NoncurrentVersionExpirationInDays: 14
      Tags:
        - Key: Name
          Value: !Sub "kanu-code-storage-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  FrontendAssetsBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub "kanu-frontends-${AWS::AccountId}-${AWS::StackName}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      VersioningConfiguration:
        Status: Enabled
      Tags:
        - Key: Name
          Value: !Sub "kanu-frontends-${EnvironmentName}"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # MESSAGING - SNS Topics and SQS Queues
  # ==============================================

  GeneratorStatusTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub "${AWS::StackName}-generator-status"
      DisplayName: "KANU Generator Status Updates"
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: Component
          Value: "messaging"

  DecisionResponseTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub "${AWS::StackName}-decision-response"
      DisplayName: "KANU Decision Responses"
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: Component
          Value: "messaging"

  GeneratorStatusQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "${AWS::StackName}-generator-status"
      VisibilityTimeout: 30
      MessageRetentionPeriod: 1209600 # 14 days
      ReceiveMessageWaitTimeSeconds: 20 # Long polling
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: Component
          Value: "messaging"

  GeneratorStatusQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref GeneratorStatusQueue
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: "sqs:SendMessage"
            Resource: !GetAtt GeneratorStatusQueue.Arn
            Condition:
              ArnEquals:
                "aws:SourceArn": !Ref GeneratorStatusTopic

  GeneratorStatusSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: sqs
      TopicArn: !Ref GeneratorStatusTopic
      Endpoint: !GetAtt GeneratorStatusQueue.Arn
      RawMessageDelivery: true

  DecisionResponseQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: !Sub "${AWS::StackName}-decision-response"
      VisibilityTimeout: 30
      MessageRetentionPeriod: 1209600 # 14 days
      ReceiveMessageWaitTimeSeconds: 20 # Long polling
      Tags:
        - Key: Environment
          Value: !Ref EnvironmentName
        - Key: Component
          Value: "messaging"

  DecisionResponseQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref DecisionResponseQueue
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: "sqs:SendMessage"
            Resource: !GetAtt DecisionResponseQueue.Arn
            Condition:
              ArnEquals:
                "aws:SourceArn": !Ref DecisionResponseTopic

  DecisionResponseSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: sqs
      TopicArn: !Ref DecisionResponseTopic
      Endpoint: !GetAtt DecisionResponseQueue.Arn
      RawMessageDelivery: true

  # ==============================================
  # COMPUTE - ECS Clusters
  # ==============================================

  # Lambda that idempotently creates the ECS service-linked role.
  # AWS::IAM::ServiceLinkedRole fails if the role already exists, so we use
  # a custom resource that calls CreateServiceLinkedRole only on fresh accounts
  # and treats an already-exists error as success.
  ECSServiceLinkedRoleLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-ecs-slr-lambda-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: CreateECSServiceLinkedRole
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: iam:CreateServiceLinkedRole
                Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS"

  ECSServiceLinkedRoleLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-ecs-slr"
      Runtime: python3.12
      Handler: index.handler
      Timeout: 30
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Role: !GetAtt ECSServiceLinkedRoleLambdaRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              if event['RequestType'] == 'Delete':
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                  return
              try:
                  iam = boto3.client('iam')
                  iam.create_service_linked_role(AWSServiceName='ecs.amazonaws.com')
              except iam.exceptions.InvalidInputException as e:
                  # Role already exists — this is the expected case for accounts
                  # that have previously used ECS. Treat as success.
                  if 'has been taken' in str(e) or 'already exists' in str(e):
                      pass
                  else:
                      cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
                      return
              except Exception as e:
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
                  return
              cfnresponse.send(event, context, cfnresponse.SUCCESS, {})

  ECSServiceLinkedRoleCustomResource:
    Type: Custom::ECSServiceLinkedRole
    Properties:
      ServiceToken: !GetAtt ECSServiceLinkedRoleLambda.Arn

  # Web Application Cluster
  WebAppECSCluster:
    DependsOn: ECSServiceLinkedRoleCustomResource
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "${AWS::StackName}-cluster"
      ClusterSettings:
        - Name: containerInsights
          Value: enabled
      CapacityProviders:
        - FARGATE
        - FARGATE_SPOT
      DefaultCapacityProviderStrategy:
        - CapacityProvider: FARGATE
          Weight: 1

  # Generation/Orchestration Cluster
  GenerationCluster:
    DependsOn: ECSServiceLinkedRoleCustomResource
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "${AWS::StackName}-generation"
      ClusterSettings:
        - Name: containerInsights
          Value: enabled
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-generation"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # NETWORKING - CloudFront Prefix List Lookup
  # ==============================================
  # Looks up the AWS-managed prefix list for CloudFront origin-facing IPs.
  # Used to restrict ALB security group to only accept traffic from CloudFront,
  # instead of 0.0.0.0/0 which compliance tools may automatically remove.

  CloudFrontPrefixListLookupRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-cf-pl-lookup-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: DescribePrefixLists
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: ec2:DescribeManagedPrefixLists
                Resource: "*"

  CloudFrontPrefixListLookupLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-cf-pl-lookup"
      Description: "Looks up CloudFront origin-facing managed prefix list ID for ALB security group"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt CloudFrontPrefixListLookupRole.Arn
      Timeout: 30
      MemorySize: 128
      ReservedConcurrentExecutions: 1
      Code:
        ZipFile: |
          import boto3
          import cfnresponse

          def handler(event, context):
              try:
                  if event['RequestType'] == 'Delete':
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
                      return

                  ec2 = boto3.client('ec2')
                  response = ec2.describe_managed_prefix_lists(
                      Filters=[{
                          'Name': 'prefix-list-name',
                          'Values': ['com.amazonaws.global.cloudfront.origin-facing']
                      }]
                  )

                  prefix_lists = response.get('PrefixLists', [])
                  if not prefix_lists:
                      cfnresponse.send(event, context, cfnresponse.FAILED,
                          {'Error': 'CloudFront managed prefix list not found in this region'})
                      return

                  prefix_list_id = prefix_lists[0]['PrefixListId']
                  print(f"Found CloudFront prefix list: {prefix_list_id}")
                  cfnresponse.send(event, context, cfnresponse.SUCCESS,
                      {'PrefixListId': prefix_list_id})
              except Exception as e:
                  print(f"Error: {e}")
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})

  CloudFrontPrefixListCustomResource:
    Type: Custom::CloudFrontPrefixList
    Properties:
      ServiceToken: !GetAtt CloudFrontPrefixListLookupLambda.Arn

  # ==============================================
  # NETWORKING - Security Groups
  # ==============================================

  # Security Group for ALB
  # Uses CloudFront managed prefix list instead of 0.0.0.0/0 to:
  # 1. Restrict ALB traffic to CloudFront IPs only (defense in depth with X-Origin-Verify)
  # 2. Prevent compliance tools from stripping the inbound rules
  # 3. Auto-update when AWS changes CloudFront IP ranges
  # Note: Only port 80 is needed - CloudFront connects via HTTP (OriginProtocolPolicy: http-only)
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for KANU Application Load Balancer
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourcePrefixListId: !GetAtt CloudFrontPrefixListCustomResource.PrefixListId
          Description: Allow HTTP from CloudFront origin-facing IPs only
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
          Description: Allow all outbound
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-alb-sg"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Security Group for ECS Tasks
  ECSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for KANU ECS tasks
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 3000
          ToPort: 3000
          SourceSecurityGroupId: !Ref ALBSecurityGroup
          Description: Allow traffic from ALB only
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
          Description: Allow all outbound
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-ecs-sg"
        - Key: Environment
          Value: !Ref EnvironmentName

  # Security Group for Generation Tasks
  GenerationSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for AI generation tasks
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 8000
          ToPort: 8000
          SourceSecurityGroupId: !Ref ECSSecurityGroup
          Description: Allow SSE traffic from webapp ECS tasks on port 8000
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
          Description: Allow all outbound for Bedrock, ECR, S3, DynamoDB
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-generation-sg"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # LOAD BALANCING - ALB
  # ==============================================

  # Application Load Balancer
  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub "${AWS::StackName}-alb"
      Type: application
      Scheme: internet-facing
      IpAddressType: ipv4
      Subnets: !If
        - UseExistingSubnets
        - !Ref PublicSubnetIds
        - [!Ref PublicSubnet1, !Ref PublicSubnet2]
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-alb"

  # Target Group
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub "${AWS::StackName}-tg"
      Port: 3000
      Protocol: HTTP
      TargetType: ip
      VpcId: !Ref VpcId
      TargetGroupAttributes:
        - Key: deregistration_delay.timeout_seconds
          Value: "30" # Reduced from default 300s to allow faster rolling deployments
      HealthCheckEnabled: true
      HealthCheckProtocol: HTTP
      HealthCheckPath: /
      HealthCheckIntervalSeconds: 30
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 3
      Matcher:
        HttpCode: 200-399
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-tg"

  # ALB Listener
  ALBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: fixed-response
          FixedResponseConfig:
            StatusCode: "403"
            ContentType: text/plain
            MessageBody: "Direct access not allowed. Please use the CloudFront URL."

  # Allow requests with valid origin verify header
  OriginVerifyListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      ListenerArn: !Ref ALBListener
      Priority: 1
      Conditions:
        - Field: http-header
          HttpHeaderConfig:
            HttpHeaderName: X-Origin-Verify
            Values:
              - !Sub "{{resolve:secretsmanager:${OriginVerifySecret}:SecretString:secret}}"
      Actions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

  # CloudFront distribution in front of the ALB (uses default CloudFront domain)
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        PriceClass: PriceClass_All
        DefaultRootObject: ""
        WebACLId: !GetAtt CloudFrontWebACLv2.Arn
        Aliases:
          !If [HasCustomDomain, [!Ref CustomDomainName], !Ref "AWS::NoValue"]
        Origins:
          - Id: WebALBOrigin
            DomainName: !GetAtt ApplicationLoadBalancer.DNSName
            CustomOriginConfig:
              OriginProtocolPolicy: http-only # ALB listener is HTTP; switch to https-only if you add TLS
              HTTPPort: 80
              HTTPSPort: 443
              # Increased timeouts for SSE streaming - prevents CloudFront from
              # terminating long-lived connections before heartbeats arrive
              OriginReadTimeout: 60
              OriginKeepaliveTimeout: 60
            OriginCustomHeaders:
              # Make the app think the original scheme was HTTPS so secure cookies work
              - HeaderName: X-Forwarded-Proto
                HeaderValue: https
              # Origin verification - prevents direct ALB access (bypassing WAF)
              - HeaderName: X-Origin-Verify
                HeaderValue: !Sub "{{resolve:secretsmanager:${OriginVerifySecret}:SecretString:secret}}"
        # SSE streaming cache behavior - uses CachingDisabled to prevent buffering
        # This ensures real-time SSE event delivery (heartbeats, agent messages)
        CacheBehaviors:
          - PathPattern: "/api/tasks/*/stream-proxy"
            TargetOriginId: WebALBOrigin
            ViewerProtocolPolicy: redirect-to-https
            AllowedMethods:
              - GET
              - HEAD
              - OPTIONS
            # Use AWS managed CachingDisabled policy to prevent buffering
            CachePolicyId:
              !FindInMap [AWSManagedPolicies, CloudFront, CachingDisabled]
            # Use AWS managed AllViewerExceptHostHeader policy to forward auth cookies
            OriginRequestPolicyId:
              !FindInMap [
                AWSManagedPolicies,
                CloudFront,
                AllViewerExceptHostHeader,
              ]
            # Disable compression for SSE - compression can cause buffering
            Compress: false
        DefaultCacheBehavior:
          TargetOriginId: WebALBOrigin
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
            - PUT
            - POST
            - PATCH
            - DELETE
          CachedMethods:
            - GET
            - HEAD
          Compress: true
          ForwardedValues:
            QueryString: true
            Cookies:
              Forward: all
            Headers:
              - "*"
        ViewerCertificate: !If
          - HasCustomDomain
          - AcmCertificateArn: !Ref CustomDomainCertificateArn
            SslSupportMethod: sni-only
            MinimumProtocolVersion: TLSv1.2_2021
          - CloudFrontDefaultCertificate: true
        HttpVersion: http2

  # AWS WAF for CloudFront (edge scope) - must be created in us-east-1
  # Uses a Custom Resource to create WAF in us-east-1 regardless of stack region

  CloudFrontWAFLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-waf-lambda-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: WAFv2Management
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - wafv2:CreateWebACL
                  - wafv2:UpdateWebACL
                  - wafv2:DeleteWebACL
                  - wafv2:GetWebACL
                  - wafv2:ListWebACLs
                Resource: "*"

  CloudFrontWAFLambda:
    Type: AWS::Lambda::Function
    DependsOn: CloudFrontWAFLambdaRole
    Properties:
      FunctionName: !Sub "${AWS::StackName}-waf-manager"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt CloudFrontWAFLambdaRole.Arn
      Timeout: 300
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import json
          import traceback

          def handler(event, context):
              print(f"Event: {json.dumps(event)}")
              try:
                  request_type = event['RequestType']
                  props = event['ResourceProperties']
                  stack_name = props['StackName']
                  # Use "-waf-v2" suffix to avoid conflict with legacy native CloudFormation WAF resource
                  waf_name = f"{stack_name}-cloudfront-waf-v2"
                  
                  # WAFv2 CLOUDFRONT scope must use us-east-1
                  waf_client = boto3.client('wafv2', region_name='us-east-1')
                  
                  # Use existing physical resource ID if available, otherwise generate one
                  physical_id = event.get('PhysicalResourceId', f"waf-{waf_name}")
                  
                  if request_type == 'Delete':
                      # Find and delete the WebACL
                      try:
                          response = waf_client.list_web_acls(Scope='CLOUDFRONT')
                          for acl in response.get('WebACLs', []):
                              if acl['Name'] == waf_name:
                                  waf_client.delete_web_acl(
                                      Name=waf_name,
                                      Scope='CLOUDFRONT',
                                      Id=acl['Id'],
                                      LockToken=acl['LockToken']
                                  )
                                  print(f"Deleted WebACL: {waf_name}")
                                  break
                      except Exception as e:
                          print(f"Error deleting WebACL (may not exist): {e}")
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
                      return
                  
                  # Define WAF rules
                  rules = [
                      {
                          'Name': 'AllowSAMLAuthentication',
                          'Priority': 0,
                          'Action': {'Allow': {}},
                          'Statement': {
                              'OrStatement': {
                                  'Statements': [
                                      {'ByteMatchStatement': {'SearchString': '/api/auth/saml/', 'FieldToMatch': {'UriPath': {}}, 'PositionalConstraint': 'STARTS_WITH', 'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]}},
                                      {'ByteMatchStatement': {'SearchString': '/login', 'FieldToMatch': {'UriPath': {}}, 'PositionalConstraint': 'STARTS_WITH', 'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]}},
                                      {'ByteMatchStatement': {'SearchString': '/tasks', 'FieldToMatch': {'UriPath': {}}, 'PositionalConstraint': 'STARTS_WITH', 'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]}}
                                  ]
                              }
                          },
                          'VisibilityConfig': {'CloudWatchMetricsEnabled': True, 'MetricName': f'{stack_name}-saml-allow', 'SampledRequestsEnabled': True}
                      },
                      # --- Common Rule Set: Two-rule strategy ---
                      # Rule 1: Full CRS for ALL endpoints, but with SizeRestrictions_BODY and
                      #         EC2MetaDataSSRF_BODY set to Count (not Block). This ensures XSS,
                      #         SQLi, LFI, RFI, etc. protect every endpoint including the exempt ones.
                      # Rule 2: Only SizeRestrictions_BODY and EC2MetaDataSSRF_BODY (all other rules
                      #         excluded), scoped to NON-exempt endpoints. This re-enables blocking
                      #         for body size and SSRF checks on normal endpoints while leaving the
                      #         exempt endpoints (env uploads, chat, task creation) unblocked.
                      {
                          'Name': 'AWSCommonRules',
                          'Priority': 1,
                          'OverrideAction': {'None': {}},
                          'Statement': {
                              'ManagedRuleGroupStatement': {
                                  'VendorName': 'AWS',
                                  'Name': 'AWSManagedRulesCommonRuleSet',
                                  # Set body rules to Count globally - user prompts often contain
                                  # code with file paths (LFI), URLs (RFI), or HTML/JS snippets (XSS)
                                  # that trigger false positives. Size/SSRF are re-enabled for non-exempt endpoints.
                                  'RuleActionOverrides': [
                                      {'Name': 'SizeRestrictions_BODY', 'ActionToUse': {'Count': {}}},
                                      {'Name': 'EC2MetaDataSSRF_BODY', 'ActionToUse': {'Count': {}}},
                                      {'Name': 'GenericLFI_BODY', 'ActionToUse': {'Count': {}}},
                                      {'Name': 'GenericRFI_BODY', 'ActionToUse': {'Count': {}}},
                                      {'Name': 'CrossSiteScripting_BODY', 'ActionToUse': {'Count': {}}}
                                  ]
                              }
                          },
                          'VisibilityConfig': {'CloudWatchMetricsEnabled': True, 'MetricName': f'{stack_name}-common', 'SampledRequestsEnabled': True}
                      },
                      {
                          'Name': 'AWSCommonRulesBodyChecks',
                          'Priority': 2,
                          'OverrideAction': {'None': {}},
                          'Statement': {
                              'ManagedRuleGroupStatement': {
                                  'VendorName': 'AWS',
                                  'Name': 'AWSManagedRulesCommonRuleSet',
                                  # Exclude ALL rules except SizeRestrictions_BODY and EC2MetaDataSSRF_BODY.
                                  # These two rules will Block (default action) on non-exempt endpoints.
                                  'ExcludedRules': [
                                      {'Name': 'NoUserAgent_HEADER'},
                                      {'Name': 'UserAgent_BadBots_HEADER'},
                                      {'Name': 'SizeRestrictions_QUERYSTRING'},
                                      {'Name': 'SizeRestrictions_Cookie_HEADER'},
                                      {'Name': 'SizeRestrictions_URIPATH'},
                                      {'Name': 'EC2MetaDataSSRF_COOKIE'},
                                      {'Name': 'EC2MetaDataSSRF_URIPATH'},
                                      {'Name': 'EC2MetaDataSSRF_QUERYARGUMENTS'},
                                      {'Name': 'GenericLFI_QUERYARGUMENTS'},
                                      {'Name': 'GenericLFI_URIPATH'},
                                      {'Name': 'GenericLFI_BODY'},
                                      {'Name': 'RestrictedExtensions_URIPATH'},
                                      {'Name': 'RestrictedExtensions_QUERYARGUMENTS'},
                                      {'Name': 'GenericRFI_QUERYARGUMENTS'},
                                      {'Name': 'GenericRFI_BODY'},
                                      {'Name': 'GenericRFI_URIPATH'},
                                      {'Name': 'CrossSiteScripting_COOKIE'},
                                      {'Name': 'CrossSiteScripting_QUERYARGUMENTS'},
                                      {'Name': 'CrossSiteScripting_BODY'},
                                      {'Name': 'CrossSiteScripting_URIPATH'},
                                  ],
                                  # Only evaluate non-exempt endpoints
                                  # Exempt: /api/tasks/settings/gitrepo (env uploads > 8KB)
                                  #         /api/tasks/create-with-message (prompts may contain code/URLs)
                                  #         /api/tasks/{taskId}/chat (messages may contain code/URLs)
                                  #         /api/tasks/{taskId}/attachments (file uploads up to 1MB)
                                  'ScopeDownStatement': {
                                      'NotStatement': {
                                          'Statement': {
                                              'OrStatement': {
                                                  'Statements': [
                                                      {'ByteMatchStatement': {
                                                          'SearchString': '/api/tasks/settings/gitrepo',
                                                          'FieldToMatch': {'UriPath': {}},
                                                          'PositionalConstraint': 'STARTS_WITH',
                                                          'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                      }},
                                                      {'ByteMatchStatement': {
                                                          'SearchString': '/api/tasks/create-with-message',
                                                          'FieldToMatch': {'UriPath': {}},
                                                          'PositionalConstraint': 'STARTS_WITH',
                                                          'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                      }},
                                                      {'AndStatement': {
                                                          'Statements': [
                                                              {'ByteMatchStatement': {
                                                                  'SearchString': '/api/tasks/',
                                                                  'FieldToMatch': {'UriPath': {}},
                                                                  'PositionalConstraint': 'STARTS_WITH',
                                                                  'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                              }},
                                                              {'ByteMatchStatement': {
                                                                  'SearchString': '/chat',
                                                                  'FieldToMatch': {'UriPath': {}},
                                                                  'PositionalConstraint': 'ENDS_WITH',
                                                                  'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                              }}
                                                          ]
                                                      }},
                                                      {'AndStatement': {
                                                          'Statements': [
                                                              {'ByteMatchStatement': {
                                                                  'SearchString': '/api/tasks/',
                                                                  'FieldToMatch': {'UriPath': {}},
                                                                  'PositionalConstraint': 'STARTS_WITH',
                                                                  'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                              }},
                                                              {'ByteMatchStatement': {
                                                                  'SearchString': '/attachments',
                                                                  'FieldToMatch': {'UriPath': {}},
                                                                  'PositionalConstraint': 'ENDS_WITH',
                                                                  'TextTransformations': [{'Priority': 0, 'Type': 'NONE'}]
                                                              }}
                                                          ]
                                                      }}
                                                  ]
                                              }
                                          }
                                      }
                                  }
                              }
                          },
                          'VisibilityConfig': {'CloudWatchMetricsEnabled': True, 'MetricName': f'{stack_name}-common-body', 'SampledRequestsEnabled': True}
                      },
                      # NOTE: AWSManagedRulesKnownBadInputsRuleSet removed - it was blocking
                      # legitimate user prompts containing code patterns (Log4j ${...}, file paths,
                      # Java serialization patterns). The app accepts user-generated code/prompts
                      # which frequently trigger false positives. XSS/SQLi protection is still
                      # provided by AWSManagedRulesCommonRuleSet.
                      {
                          'Name': 'AWSAnonymousIp',
                          'Priority': 3,
                          'OverrideAction': {'None': {}},
                          'Statement': {'ManagedRuleGroupStatement': {'VendorName': 'AWS', 'Name': 'AWSManagedRulesAnonymousIpList'}},
                          'VisibilityConfig': {'CloudWatchMetricsEnabled': True, 'MetricName': f'{stack_name}-anon', 'SampledRequestsEnabled': True}
                      },
                      {
                          'Name': 'AWSRateLimit',
                          'Priority': 4,
                          'Action': {'Block': {}},
                          'Statement': {'RateBasedStatement': {'AggregateKeyType': 'IP', 'Limit': 20000}},
                          'VisibilityConfig': {'CloudWatchMetricsEnabled': True, 'MetricName': f'{stack_name}-rate', 'SampledRequestsEnabled': True}
                      }
                  ]
                  
                  visibility_config = {
                      'CloudWatchMetricsEnabled': True,
                      'MetricName': f'{stack_name}-cloudfront-waf-v2',
                      'SampledRequestsEnabled': True
                  }
                  
                  if request_type == 'Create':
                      # Check if WAF already exists (e.g., from previous native CloudFormation resource)
                      existing_acl = None
                      response = waf_client.list_web_acls(Scope='CLOUDFRONT')
                      for acl in response.get('WebACLs', []):
                          if acl['Name'] == waf_name:
                              existing_acl = acl
                              break
                      
                      if existing_acl:
                          # Adopt existing WAF (created by old native resource)
                          print(f"Found existing WebACL, adopting: {existing_acl['ARN']}")
                          cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Arn': existing_acl['ARN']}, existing_acl['ARN'])
                      else:
                          # Create new WAF
                          response = waf_client.create_web_acl(
                              Name=waf_name,
                              Scope='CLOUDFRONT',
                              DefaultAction={'Allow': {}},
                              Description='CloudFront WAF for KANU',
                              Rules=rules,
                              VisibilityConfig=visibility_config
                          )
                          arn = response['Summary']['ARN']
                          print(f"Created WebACL: {arn}")
                          cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Arn': arn}, arn)
                  
                  elif request_type == 'Update':
                      # Get current WebACL to get lock token
                      response = waf_client.list_web_acls(Scope='CLOUDFRONT')
                      acl_id = None
                      lock_token = None
                      for acl in response.get('WebACLs', []):
                          if acl['Name'] == waf_name:
                              acl_id = acl['Id']
                              lock_token = acl['LockToken']
                              break
                      
                      if acl_id:
                          response = waf_client.update_web_acl(
                              Name=waf_name,
                              Scope='CLOUDFRONT',
                              Id=acl_id,
                              DefaultAction={'Allow': {}},
                              Description='CloudFront WAF for KANU',
                              Rules=rules,
                              VisibilityConfig=visibility_config,
                              LockToken=lock_token
                          )
                          # Get ARN from describe
                          describe_resp = waf_client.get_web_acl(Name=waf_name, Scope='CLOUDFRONT', Id=acl_id)
                          arn = describe_resp['WebACL']['ARN']
                          print(f"Updated WebACL: {arn}")
                          cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Arn': arn}, arn)
                      else:
                          # WebACL not found, create it
                          response = waf_client.create_web_acl(
                              Name=waf_name,
                              Scope='CLOUDFRONT',
                              DefaultAction={'Allow': {}},
                              Description='CloudFront WAF for KANU',
                              Rules=rules,
                              VisibilityConfig=visibility_config
                          )
                          arn = response['Summary']['ARN']
                          print(f"Created WebACL (on update): {arn}")
                          cfnresponse.send(event, context, cfnresponse.SUCCESS, {'Arn': arn}, arn)
                          
              except Exception as e:
                  print(f"Error: {e}")
                  traceback.print_exc()
                  # Use existing physical resource ID if available
                  physical_id = event.get('PhysicalResourceId', f"waf-{event['ResourceProperties']['StackName']}-cloudfront-waf-v2")
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, physical_id)

  # Note: This uses a different logical ID (CloudFrontWebACLv2) than the original
  # AWS::WAFv2::WebACL resource (CloudFrontWebACL) to allow stack updates.
  # The old resource will be automatically deleted and replaced with this custom resource.
  CloudFrontWebACLv2:
    Type: Custom::CloudFrontWAF
    Properties:
      ServiceToken: !GetAtt CloudFrontWAFLambda.Arn
      StackName: !Ref AWS::StackName
      # Bump this version to force CloudFormation to re-invoke the WAF Lambda
      # (Custom Resources only re-trigger when a property value changes)
      Version: "6"

  # IAM Role for ECS Task (Application Permissions)
  WebAppECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-webapp-task-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*"
      Policies:
        - PolicyName: WebAppMessagingAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sns:Publish
                  - sns:Subscribe
                  - sqs:SendMessage
                  - sqs:ReceiveMessage
                  - sqs:DeleteMessage
                  - sqs:GetQueueAttributes
                Resource:
                  - !Ref GeneratorStatusTopic
                  - !GetAtt DecisionResponseQueue.Arn
        - PolicyName: DynamoDBAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                  - dynamodb:Query
                  - dynamodb:Scan
                  - dynamodb:BatchGetItem
                  - dynamodb:BatchWriteItem
                Resource:
                  - !GetAtt TasksTable.Arn
                  - !GetAtt TaskDecisionsTable.Arn
                  - !GetAtt TaskActivityLogsTable.Arn
                  - !GetAtt ChatMessagesTable.Arn
                  - !GetAtt UserRepoConfigsTable.Arn
                  - !GetAtt UsageMetricsTable.Arn
                  - !Sub "${TasksTable.Arn}/index/*"
                  - !Sub "${TaskDecisionsTable.Arn}/index/*"
                  - !Sub "${ChatMessagesTable.Arn}/index/*"
                  - !Sub "${UsageMetricsTable.Arn}/index/*"
              # Self-service tables
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                  - dynamodb:Query
                  - dynamodb:Scan
                  - dynamodb:BatchGetItem
                  - dynamodb:BatchWriteItem
                Resource:
                  - !GetAtt OrganizationsTable.Arn
                  - !GetAtt SelfServiceUsersTable.Arn
                  - !GetAtt AwsAccountsTable.Arn
                  - !GetAtt AwsAccountRolesTable.Arn
                  - !GetAtt UserPermissionsTable.Arn
                  - !GetAtt InfraScannerCacheTable.Arn
                  - !Sub "${SelfServiceUsersTable.Arn}/index/*"
                  - !Sub "${AwsAccountsTable.Arn}/index/*"
                  - !Sub "${AwsAccountRolesTable.Arn}/index/*"
                  - !Sub "${UserPermissionsTable.Arn}/index/*"
              # Billing tables (SelfServiceAndSelfHosted mode only)
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:GetItem
                    - dynamodb:PutItem
                    - dynamodb:UpdateItem
                    - dynamodb:Query
                  Resource:
                    - !GetAtt BillingTable.Arn
                    - !GetAtt MonthlyUsageTable.Arn
                    - !GetAtt TaskUsageTable.Arn
                    - !GetAtt PromoRedemptionsTable.Arn
                - !Ref AWS::NoValue
              # Promo codes table needs Scan for admin listing
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:GetItem
                    - dynamodb:PutItem
                    - dynamodb:UpdateItem
                    - dynamodb:Query
                    - dynamodb:Scan
                  Resource:
                    - !GetAtt PromoCodesTable.Arn
                - !Ref AWS::NoValue
              # Rate limit counters table (SelfServiceAndSelfHosted mode only)
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:GetItem
                    - dynamodb:PutItem
                    - dynamodb:UpdateItem
                    - dynamodb:Query
                  Resource:
                    - !GetAtt RateLimitCountersTable.Arn
                - !Ref AWS::NoValue
        - PolicyName: S3Access
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                  - s3:ListBucket
                Resource:
                  - !GetAtt CodeStorageBucket.Arn
                  - !Sub "${CodeStorageBucket.Arn}/*"
                  - !GetAtt FrontendAssetsBucket.Arn
                  - !Sub "${FrontendAssetsBucket.Arn}/*"
        - PolicyName: BedrockAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - bedrock:InvokeModel
                  - bedrock:InvokeModelWithResponseStream
                  - bedrock:ApplyGuardrail
                Resource: "*"
        - PolicyName: ECSGenerationAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ecs:RunTask
                  - ecs:DescribeTasks
                  - ecs:DescribeTaskDefinition
                  - ecs:ListTasks
                  - ecs:StopTask
                Resource: "*"
              - Effect: Allow
                Action:
                  - ecs:TagResource
                Resource:
                  - !Sub "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:task/*"
              - Effect: Allow
                Action:
                  - iam:PassRole
                Resource:
                  - !GetAtt ECSTaskExecutionRole.Arn
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-generation-task-role"
        - PolicyName: LambdaAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:*
                Resource: "*"
        - PolicyName: APIGatewayAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - apigateway:*
                Resource: "*"
        - PolicyName: SESAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ses:*
                Resource: "*"
        # KMS access for encrypting/decrypting ExternalId secrets
        - PolicyName: KMSSecretsAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - kms:Encrypt
                  - kms:Decrypt
                  - kms:GenerateDataKey
                Resource: !GetAtt SecretsEncryptionKey.Arn
                Condition:
                  StringEquals:
                    kms:EncryptionContext:purpose: external_id_secret
                    kms:EncryptionContext:service: kanu
        - PolicyName: IAMManagement
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Manage CDK bootstrap roles (needed for CDK deployments)
              - Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:GetRole
                  - iam:TagRole
                  - iam:DeleteRole
                  - iam:UpdateAssumeRolePolicy
                  - iam:ListAttachedRolePolicies
                  - iam:GetRolePolicy
                  - iam:PutRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:CreatePolicy
                  - iam:GetPolicy
                  - iam:GetPolicyVersion
                  - iam:ListPolicyVersions
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-*"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:policy/cdk-*"
        - PolicyName: IAMSSOAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sso:ListAccountAssignmentsForPrincipal
                  - sso:DescribePermissionSet
                  - sso:ListManagedPoliciesInPermissionSet
                  - sso:ListInstances
                  - identitystore:DescribeGroup
                  - identitystore:ListGroups
                  - identitystore:ListGroupMembershipsForMember
                Resource: "*"
        - PolicyName: PassRoleToECSTask
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: iam:PassRole
                Resource:
                  - !Sub arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*
                Condition:
                  StringEquals:
                    iam:PassedToService: ecs-tasks.amazonaws.com
        - PolicyName: CDKBootstrapAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:*
                  - ssm:GetParameter
                  - ssm:PutParameter
                Resource:
                  - "arn:aws:s3:::cdk-*"
                  - "arn:aws:s3:::cdk-*/*"
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/cdk-bootstrap/*"
              - Effect: Allow
                Action:
                  - ecr:*
                Resource: "*"
        - PolicyName: CloudWatchLogsAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - logs:DescribeLogGroups
                  - logs:DescribeLogStreams
                  - logs:FilterLogEvents
                  - logs:GetLogEvents
                Resource: "*"
        - PolicyName: UserEnvSecretsManagement
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Secrets Manager access for creating/updating user environment variables
              # The web app creates these secrets when users configure repo settings
              - Effect: Allow
                Action:
                  - secretsmanager:CreateSecret
                  - secretsmanager:UpdateSecret
                  - secretsmanager:DeleteSecret
                  - secretsmanager:DescribeSecret
                  - secretsmanager:TagResource
                Resource:
                  - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:kanu-user-env/*"
        - PolicyName: CloudFormationStackCleanup
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # CloudFormation access for cleaning up task-related stacks when canceling tasks
              - Effect: Allow
                Action:
                  - cloudformation:DescribeStacks
                  - cloudformation:DeleteStack
                  - cloudformation:ListStackResources
                Resource:
                  - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/kanu-task-*"
              # DescribeStacks on the platform stack for update-in-progress detection
              - Effect: Allow
                Action:
                  - cloudformation:DescribeStacks
                Resource:
                  - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*"
        - PolicyName: CloudFormationResourceScan
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudformation:StartResourceScan
                  - cloudformation:ListResourceScans
                  - cloudformation:DescribeResourceScan
                  - cloudformation:ListResourceScanResources
                Resource: "*"
        - PolicyName: CrossAccountReadOnlyAssume
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: sts:AssumeRole
                Resource: "arn:aws:iam::*:role/Kanu*"
                Condition:
                  StringLike:
                    sts:ExternalId: "kanu-*"
        # Cognito User Pool admin access for self-service user management
        - PolicyName: CognitoAdminAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cognito-idp:AdminCreateUser
                  - cognito-idp:AdminDeleteUser
                  - cognito-idp:AdminGetUser
                  - cognito-idp:AdminUpdateUserAttributes
                  - cognito-idp:AdminSetUserMFAPreference
                  - cognito-idp:AdminSetUserPassword
                  - cognito-idp:AdminAddUserToGroup
                  - cognito-idp:AdminRemoveUserFromGroup
                  - cognito-idp:AdminListGroupsForUser
                  - cognito-idp:ListUsers
                  - cognito-idp:ListUsersInGroup
                Resource:
                  - !GetAtt CognitoUserPool.Arn
        # SSM access for reading platform version (update notifications)
        - PolicyName: SSMPlatformVersionAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ssm:GetParameter
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/platform-version"
        # Lambda access for triggering manual platform updates
        - PolicyName: ManualUpdateAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource:
                  - !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${AWS::StackName}-auto-update"
        # AWS Marketplace access for customer registration and entitlement
        - PolicyName: MarketplaceAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Marketplace Metering API (ResolveCustomer)
              - Effect: Allow
                Action:
                  - aws-marketplace:ResolveCustomer
                  - aws-marketplace:GetEntitlements
                Resource: "*"
              # Marketplace Deployment API (PutDeploymentParameter, tagging)
              - Effect: Allow
                Action:
                  - aws-marketplace:PutDeploymentParameter
                  - aws-marketplace:TagResource
                  - aws-marketplace:UntagResource
                  - aws-marketplace:ListTagsForResource
                Resource: "*"
        - PolicyName: CloudWatchMetricsAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudwatch:PutMetricData
                Resource: "*"
                Condition:
                  StringEquals:
                    cloudwatch:namespace:
                      - "KANU/RateLimiting"
                      - "KANU/ReCaptcha"
                      - "KANU/SSE"
                      - "KANU/Orchestrator"
        - PolicyName: SecurityObservabilityScan
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  # Core security scanning
                  - guardduty:ListDetectors
                  - guardduty:ListFindings
                  - guardduty:GetFindings
                  - inspector2:ListFindings
                  - securityhub:GetFindings
                  # Config
                  - config:DescribeConfigurationRecorders
                  - config:DescribeConfigRules
                  - config:DescribeComplianceByConfigRule
                  # Macie
                  - macie2:GetMacieSession
                  - macie2:ListFindings
                  - macie2:GetFindings
                  # Access Analyzer
                  - access-analyzer:ListAnalyzers
                  - access-analyzer:ListFindings
                  # SSM Patch Manager
                  - ssm:DescribePatchBaselines
                  - ssm:DescribeInstancePatchStates
                  # Contextual resource checks
                  - s3:ListAllMyBuckets
                  - ec2:DescribeInstances
                Resource: "*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-webapp-task-role"
        - Key: Environment
          Value: !Ref EnvironmentName

  # IAM Role for ECS Task Execution
  # Images are pulled from customer's local ECR (copied during deployment)
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-execution-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, ECSTaskExecution]
      Policies:
        - PolicyName: LocalECRAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ecr:BatchGetImage
                  - ecr:GetDownloadUrlForLayer
                  - ecr:BatchCheckLayerAvailability
                Resource:
                  - !GetAtt WebAppRepository.Arn
                  - !GetAtt OrchestratorRepository.Arn
              - Effect: Allow
                Action:
                  - ecr:GetAuthorizationToken
                Resource: "*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-execution-role"

  # ==============================================
  # SHARED IAM POLICIES
  # ==============================================
  # Customer-managed policies shared across multiple SelfHosted roles.
  # Defined once to eliminate duplication and ensure consistency.

  SecurityGuardrailsPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub "${AWS::StackName}-security-guardrails"
      Description: "Shared security guardrails - explicit denies for dangerous actions"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          # Prevent creating persistent credentials (privilege escalation)
          - Sid: DenyCredentialCreation
            Effect: Deny
            Action:
              - iam:CreateUser
              - iam:CreateAccessKey
              - iam:CreateLoginProfile
              - iam:UpdateLoginProfile
            Resource: "*"
          # Prevent tampering with audit logs
          - Sid: DenyAuditLogTampering
            Effect: Deny
            Action:
              - cloudtrail:DeleteTrail
              - cloudtrail:StopLogging
              - cloudtrail:UpdateTrail
              - cloudtrail:PutEventSelectors
            Resource: "*"
          # Prevent assuming KANU platform roles (lateral movement)
          - Sid: DenyKanuRoleAssumption
            Effect: Deny
            Action:
              - sts:AssumeRole
            Resource:
              - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*"
          # Prevent modifying the KANU CloudFormation stack itself
          - Sid: DenyStackModification
            Effect: Deny
            Action:
              - cloudformation:DeleteStack
              - cloudformation:UpdateStack
            Resource:
              - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*"
          # Prevent modifying or deleting KANU IAM roles
          - Sid: DenyKanuStackRoleModification
            Effect: Deny
            Action:
              - iam:DeleteRole
              - iam:UpdateAssumeRolePolicy
              - iam:PutRolePolicy
              - iam:DeleteRolePolicy
              - iam:AttachRolePolicy
              - iam:DetachRolePolicy
              - iam:UntagRole
            Resource:
              - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*"
          # Prevent deleting or disabling KMS keys
          - Sid: DenyKMSKeyDeletion
            Effect: Deny
            Action:
              - kms:ScheduleKeyDeletion
              - kms:DisableKey
            Resource: "*"
          # Prevent billing and cost manipulation
          - Sid: DenyBillingAccess
            Effect: Deny
            Action:
              - aws-portal:*
              - budgets:ModifyBudget
              - budgets:DeleteBudget
              - ce:UpdateCostAllocationTagsStatus
              - cur:DeleteReportDefinition
              - purchase-orders:*
            Resource: "*"
          # Prevent disabling security monitoring services
          - Sid: DenySecurityServiceTampering
            Effect: Deny
            Action:
              - guardduty:DeleteDetector
              - guardduty:DeleteMembers
              - guardduty:DisassociateFromMasterAccount
              - securityhub:DisableSecurityHub
              - config:DeleteConfigurationRecorder
              - config:StopConfigurationRecorder
              - inspector2:Disable
              - macie2:DisableMacie
              - accessanalyzer:DeleteAnalyzer
              - network-firewall:DeleteFirewall
              - network-firewall:DeleteFirewallPolicy
              - network-firewall:DeleteRuleGroup
              - wafv2:DeleteWebACL
              - wafv2:DeleteRuleGroup
              - shield:DeleteProtection
            Resource: "*"
          # Prevent deletion of backups and snapshots
          - Sid: DenyBackupDeletion
            Effect: Deny
            Action:
              - backup:DeleteBackupVault
              - backup:DeleteRecoveryPoint
              - rds:DeleteDBClusterSnapshot
              - rds:DeleteDBSnapshot
              - ec2:DeleteSnapshot
            Resource: "*"
          # Prevent reading auth tokens, private package assets, and license data
          - Sid: DenySensitiveReadAccess
            Effect: Deny
            Action:
              - ssm:GetParameterHistory
              - ses:GetIdentityVerificationAttributes
              - codeartifact:GetAuthorizationToken
              - codeartifact:ReadFromRepository
              - codeartifact:GetPackageVersionAsset
              - license-manager:GetLicense
              - license-manager:GetLicenseConfiguration
              - license-manager:GetLicenseUsage
            Resource: "*"

  KanuPermissionBoundaryPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub "${AWS::StackName}-kanu-permission-boundary"
      Description: "Shared permission boundary - deny access to customer secrets and credentials, allow KANU-managed secrets"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          # Allow reading KANU-managed user environment secrets
          # Essential for ReadOnly role (which has no other source of secretsmanager:GetSecretValue).
          # Redundant on PowerUser (covered by PowerUserAccessCustom NotAction), but harmless.
          # The DenyCustomerSecrets statement below uses NotResource to exempt kanu-user-env/*,
          # but NotResource only lifts the deny — it doesn't grant access. This Allow does.
          - Sid: AllowKanuUserEnvSecrets
            Effect: Allow
            Action:
              - secretsmanager:GetSecretValue
              - secretsmanager:DescribeSecret
            Resource:
              - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:kanu-user-env/*"
          # Deny access to secrets EXCEPT KANU-managed ones
          - Sid: DenyCustomerSecrets
            Effect: Deny
            Action:
              - secretsmanager:GetSecretValue
              - secretsmanager:BatchGetSecretValue
              - secretsmanager:GetRandomPassword
            NotResource:
              - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:kanu-user-env/*"
              - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${AWS::StackName}-*"
          # Deny SSM parameter access (customer infrastructure secrets)
          # Exceptions: KANU platform params and CDK bootstrap params (required for deployments)
          - Sid: DenySSMParameters
            Effect: Deny
            Action:
              - ssm:GetParameter
              - ssm:GetParameters
              - ssm:GetParametersByPath
            NotResource:
              - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/*"
              - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/cdk-bootstrap/*"
          # Deny KMS decryption (prevents decrypting customer-encrypted data)
          # NOTE: This is a data-access control (like DenySecretsAccess and DenySSMParameters above),
          # not an administrative guardrail. DenyKMSKeyDeletion (preventing key deletion/disabling)
          # is in SecurityGuardrailsPolicy. The split: SecurityGuardrails = admin actions,
          # PermissionBoundary = data access controls.
          - Sid: DenyKMSDecrypt
            Effect: Deny
            Action:
              - kms:Decrypt
              - kms:GenerateDataKey
              - kms:GenerateDataKeyWithoutPlaintext
              - kms:GenerateDataKeyPair
              - kms:GenerateDataKeyPairWithoutPlaintext
            Resource: "*"

  # ==============================================
  # ABAC ROLE ARCHITECTURE FOR GENERATION TASKS
  # ==============================================
  # Multi-role architecture for security isolation:
  #
  # 1. GenerationTaskRole (ECS Task Role - minimal)
  #    - Only allows sts:AssumeRole to specialized roles
  #    - ECS task starts with this role
  #
  # 2. SelfHostedPowerUserRole (assumed when org_id = "selfhosted", role_type = "poweruser")
  #    - Full PowerUser + IAM deployment permissions
  #    - For self-hosted deployments that need to create/modify infrastructure
  #
  # 3. SelfHostedReadOnlyRole (assumed when org_id = "selfhosted", role_type = "readonly")
  #    - Read-only access to AWS resources + KANU control plane writes
  #    - For self-hosted tasks that only need to scan/inspect infrastructure
  #
  # 4. OrgScopedRole (assumed for multi-tenant users with org_id = UUID)
  #    - ABAC-restricted access to KANU resources
  #    - Uses session tags for LeadingKeys enforcement
  #
  # 5. Cross-account roles (Kanu* pattern)
  #    - Assumed for customer AWS account operations
  #    - Uses ExternalId for security
  # ==============================================

  # Minimal ECS Task Role - only allows assuming specialized roles
  GenerationTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-generation-task-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
              ArnLike:
                aws:SourceArn: !Sub "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*"
      Policies:
        # Only permission: assume the specialized roles
        - PolicyName: AssumeSpecializedRoles
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Assume SelfHosted roles (for self-hosted deployments)
              - Effect: Allow
                Action: sts:AssumeRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-selfhosted-poweruser-role"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-selfhosted-readonly-role"
              # Assume OrgScopedRole (for multi-tenant users) with session tags
              # sts:TagSession is required to pass org_id session tag for ABAC
              - Effect: Allow
                Action:
                  - sts:AssumeRole
                  - sts:TagSession
                Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-org-scoped-role"
              # Assume cross-account roles (for customer AWS account operations)
              - Effect: Allow
                Action: sts:AssumeRole
                Resource: "arn:aws:iam::*:role/Kanu*"
                Condition:
                  StringLike:
                    sts:ExternalId: "kanu-*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-generation-task-role"
        - Key: Environment
          Value: !Ref EnvironmentName

  # SelfHosted PowerUser Role - Full deployment permissions for self-hosted deployments
  # Assumed when org_id = "selfhosted" and role_type = "poweruser"
  # NOTE: Control plane writes (DynamoDB, S3, CloudWatch, Bedrock) are implicitly granted
  # by PowerUserAccessCustom (NotAction pattern). ReadOnly needs a separate
  # KanuControlPlaneAccess policy because it only has ReadOnlyAccess (no write permissions).
  SelfHostedPowerUserRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-selfhosted-poweruser-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS: !GetAtt GenerationTaskRole.Arn
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                "sts:ExternalId": !Sub "{{resolve:secretsmanager:${SelfHostedAssumeSecret}:SecretString:secret}}"
          # Developer user trust (for local DEV_MODE testing)
          - !If
            - HasDeveloperUser
            - Effect: Allow
              Principal:
                AWS: !Ref DeveloperUserArn
              Action: sts:AssumeRole
            - !Ref AWS::NoValue
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/ReadOnlyAccess"
        - !Ref SecurityGuardrailsPolicy
        - !Ref KanuPermissionBoundaryPolicy
      Policies:
        # PowerUserAccess equivalent (everything except IAM/Organizations/Account)
        # IMPORTANT: NotAction on an Allow does NOT deny anything — it simply doesn't grant
        # these action prefixes. IAM reads (GetRole, ListPolicies, etc.) and account/org reads
        # are granted separately by the ReadOnlyAccess managed policy. IAM writes are granted
        # by the IAMDeploymentAccess inline policy below.
        - PolicyName: PowerUserAccessCustom
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                NotAction:
                  - iam:*
                  - organizations:*
                  - account:*
                Resource: "*"
        # IAM permissions for CDK/Terraform infrastructure deployment
        - PolicyName: IAMDeploymentAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Role management write permissions (reads covered by ReadOnlyAccess)
              - Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:TagRole
                  - iam:UntagRole
                  - iam:UpdateAssumeRolePolicy
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/*"
              # DeleteRole - scoped to roles created by CDK/KANU deployments
              - Effect: Allow
                Action:
                  - iam:DeleteRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-*"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/kanu-*"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/*-*"
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}*"
              # PassRole — allowlist of safe service principals
              # Broad scope (role/*) is needed because KANU deploys customer stacks that
              # create roles with arbitrary names. The StringEquals allowlist restricts
              # which services can assume the passed role.
              - Effect: Allow
                Action:
                  - iam:PassRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/*"
                Condition:
                  StringEquals:
                    iam:PassedToService:
                      # Compute
                      - lambda.amazonaws.com
                      - ecs-tasks.amazonaws.com
                      - ec2.amazonaws.com
                      - autoscaling.amazonaws.com
                      - ecs.application-autoscaling.amazonaws.com
                      - application-autoscaling.amazonaws.com
                      - batch.amazonaws.com
                      - apprunner.amazonaws.com
                      - spotfleet.amazonaws.com
                      - elasticbeanstalk.amazonaws.com
                      - imagebuilder.amazonaws.com
                      - fis.amazonaws.com
                      # Containers
                      - ecs.amazonaws.com
                      # API/Orchestration
                      - apigateway.amazonaws.com
                      - states.amazonaws.com
                      - events.amazonaws.com
                      - scheduler.amazonaws.com
                      - pipes.amazonaws.com
                      # IaC/CI/CD
                      - cloudformation.amazonaws.com
                      - codebuild.amazonaws.com
                      - codepipeline.amazonaws.com
                      - codedeploy.amazonaws.com
                      - proton.amazonaws.com
                      - codestar.amazonaws.com
                      - servicecatalog.amazonaws.com
                      # Databases
                      - rds.amazonaws.com
                      - elasticache.amazonaws.com
                      - opensearchservice.amazonaws.com
                      - redshift.amazonaws.com
                      - dax.amazonaws.com
                      - monitoring.rds.amazonaws.com
                      - export.rds.amazonaws.com
                      - dms.amazonaws.com
                      - lakeformation.amazonaws.com
                      - athena.amazonaws.com
                      # Storage
                      - s3.amazonaws.com
                      - backup.amazonaws.com
                      - transfer.amazonaws.com
                      - datasync.amazonaws.com
                      - fsx.amazonaws.com
                      - elasticfilesystem.amazonaws.com
                      - dlm.amazonaws.com
                      # Messaging
                      - sns.amazonaws.com
                      - sqs.amazonaws.com
                      - firehose.amazonaws.com
                      - kinesis.amazonaws.com
                      - kafka.amazonaws.com
                      - ses.amazonaws.com
                      # Networking
                      - elasticloadbalancing.amazonaws.com
                      - network-firewall.amazonaws.com
                      - vpc-flow-logs.amazonaws.com
                      - elasticmapreduce.amazonaws.com
                      - transitgateway.amazonaws.com
                      - cloudfront.amazonaws.com
                      - route53.amazonaws.com
                      - waf.amazonaws.com
                      - shield.amazonaws.com
                      # Security
                      - guardduty.amazonaws.com
                      - inspector2.amazonaws.com
                      - macie.amazonaws.com
                      - securityhub.amazonaws.com
                      - access-analyzer.amazonaws.com
                      - config.amazonaws.com
                      - ssm.amazonaws.com
                      # Monitoring
                      - logs.amazonaws.com
                      - monitoring.amazonaws.com
                      - cloudwatch-crossaccount.amazonaws.com
                      - delivery.logs.amazonaws.com
                      - grafana.amazonaws.com
                      - chatbot.amazonaws.com
                      - quicksight.amazonaws.com
                      # AI/ML
                      - sagemaker.amazonaws.com
                      - bedrock.amazonaws.com
                      - rekognition.amazonaws.com
                      - comprehend.amazonaws.com
                      - textract.amazonaws.com
                      - transcribe.amazonaws.com
                      - translate.amazonaws.com
                      - personalize.amazonaws.com
                      - forecast.amazonaws.com
                      - robomaker.amazonaws.com
                      # IoT
                      - iot.amazonaws.com
                      - iotevents.amazonaws.com
                      - iotsitewise.amazonaws.com
                      - greengrass.amazonaws.com
                      # Other
                      - glue.amazonaws.com
                      - kinesisanalytics.amazonaws.com
                      - mediaconvert.amazonaws.com
                      - amplify.amazonaws.com
                      - appsync.amazonaws.com
                      - opsworks.amazonaws.com
                      - datapipeline.amazonaws.com
                      - cognito-idp.amazonaws.com
                      - cognito-identity.amazonaws.com
                      - lightsail.amazonaws.com
              # Service-Linked Role creation — allowlist of safe service principals
              # Create-only: SLRs are shared account-wide resources (not Kanu-owned),
              # so we don't allow deletion to avoid impacting other services.
              - Effect: Allow
                Action:
                  - iam:CreateServiceLinkedRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/*"
                Condition:
                  StringLike:
                    iam:AWSServiceName:
                      - rds.amazonaws.com
                      - elasticloadbalancing.amazonaws.com
                      - elasticache.amazonaws.com
                      - autoscaling.amazonaws.com
                      - ec2.amazonaws.com
                      - ecs.amazonaws.com
                      - spot.amazonaws.com
                      - dynamodb.amazonaws.com
                      - opensearchservice.amazonaws.com
                      - redshift.amazonaws.com
                      - guardduty.amazonaws.com
                      - inspector2.amazonaws.com
                      - macie.amazonaws.com
                      - securityhub.amazonaws.com
                      - access-analyzer.amazonaws.com
                      - config.amazonaws.com
                      - ssm.amazonaws.com
                      - network-firewall.amazonaws.com
                      - batch.amazonaws.com
                      - apprunner.amazonaws.com
                      - codedeploy.amazonaws.com
                      - s3.amazonaws.com
                      - backup.amazonaws.com
                      - kinesis.amazonaws.com
                      - kafka.amazonaws.com
                      - glue.amazonaws.com
                      - amplify.amazonaws.com
                      - application-autoscaling.amazonaws.com
                      - cloudwatch-crossaccount.amazonaws.com
                      - lightsail.amazonaws.com
                      - dax.amazonaws.com
              # Policy management write permissions (reads covered by ReadOnlyAccess)
              - Effect: Allow
                Action:
                  - iam:CreatePolicy
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:policy/*"
        # Anthropic API Key secret access (only if key is provided)
        - !If
          - HasAnthropicApiKey
          - PolicyName: AnthropicApiKeySecretAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - secretsmanager:GetSecretValue
                    - secretsmanager:DescribeSecret
                  Resource: !Ref AnthropicApiKeySecret
          - !Ref AWS::NoValue
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-selfhosted-poweruser-role"
        - Key: Environment
          Value: !Ref EnvironmentName

  # SelfHosted ReadOnly Role - Read-only access for self-hosted deployments
  # Assumed when org_id = "selfhosted" and role_type = "readonly"
  # Provides broad read access to all AWS services for infrastructure scanning,
  # plus KANU control plane write access (DynamoDB, S3, CloudWatch, Bedrock)
  # required by the orchestrator.
  SelfHostedReadOnlyRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-selfhosted-readonly-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS: !GetAtt GenerationTaskRole.Arn
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                "sts:ExternalId": !Sub "{{resolve:secretsmanager:${SelfHostedAssumeSecret}:SecretString:secret}}"
          # Developer user trust (for local DEV_MODE testing)
          - !If
            - HasDeveloperUser
            - Effect: Allow
              Principal:
                AWS: !Ref DeveloperUserArn
              Action: sts:AssumeRole
            - !Ref AWS::NoValue
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/ReadOnlyAccess"
        - !Ref SecurityGuardrailsPolicy
        - !Ref KanuPermissionBoundaryPolicy
      Policies:
        # KANU control plane writes - the orchestrator needs these regardless of role type
        - PolicyName: KanuControlPlaneAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # DynamoDB writes to KANU tables (task status, activity logs, chat messages)
              - Effect: Allow
                Action:
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                  - dynamodb:BatchWriteItem
                Resource:
                  - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/kanu-*"
              # S3 writes to KANU code storage bucket
              - Effect: Allow
                Action:
                  - s3:PutObject
                  - s3:DeleteObject
                Resource:
                  - !Sub "arn:aws:s3:::${CodeStorageBucket}/*"
              # CloudWatch Logs writes for orchestrator logging
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kanu/${AWS::StackName}/*"
              # Bedrock access for AI operations
              - Effect: Allow
                Action:
                  - bedrock:InvokeModel
                  - bedrock:InvokeModelWithResponseStream
                  - bedrock:ApplyGuardrail
                Resource: "*"
              # STS for identity verification
              - Effect: Allow
                Action:
                  - sts:GetCallerIdentity
                Resource: "*"
        # Anthropic API Key secret access (only if key is provided)
        - !If
          - HasAnthropicApiKey
          - PolicyName: AnthropicApiKeySecretAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - secretsmanager:GetSecretValue
                    - secretsmanager:DescribeSecret
                  Resource: !Ref AnthropicApiKeySecret
          - !Ref AWS::NoValue
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-selfhosted-readonly-role"
        - Key: Environment
          Value: !Ref EnvironmentName

  # OrgScoped Role - ABAC-restricted access for multi-tenant users
  # Assumed with session tag org_id for LeadingKeys enforcement
  OrgScopedRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-org-scoped-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt GenerationTaskRole.Arn
                # Aggregation worker tasks assume this role per-org with the org_id session tag
                - !GetAtt AggregationTaskRole.Arn
            Action:
              - sts:AssumeRole
              - sts:TagSession # Required to accept session tags for ABAC
          # Developer user trust (for local DEV_MODE testing)
          - !If
            - HasDeveloperUser
            - Effect: Allow
              Principal:
                AWS: !Ref DeveloperUserArn
              Action:
                - sts:AssumeRole
                - sts:TagSession
            - !Ref AWS::NoValue
      Policies:
        # S3 access restricted by org_id prefix
        # Separate statements for bucket-level (ListBucket) and object-level operations
        - PolicyName: S3OrgScopedAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Object-level operations - restricted by ARN pattern
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                Resource:
                  - !Sub "arn:aws:s3:::${CodeStorageBucket}/orgs/${!aws:PrincipalTag/org_id}/*"
              # Bucket-level operations - restricted by s3:prefix condition
              # The prefix patterns must match any listing request within the org's path
              # IMPORTANT: Include trailing slash or /* to prevent prefix overlap between orgs
              - Effect: Allow
                Action:
                  - s3:ListBucket
                Resource:
                  - !Sub "arn:aws:s3:::${CodeStorageBucket}"
                Condition:
                  StringLike:
                    s3:prefix:
                      # List at exact org root: orgs/{org_id}/
                      - "orgs/${aws:PrincipalTag/org_id}/"
                      # List any subdirectory: orgs/{org_id}/tasks/..., orgs/{org_id}/whatever/...
                      - "orgs/${aws:PrincipalTag/org_id}/*"
              # GetBucketLocation needed by aws s3 sync to determine bucket region
              - Effect: Allow
                Action:
                  - s3:GetBucketLocation
                Resource:
                  - !Sub "arn:aws:s3:::${CodeStorageBucket}"
        # DynamoDB access restricted by LeadingKeys (pk starts with org_id#)
        - PolicyName: DynamoDBOrgScopedAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Task-related tables: full CRUD needed for task execution
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                  - dynamodb:Query
                  - dynamodb:BatchGetItem
                Resource:
                  - !GetAtt TasksTable.Arn
                  - !GetAtt TaskDecisionsTable.Arn
                  - !GetAtt TaskActivityLogsTable.Arn
                  - !GetAtt ChatMessagesTable.Arn
                Condition:
                  ForAllValues:StringLike:
                    dynamodb:LeadingKeys:
                      - "${aws:PrincipalTag/org_id}#*"
              # UserRepoConfigsTable: read-only for known-repo hints during aggregation.
              # pk=user_id — no org-based ABAC possible; only repo URLs are read (PATs are encrypted).
              - Effect: Allow
                Action:
                  - dynamodb:BatchGetItem
                Resource:
                  - !GetAtt UserRepoConfigsTable.Arn
              # UsageMetricsTable: write-only (append-only audit log pattern)
              - Effect: Allow
                Action:
                  - dynamodb:PutItem
                Resource:
                  - !GetAtt UsageMetricsTable.Arn
                Condition:
                  ForAllValues:StringLike:
                    dynamodb:LeadingKeys:
                      - "${aws:PrincipalTag/org_id}#*"
              # Allow GSI queries on TasksTable only
              # Note: UsageMetricsTable GSIs are NOT included here because:
              # 1. org-metrics-index PK=org_id - would need separate ABAC condition
              # 2. user-metrics-index PK=user_id - cannot be ABAC-restricted by org_id
              # The webapp queries these GSIs using its own ECS task role credentials
              - Effect: Allow
                Action:
                  - dynamodb:Query
                Resource:
                  - !Sub "${TasksTable.Arn}/index/*"
              # Billing tables (only for self-service deployments)
              # BillingTable: org_id is the partition key (exact match ABAC)
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:GetItem
                  Resource:
                    - !GetAtt BillingTable.Arn
                  Condition:
                    ForAllValues:StringEquals:
                      dynamodb:LeadingKeys:
                        - "${aws:PrincipalTag/org_id}"
                - !Ref AWS::NoValue
              # MonthlyUsageTable: org_id is partition key, period is sort key
              # Python orchestrator needs to read and atomically update monthly aggregates
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:GetItem
                    - dynamodb:PutItem
                    - dynamodb:UpdateItem
                  Resource:
                    - !GetAtt MonthlyUsageTable.Arn
                  Condition:
                    ForAllValues:StringEquals:
                      dynamodb:LeadingKeys:
                        - "${aws:PrincipalTag/org_id}"
                - !Ref AWS::NoValue
              # TaskUsageTable: pk is org_id#task_id#uuid (ABAC via LeadingKeys)
              # Write access for recording task usage (triggers Stripe Lambda)
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:PutItem
                  Resource:
                    - !GetAtt TaskUsageTable.Arn
                  Condition:
                    ForAllValues:StringLike:
                      dynamodb:LeadingKeys:
                        - "${aws:PrincipalTag/org_id}#*"
                - !Ref AWS::NoValue
              # TaskUsageTable GSI: org-usage-index has org_id as partition key
              # Query access for spending limit checks during task execution
              - !If
                - IncludeMultiTenantGroups
                - Effect: Allow
                  Action:
                    - dynamodb:Query
                  Resource:
                    - !Sub "${TaskUsageTable.Arn}/index/org-usage-index"
                  Condition:
                    ForAllValues:StringEquals:
                      dynamodb:LeadingKeys:
                        - "${aws:PrincipalTag/org_id}"
                - !Ref AWS::NoValue
              # Deployment locks table: pk is org_id#aws_account_id (ABAC via LeadingKeys)
              # Restricts each org to only access locks for their own deployments
              # Prevents DoS: org A cannot create locks for org B's AWS account
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                  - dynamodb:Query
                Resource:
                  - !GetAtt DeploymentLocksTable.Arn
                Condition:
                  ForAllValues:StringLike:
                    dynamodb:LeadingKeys:
                      - "${aws:PrincipalTag/org_id}#*"
        # CloudWatch Logs access restricted by org_id in log stream path
        - PolicyName: CloudWatchOrgScopedAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Allow creating/writing to org-scoped log streams
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - logs:DescribeLogStreams
                Resource:
                  # Log stream pattern: orgs/{org_id}/tasks/{task_id}/*
                  # CDK deployments log group (used by Python orchestrator)
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kanu/${AWS::StackName}/deployments:log-stream:orgs/${!aws:PrincipalTag/org_id}/*"
                  # Unified task logs log group (per-task + per-agent streams)
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kanu/${AWS::StackName}/task-logs:log-stream:orgs/${!aws:PrincipalTag/org_id}/*"
                  # ECS generation log group (legacy, may still be referenced)
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/${AWS::StackName}/generation:log-stream:orgs/${!aws:PrincipalTag/org_id}/*"
              # Allow describing log groups (needed for checking if log group exists)
              - Effect: Allow
                Action:
                  - logs:DescribeLogGroups
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*"
              # Allow creating log group if it doesn't exist
              # Note: Log groups are now created by CloudFormation, but we keep this for resilience
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kanu/${AWS::StackName}/deployments*"
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/kanu/${AWS::StackName}/task-logs*"
        # CloudWatch Metrics: allow emitting custom metrics for orchestrator dashboards
        - PolicyName: CloudWatchMetricsOrgScoped
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudwatch:PutMetricData
                Resource: "*"
                Condition:
                  StringEquals:
                    cloudwatch:namespace: "KANU/Orchestrator"
        # Secrets Manager access for org-scoped user environment variables
        # Secret path: kanu-user-env/{org_id}/{userId}/{configId}
        # ABAC enforcement ensures tenants can only access their own secrets
        - PolicyName: SecretsOrgScopedAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                  - secretsmanager:DescribeSecret
                Resource:
                  # ABAC: Restrict to secrets under this org's path
                  - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:kanu-user-env/${!aws:PrincipalTag/org_id}/*"
        # Anthropic API Key secret access (only if key is provided)
        - !If
          - HasAnthropicApiKey
          - PolicyName: AnthropicApiKeySecretAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - secretsmanager:GetSecretValue
                    - secretsmanager:DescribeSecret
                  Resource: !Ref AnthropicApiKeySecret
          - !Ref AWS::NoValue
        # Bedrock access for model inference and guardrails
        - PolicyName: BedrockAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - bedrock:InvokeModel
                  - bedrock:InvokeModelWithResponseStream
                  - bedrock:ApplyGuardrail
                Resource: "*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-org-scoped-role"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # LOGGING - CloudWatch Log Groups
  # ==============================================

  # CloudWatch Log Group for Web App
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs/${AWS::StackName}"
      RetentionInDays: 7

  # CloudWatch Log Group for Generation Tasks (ECS container logs)
  GenerationLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs/${AWS::StackName}-generation"
      RetentionInDays: 7

  # CloudWatch Log Group for CDK Deployments (Python orchestrator application logs)
  # Uses org_id-scoped log streams: orgs/{org_id}/tasks/{task_id}/*
  CdkDeploymentsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/kanu/${AWS::StackName}/deployments"
      RetentionInDays: 7

  # CloudWatch Log Group for Unified Task Logs
  # Consolidates ALL per-task logs into a single log group with structured streams:
  #   orgs/{org_id}/tasks/{task_id}/generation-{date}         - Full sequential firehose (all logs chronological)
  #   orgs/{org_id}/tasks/{task_id}/webapp-{date}             - Web app task lifecycle events
  #   orgs/{org_id}/tasks/{task_id}/agents/{AgentName}-{date} - Per-agent logs (DeveloperAgent, ChatAgent, DeploymentAgent, etc.)
  # Existing log groups remain untouched; this is purely additive.
  # Note: Stripe Billing, Auto Update, and platform CDK deployments are NOT task-scoped
  # and remain in their own dedicated log groups.
  UnifiedTaskLogsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/kanu/${AWS::StackName}/task-logs"
      RetentionInDays: 7

  # ==============================================
  # TASK DEFINITIONS
  # ==============================================

  # ECS Task Definition
  WebAppTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DependsOn: ImageCopyCustomResource
    Properties:
      Family: !Sub "${AWS::StackName}-webapp"
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      Cpu: "1024"
      Memory: "2048"
      TaskRoleArn: !GetAtt WebAppECSTaskRole.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      ContainerDefinitions:
        - Name: kanu-webapp
          # Use customer's local ECR (images copied during deployment)
          Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${WebAppRepository}:latest"
          Essential: true
          PortMappings:
            - ContainerPort: 3000
              Protocol: tcp
          Environment:
            - Name: NODE_ENV
              Value: production
            - Name: PORT
              Value: "3000"

            - Name: APP_URL
              Value: !Sub
                - "https://${Domain}"
                - Domain:
                    !If [
                      HasCustomDomain,
                      !Ref CustomDomainName,
                      !GetAtt CloudFrontDistribution.DomainName,
                    ]
            - Name: ROOT_DOMAIN
              Value:
                !If [
                  HasCustomDomain,
                  !Ref CustomDomainName,
                  !GetAtt CloudFrontDistribution.DomainName,
                ]

            # Platform version for update notifications (fallback, SSM is preferred)
            - Name: PLATFORM_VERSION
              Value: !Ref PlatformVersion
            # SSM parameter name for reading current platform version
            - Name: PLATFORM_VERSION_SSM_PARAMETER
              Value: !Sub "/${AWS::StackName}/platform-version"

            # Lambda function name for triggering manual platform updates
            - Name: AUTO_UPDATE_LAMBDA_NAME
              Value: !Sub "${AWS::StackName}-auto-update"

            # Stack name for environment identification
            - Name: AWS_STACK_NAME
              Value: !Ref AWS::StackName

            # KMS key for secret encryption
            - Name: KMS_KEY_ARN
              Value: !GetAtt SecretsEncryptionKey.Arn

            # SelfHosted Role ARNs (for role selection in permitted-roles API)
            - Name: SELFHOSTED_POWERUSER_ROLE_ARN
              Value: !GetAtt SelfHostedPowerUserRole.Arn
            - Name: SELFHOSTED_READONLY_ROLE_ARN
              Value: !GetAtt SelfHostedReadOnlyRole.Arn
            # ExternalId for SelfHosted role assumption (webapp passes to generation tasks)
            - Name: SELFHOSTED_ASSUME_EXTERNAL_ID
              Value: !Sub "{{resolve:secretsmanager:${SelfHostedAssumeSecret}:SecretString:secret}}"

            # Generation Infrastructure
            - Name: ECS_CLUSTER_NAME
              Value: !Ref GenerationCluster
            - Name: ECS_PYTHON_TASK_DEFINITION
              Value: !Ref GenerationTaskDefinition
            - Name: ECS_SUBNET_IDS
              Value: !If
                - UseExistingSubnets
                - !If [
                    UsePrivateSubnetsCondition,
                    !Join [",", !Ref PrivateSubnetIds],
                    !Join [",", !Ref PublicSubnetIds],
                  ]
                - !If [
                    UsePrivateSubnetsCondition,
                    !Join [",", [!Ref PrivateSubnet1, !Ref PrivateSubnet2]],
                    !Join [",", [!Ref PublicSubnet1, !Ref PublicSubnet2]],
                  ]
            - Name: ECS_SECURITY_GROUP_IDS
              Value: !Ref GenerationSecurityGroup

            # SSE Streaming Configuration (must match generation task)
            - Name: SSE_API_PORT
              Value: "8000"

            # AWS Configuration
            - Name: AWS_REGION
              Value: !Ref AWS::Region
            - Name: AWS_ACCOUNT_ID
              Value: !Ref AWS::AccountId
            - Name: AWS_BEDROCK_REGION
              Value: !Ref AWS::Region
            - Name: USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "false", "true"]
            - Name: CLAUDE_CODE_USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "0", "1"]

            - Name: MAX_ACTIVE_TASKS_PER_USER
              Value: !If [IsProductionDomain, "6", "20"]

            # Memory/skills rollout (stack parameters). Web app reads these for the launcher;
            # generation task repeats them so orchestrator processes always inherit the stack default
            # when RunTask omits overrides (scheduled tasks, manual RunTask, etc.).
            - Name: ENABLE_MEMORY_INJECTION
              Value: !Ref EnableMemoryInjection
            - Name: ENABLE_SKILL_INJECTION
              Value: !Ref EnableSkillInjection

            # Bedrock Guardrails Configuration
            - Name: ENABLE_BEDROCK_GUARDRAILS
              Value: "false"
            # - Name: BEDROCK_GUARDRAIL_ID
            #   Value: !GetAtt KanuBedrockGuardrail.GuardrailId
            # - Name: BEDROCK_GUARDRAIL_VERSION
            #   Value: !GetAtt KanuBedrockGuardrailVersion.Version

            # Storage
            - Name: KANU_CODE_BUCKET
              Value: !Ref CodeStorageBucket

            # DynamoDB Tables
            - Name: TASKS_TABLE_NAME
              Value: !Ref TasksTable
            - Name: TASK_DECISIONS_TABLE_NAME
              Value: !Ref TaskDecisionsTable
            - Name: TASK_ACTIVITY_LOGS_TABLE_NAME
              Value: !Ref TaskActivityLogsTable
            - Name: USAGE_METRICS_TABLE_NAME
              Value: !Ref UsageMetricsTable
            - Name: CHAT_MESSAGES_TABLE_NAME
              Value: !Ref ChatMessagesTable
            - Name: DEPLOYMENT_LOCKS_TABLE_NAME
              Value: !Ref DeploymentLocksTable
            - Name: USER_REPO_CONFIGS_TABLE_NAME
              Value: !Ref UserRepoConfigsTable

            # CloudWatch Log Group for application logs
            - Name: CLOUDWATCH_LOG_GROUP
              Value: !Sub "/aws/kanu/${AWS::StackName}/deployments"
            # Unified task log group for per-task + per-agent consolidated logs
            - Name: UNIFIED_TASK_LOG_GROUP
              Value: !Sub "/aws/kanu/${AWS::StackName}/task-logs"

            - Name: GIT_TOKEN_ENCRYPTION_KEY
              Value:
                Fn::Sub:
                  - "{{resolve:secretsmanager:${SecretArn}:SecretString:key}}"
                  - SecretArn: !Ref GitTokenEncryptionKey

            # Messaging
            - Name: GENERATOR_STATUS_TOPIC_ARN
              Value: !Ref GeneratorStatusTopic
            - Name: GENERATOR_STATUS_QUEUE_URL
              Value: !Ref GeneratorStatusQueue
            - Name: DECISION_RESPONSE_TOPIC_ARN
              Value: !Ref DecisionResponseTopic
            - Name: DECISION_RESPONSE_QUEUE_URL
              Value: !Ref DecisionResponseQueue
            - Name: ECS_TASK_TIMEOUT_SECONDS
              Value: "10800"

            - !If
              - HasAnthropicApiKey
              - Name: ANTHROPIC_API_KEY
                Value: !Sub "{{resolve:secretsmanager:${AnthropicApiKeySecret}:SecretString}}"
              - !Ref AWS::NoValue

            # Deployment Mode Configuration
            # SelfHosted: Only SelfHosted Cognito group (customer deployments)
            # SelfServiceAndSelfHosted: Admin, Member, SelfHosted groups (internal multi-tenant)
            - Name: DEPLOYMENT_MODE
              Value: !Ref DeploymentMode
            - Name: COGNITO_USER_POOL_ID
              Value: !Ref CognitoUserPool
            - Name: COGNITO_USER_POOL_CLIENT_ID
              Value: !Ref CognitoUserPoolClient
            - Name: COGNITO_USER_POOL_CLIENT_SECRET
              Value: !GetAtt CognitoUserPoolClient.ClientSecret

            # DynamoDB Tables
            - Name: ORGANIZATIONS_TABLE_NAME
              Value: !Ref OrganizationsTable
            - Name: USERS_TABLE_NAME
              Value: !Ref SelfServiceUsersTable
            - Name: AWS_ACCOUNTS_TABLE_NAME
              Value: !Ref AwsAccountsTable
            - Name: AWS_ACCOUNT_ROLES_TABLE_NAME
              Value: !Ref AwsAccountRolesTable
            - Name: USER_PERMISSIONS_TABLE_NAME
              Value: !Ref UserPermissionsTable
            - Name: INFRA_SCAN_CACHE_TABLE
              Value: !Ref InfraScannerCacheTable

            # Billing tables (SelfServiceAndSelfHosted mode only)
            - !If
              - IncludeMultiTenantGroups
              - Name: BILLING_TABLE_NAME
                Value: !Ref BillingTable
              - !Ref AWS::NoValue
            - !If
              - IncludeMultiTenantGroups
              - Name: MONTHLY_USAGE_TABLE_NAME
                Value: !Ref MonthlyUsageTable
              - !Ref AWS::NoValue
            - !If
              - IncludeMultiTenantGroups
              - Name: TASK_USAGE_TABLE_NAME
                Value: !Ref TaskUsageTable
              - !Ref AWS::NoValue
            - !If
              - IncludeMultiTenantGroups
              - Name: PROMO_CODES_TABLE_NAME
                Value: !Ref PromoCodesTable
              - !Ref AWS::NoValue
            - !If
              - IncludeMultiTenantGroups
              - Name: PROMO_REDEMPTIONS_TABLE_NAME
                Value: !Ref PromoRedemptionsTable
              - !Ref AWS::NoValue

            # Stripe billing configuration (only if Stripe is configured)
            - !If
              - HasStripeConfig
              - Name: STRIPE_SECRET_KEY
                Value: !Sub "{{resolve:secretsmanager:${StripeApiKeySecret}:SecretString}}"
              - !Ref AWS::NoValue
            - !If
              - HasStripeConfig
              - Name: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
                Value: !Ref StripePublishableKey
              - !Ref AWS::NoValue
            - !If
              - HasStripeConfig
              - Name: STRIPE_WEBHOOK_SECRET
                Value: !Sub "{{resolve:secretsmanager:${StripeWebhookSecretSecret}:SecretString}}"
              - !Ref AWS::NoValue
            - !If
              - HasStripeConfig
              - Name: STRIPE_PRICE_ID
                Value: !Ref StripePriceId
              - !Ref AWS::NoValue
            - !If
              - HasStripeConfig
              - Name: STRIPE_METER_EVENT_NAME
                Value: "kanu_token_usage"
              - !Ref AWS::NoValue

            - !If
              - IncludeMultiTenantGroups
              - Name: RATE_LIMIT_COUNTERS_TABLE_NAME
                Value: !Ref RateLimitCountersTable
              - !Ref AWS::NoValue
            # reCAPTCHA v3 configuration (optional - for bot protection on auth flows)
            - !If
              - HasRecaptchaConfig
              - Name: RECAPTCHA_SITE_KEY
                Value: !Ref RecaptchaSiteKey
              - !Ref AWS::NoValue
            - !If
              - HasRecaptchaConfig
              - Name: RECAPTCHA_SECRET_KEY
                Value: !Sub "{{resolve:secretsmanager:${RecaptchaSecretKeySecret}:SecretString}}"
              - !Ref AWS::NoValue

            # GitHub App Integration (direct mode — SelfServiceAndSelfHosted / Kanu prod)
            # Secrets stored in Secrets Manager, injected at runtime (NOT baked into Docker image)
            - !If
              - HasGitHubApp
              - Name: GITHUB_APP_ID
                Value: !Ref GitHubAppId
              - !Ref AWS::NoValue
            - !If
              - HasGitHubApp
              - Name: GITHUB_APP_CLIENT_ID
                Value: !Ref GitHubAppClientId
              - !Ref AWS::NoValue
            - !If
              - HasGitHubApp
              - Name: GITHUB_APP_CLIENT_SECRET
                Value: !Sub "{{resolve:secretsmanager:${GitHubAppClientSecretSecret}:SecretString}}"
              - !Ref AWS::NoValue
            - !If
              - HasGitHubApp
              - Name: GITHUB_APP_PRIVATE_KEY
                Value: !Sub "{{resolve:secretsmanager:${AWS::StackName}-github-app-private-key:SecretString}}"
              - !Ref AWS::NoValue
            - !If
              - HasGitHubApp
              - Name: GITHUB_APP_NAME
                Value: !Ref GitHubAppName
              - !Ref AWS::NoValue

            # GitHub Auth Proxy (self-hosted — delegates all GitHub ops to Kanu prod)
            - !If
              - HasGitHubProxy
              - Name: GITHUB_AUTH_PROXY_URL
                Value: !Ref GitHubAuthProxyUrl
              - !Ref AWS::NoValue
            - !If
              - HasGitHubProxy
              - Name: GITHUB_PROXY_SECRET
                Value: !Sub "{{resolve:secretsmanager:${GitHubProxySecretSecret}:SecretString}}"
              - !Ref AWS::NoValue

            # GitLab OAuth Integration (direct — Kanu prod owns the GitLab OAuth app)
            - !If
              - HasGitLab
              - Name: GITLAB_APP_CLIENT_ID
                Value: !Ref GitLabAppClientId
              - !Ref AWS::NoValue
            - !If
              - HasGitLab
              - Name: GITLAB_APP_CLIENT_SECRET
                Value: !Sub "{{resolve:secretsmanager:${GitLabAppClientSecretSecret}:SecretString}}"
              - !Ref AWS::NoValue
            # HMAC secret for GitLab OAuth proxy communication.
            # Used by the credential helper to sign requests to the webapp's
            # /api/auth/gitlab/proxy/api endpoint for fresh token resolution.
            - !If
              - HasGitLabProxySecret
              - Name: GITLAB_PROXY_SECRET
                Value: !Sub "{{resolve:secretsmanager:${GitLabProxySecretSecret}:SecretString}}"
              - !Ref AWS::NoValue

            # GitLab Auth Proxy URL (self-hosted — delegates all GitLab ops to Kanu prod)
            - !If
              - HasGitLabProxy
              - Name: GITLAB_AUTH_PROXY_URL
                Value: !Ref GitLabAuthProxyUrl
              - !Ref AWS::NoValue
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: webapp
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-webapp"

  # Generation Task Definition
  GenerationTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DependsOn: ImageCopyCustomResource
    Properties:
      Family: !Sub "${AWS::StackName}-generation"
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      Cpu: "4096"
      Memory: "8192"
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      TaskRoleArn: !GetAtt GenerationTaskRole.Arn
      ContainerDefinitions:
        - Name: orchestrator
          # Use customer's local ECR (images copied during deployment)
          Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${OrchestratorRepository}:latest"
          Essential: true
          PortMappings:
            - ContainerPort: 8000
              Protocol: tcp
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref GenerationLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: generation
          Environment:
            # Core AWS Configuration
            - Name: AWS_REGION
              Value: !Ref AWS::Region
            - Name: AWS_ACCOUNT_ID
              Value: !Ref AWS::AccountId

            # DynamoDB Tables
            # Task System Tables
            - Name: TASKS_TABLE_NAME
              Value: !Ref TasksTable
            - Name: TASK_DECISIONS_TABLE_NAME
              Value: !Ref TaskDecisionsTable
            - Name: TASK_ACTIVITY_LOGS_TABLE_NAME
              Value: !Ref TaskActivityLogsTable
            - Name: USAGE_METRICS_TABLE_NAME
              Value: !Ref UsageMetricsTable
            - Name: CHAT_MESSAGES_TABLE_NAME
              Value: !Ref ChatMessagesTable
            - Name: DEPLOYMENT_LOCKS_TABLE_NAME
              Value: !Ref DeploymentLocksTable

            # CloudWatch Log Group for application logs
            - Name: CLOUDWATCH_LOG_GROUP
              Value: !Sub "/aws/kanu/${AWS::StackName}/deployments"
            # Unified task log group for per-task + per-agent consolidated logs
            - Name: UNIFIED_TASK_LOG_GROUP
              Value: !Sub "/aws/kanu/${AWS::StackName}/task-logs"

            # S3 Buckets
            - Name: KANU_CODE_BUCKET
              Value: !Ref CodeStorageBucket

            - Name: ENABLE_MEMORY_INJECTION
              Value: !Ref EnableMemoryInjection
            - Name: ENABLE_SKILL_INJECTION
              Value: !Ref EnableSkillInjection

            # Generation Configuration
            # Use Bedrock when no Anthropic API key is provided, otherwise use direct API
            - Name: USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "false", "true"]
            - Name: CLAUDE_CODE_USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "0", "1"]

            # Bedrock Guardrails Configuration
            - Name: ENABLE_BEDROCK_GUARDRAILS
              Value: "false"
            - Name: BEDROCK_GUARDRAIL_ID
              Value: !GetAtt KanuBedrockGuardrail.GuardrailId
            - Name: BEDROCK_GUARDRAIL_VERSION
              Value: !GetAtt KanuBedrockGuardrailVersion.Version

            # Anthropic API Key (from Secrets Manager, only if key is provided)
            - !If
              - HasAnthropicApiKey
              - Name: ANTHROPIC_API_KEY
                Value: !Sub "{{resolve:secretsmanager:${AnthropicApiKeySecret}:SecretString}}"
              - !Ref AWS::NoValue

            # ABAC Role ARNs for credential provider initialization
            # SELFHOSTED_ROLE_ARN defaults to poweruser for backward compatibility;
            # the sandbox launcher overrides this with the user's selected role at launch time
            - Name: SELFHOSTED_ROLE_ARN
              Value: !GetAtt SelfHostedPowerUserRole.Arn
            - Name: ORG_SCOPED_ROLE_ARN
              Value: !GetAtt OrgScopedRole.Arn

            # Cognito Configuration for JWT validation (SSE endpoint)
            - Name: COGNITO_USER_POOL_ID
              Value: !Ref CognitoUserPool
            - Name: COGNITO_REGION
              Value: !Ref AWS::Region
            - Name: COGNITO_APP_CLIENT_ID
              Value: !Ref CognitoUserPoolClient

            # SSE Streaming Configuration
            - Name: SSE_API_PORT
              Value: "8000"
            - Name: SSE_HEARTBEAT_INTERVAL
              Value: "15"

            # Billing configuration (only for self-service deployments)
            # For SelfServiceAndSelfHosted mode, billing tables enable usage tracking
            - !If
              - IncludeMultiTenantGroups
              - Name: TASK_USAGE_TABLE_NAME
                Value: !Ref TaskUsageTable
              - !Ref AWS::NoValue
            - !If
              - IncludeMultiTenantGroups
              - Name: MONTHLY_USAGE_TABLE_NAME
                Value: !Ref MonthlyUsageTable
              - !Ref AWS::NoValue
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-generation"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # NAT GATEWAY - For Private Subnet Internet Access
  # ==============================================

  # Elastic IP for NAT Gateway
  NATGatewayEIP:
    Type: AWS::EC2::EIP
    Condition: UsePrivateSubnetsCondition
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-nat-eip"

  # NAT Gateway in first public subnet
  NATGateway:
    Type: AWS::EC2::NatGateway
    Condition: UsePrivateSubnetsCondition
    Properties:
      AllocationId: !GetAtt NATGatewayEIP.AllocationId
      SubnetId: !If
        - UseExistingSubnets
        - !Select [0, !Ref PublicSubnetIds]
        - !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-nat-gateway"

  # Route table for private subnets
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Condition: UsePrivateSubnetsCondition
    Properties:
      VpcId: !Ref VpcId
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-private-rt"

  # Route to NAT Gateway for internet access
  PrivateRoute:
    Type: AWS::EC2::Route
    Condition: UsePrivateSubnetsCondition
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NATGateway

  # Associate first private subnet with route table
  PrivateSubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: UsePrivateSubnetsCondition
    Properties:
      SubnetId: !If
        - UseExistingSubnets
        - !Select [0, !Ref PrivateSubnetIds]
        - !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

  # Associate second private subnet with route table
  PrivateSubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: UsePrivateSubnetsCondition
    Properties:
      SubnetId: !If
        - UseExistingSubnets
        - !Select [1, !Ref PrivateSubnetIds]
        - !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  # ==============================================
  # VPC ENDPOINTS - Private Access to AWS Services
  # ==============================================
  # These endpoints keep traffic to DynamoDB and S3 within the AWS network,
  # reducing attack surface and improving security/performance.
  #
  # Only created when we manage the subnets ourselves (auto-cidr or manual-cidr modes).
  # When using existing subnets, customers should configure their own VPC endpoints.

  # DynamoDB Gateway Endpoint (free, no hourly charges)
  # Created when using private subnets (attaches to PrivateRouteTable)
  DynamoDBEndpointPrivate:
    Type: AWS::EC2::VPCEndpoint
    Condition: UsePrivateSubnetsCondition
    Properties:
      VpcId: !Ref VpcId
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.dynamodb"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PrivateRouteTable
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-dynamodb-endpoint"
        - Key: Environment
          Value: !Ref EnvironmentName

  # DynamoDB Gateway Endpoint for public subnet mode (when creating our own subnets)
  DynamoDBEndpointPublic:
    Type: AWS::EC2::VPCEndpoint
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.dynamodb"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PublicRouteTable
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-dynamodb-endpoint-public"
        - Key: Environment
          Value: !Ref EnvironmentName

  # S3 Gateway Endpoint (free, no hourly charges)
  # Created when using private subnets (attaches to PrivateRouteTable)
  S3EndpointPrivate:
    Type: AWS::EC2::VPCEndpoint
    Condition: UsePrivateSubnetsCondition
    Properties:
      VpcId: !Ref VpcId
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PrivateRouteTable
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-s3-endpoint"
        - Key: Environment
          Value: !Ref EnvironmentName

  # S3 Gateway Endpoint for public subnet mode (when creating our own subnets)
  S3EndpointPublic:
    Type: AWS::EC2::VPCEndpoint
    Condition: CreateSubnets
    Properties:
      VpcId: !Ref VpcId
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PublicRouteTable
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-s3-endpoint-public"
        - Key: Environment
          Value: !Ref EnvironmentName

  # ==============================================
  # BEDROCK RUNTIME VPC ENDPOINT (Private Subnets Only)
  # ==============================================
  # Creates a VPC Interface Endpoint for Bedrock Runtime so that
  # Claude API calls bypass NAT gateway (avoids timeout/hang issues).
  # All other AWS services (STS, ECR, Secrets Manager, etc.) continue
  # through NAT gateway as before.
  #
  # A Custom Resource Lambda auto-detects if a bedrock-runtime endpoint
  # already exists and skips creation if so.  This is safe for:
  #   - Customer VPCs that already have a Bedrock endpoint
  #   - Multiple KANU stacks sharing the same VPC
  #   - Fresh deployments with no endpoints
  #
  # On stack deletion, only the endpoint tagged ManagedBy={StackName} is
  # removed — customer-owned or other-stack endpoints are left untouched.

  # Security Group for VPC Interface Endpoints we create.
  # Allows HTTPS (443) from this stack's ECS and Generation tasks.
  # When multiple stacks share a VPC, the Lambda adds each stack's SG to
  # the existing endpoint via ModifyVpcEndpoint — so every stack's tasks
  # are authorized.  On stack deletion the Lambda removes its SG first,
  # allowing CloudFormation to then delete it without DependencyViolation.
  VPCEndpointSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Condition: CreateVPCInterfaceEndpoints
    Properties:
      GroupDescription: Security group for VPC Interface Endpoints
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          SourceSecurityGroupId: !Ref ECSSecurityGroup
          Description: Allow HTTPS from Web App ECS tasks
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          SourceSecurityGroupId: !Ref GenerationSecurityGroup
          Description: Allow HTTPS from Generation ECS tasks
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-vpce-sg"
        - Key: Environment
          Value: !Ref EnvironmentName

  # IAM Role for the VPC Endpoint Manager Lambda
  VPCEndpointManagerRole:
    Type: AWS::IAM::Role
    Condition: CreateVPCInterfaceEndpoints
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: ManageVPCEndpoints
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Describe is read-only and does not support resource-level ARNs
              - Sid: DescribeEndpoints
                Effect: Allow
                Action:
                  - ec2:DescribeVpcEndpoints
                Resource: "*"
              # CreateVpcEndpoint needs access to the VPC, subnets, SG, and
              # the yet-to-exist endpoint — all scoped to this account+region.
              - Sid: CreateEndpoints
                Effect: Allow
                Action:
                  - ec2:CreateVpcEndpoint
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:vpc-endpoint/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:vpc/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:subnet/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*"
              # Modify, delete, and tag only endpoints in this account+region.
              # ModifyVpcEndpoint requires permission on both the endpoint AND any
              # security groups being added/removed.
              - Sid: ManageEndpoints
                Effect: Allow
                Action:
                  - ec2:ModifyVpcEndpoint
                  - ec2:DeleteVpcEndpoints
                  - ec2:CreateTags
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:vpc-endpoint/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*"

  # Lambda that auto-detects and creates missing VPC Interface Endpoints.
  # Idempotent: skips endpoints that already exist in the VPC (unless they
  # are native CFN resources from this stack being migrated — see above).
  VPCEndpointManagerFunction:
    Type: AWS::Lambda::Function
    Condition: CreateVPCInterfaceEndpoints
    Properties:
      FunctionName: !Sub "${AWS::StackName}-vpce-manager"
      Runtime: python3.12
      Handler: index.handler
      Timeout: 300
      MemorySize: 256
      ReservedConcurrentExecutions: 1
      Role: !GetAtt VPCEndpointManagerRole.Arn
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import json
          import time
          import traceback

          def get_existing_endpoints(ec2, vpc_id):
              """Return dict of service_name -> endpoint info for active endpoints."""
              endpoints = {}
              params = {'Filters': [{'Name': 'vpc-id', 'Values': [vpc_id]}]}
              while True:
                  resp = ec2.describe_vpc_endpoints(**params)
                  for ep in resp['VpcEndpoints']:
                      if ep['State'] in ('deleting', 'deleted', 'rejected', 'failed'):
                          continue
                      tags = {t['Key']: t['Value'] for t in ep.get('Tags', [])}
                      svc = ep['ServiceName']
                      # Prefer our ManagedBy-tagged endpoint when multiple exist
                      if svc not in endpoints or 'ManagedBy' in tags:
                          endpoints[svc] = {
                              'VpcEndpointId': ep['VpcEndpointId'],
                              'Tags': tags,
                          }
                  token = resp.get('NextToken')
                  if token:
                      params['NextToken'] = token
                  else:
                      break
              return endpoints

          def get_managed_endpoint_ids(ec2, vpc_id, stack_name):
              """Return list of endpoint IDs that were created (tagged ManagedBy) by this stack."""
              ids = []
              params = {
                  'Filters': [
                      {'Name': 'vpc-id', 'Values': [vpc_id]},
                      {'Name': 'tag:ManagedBy', 'Values': [stack_name]},
                  ]
              }
              while True:
                  resp = ec2.describe_vpc_endpoints(**params)
                  for ep in resp['VpcEndpoints']:
                      if ep['State'] not in ('deleting', 'deleted'):
                          ids.append(ep['VpcEndpointId'])
                  token = resp.get('NextToken')
                  if token:
                      params['NextToken'] = token
                  else:
                      break
              return ids

          def wait_for_endpoint_deletion(ec2, endpoint_id, timeout=120):
              """Poll until a VPC endpoint reaches 'deleted' state or disappears."""
              deadline = time.time() + timeout
              while time.time() < deadline:
                  try:
                      resp = ec2.describe_vpc_endpoints(VpcEndpointIds=[endpoint_id])
                      eps = resp.get('VpcEndpoints', [])
                      if not eps or eps[0]['State'] == 'deleted':
                          return True
                  except Exception as e:
                      if 'InvalidVpcEndpointId' in str(e):
                          return True
                      raise
                  time.sleep(5)
              return False

          def ensure_sg_on_endpoint(ec2, endpoint_id, sg_id):
              """Add a security group to a VPC endpoint (idempotent).
              Raises on failure so callers can treat it as a deployment error —
              the whole point of this resource is ensuring the SG is present."""
              ec2.modify_vpc_endpoint(
                  VpcEndpointId=endpoint_id,
                  AddSecurityGroupIds=[sg_id]
              )
              print(f"  Added SG {sg_id} to endpoint {endpoint_id}")

          def remove_sg_from_endpoints(ec2, vpc_id, sg_id):
              """Remove a security group from all VPC endpoints in the VPC (best-effort).
              Called during stack deletion so CloudFormation can delete the SG without
              a DependencyViolation error."""
              params = {'Filters': [{'Name': 'vpc-id', 'Values': [vpc_id]}]}
              while True:
                  resp = ec2.describe_vpc_endpoints(**params)
                  for ep in resp['VpcEndpoints']:
                      if ep['State'] in ('deleting', 'deleted'):
                          continue
                      ep_sg_ids = [g['GroupId'] for g in ep.get('Groups', [])]
                      if sg_id in ep_sg_ids and len(ep_sg_ids) > 1:
                          try:
                              ec2.modify_vpc_endpoint(
                                  VpcEndpointId=ep['VpcEndpointId'],
                                  RemoveSecurityGroupIds=[sg_id]
                              )
                              print(f"  Removed SG {sg_id} from endpoint {ep['VpcEndpointId']}")
                          except Exception as e:
                              print(f"  WARN: could not remove SG from {ep['VpcEndpointId']} ({e})")
                  token = resp.get('NextToken')
                  if token:
                      params['NextToken'] = token
                  else:
                      break

          def handler(event, context):
              # ----------------------------------------------------------
              # DELETE — always return SUCCESS to prevent stuck DELETE_FAILED stacks.
              # Best-effort cleanup: errors are logged but never propagated.
              # ----------------------------------------------------------
              if event['RequestType'] == 'Delete':
                  physical_id = event.get('PhysicalResourceId', 'vpce-manager-unknown')
                  try:
                      props = event['ResourceProperties']
                      vpc_id = props['VpcId']
                      region = props['Region']
                      stack_name = props['StackName']
                      sg_id = props.get('SecurityGroupId', '')
                      ec2 = boto3.client('ec2', region_name=region)

                      # Remove our SG from all endpoints BEFORE deleting anything.
                      # This prevents DependencyViolation when CFN deletes our SG.
                      if sg_id:
                          print(f"Removing SG {sg_id} from all VPC endpoints...")
                          remove_sg_from_endpoints(ec2, vpc_id, sg_id)

                      # Delete endpoints we created (tagged ManagedBy=this stack)
                      managed = get_managed_endpoint_ids(ec2, vpc_id, stack_name)
                      if managed:
                          ec2.delete_vpc_endpoints(VpcEndpointIds=managed)
                          print(f"Deleted {len(managed)} managed endpoints: {managed}")
                      else:
                          print("No managed endpoints to delete")
                  except Exception as del_err:
                      print(f"Delete cleanup error (non-fatal): {del_err}")
                      traceback.print_exc()
                  cfnresponse.send(event, context, cfnresponse.SUCCESS, {}, physical_id)
                  return

              # ----------------------------------------------------------
              # CREATE / UPDATE
              # ----------------------------------------------------------
              try:
                  props = event['ResourceProperties']
                  vpc_id = props['VpcId']
                  region = props['Region']
                  stack_name = props['StackName']
                  physical_id = f"vpce-manager-{vpc_id}-{stack_name}"

                  ec2 = boto3.client('ec2', region_name=region)

                  # ----------------------------------------------------------
                  # Auto-detect and create missing endpoints
                  # ----------------------------------------------------------
                  subnet_ids = [s for s in props['SubnetIds'] if s]
                  sg_id = props['SecurityGroupId']
                  services = props['Services']
                  environment = props.get('Environment', '')

                  existing = get_existing_endpoints(ec2, vpc_id)
                  created, skipped, replaced, errors, sg_errors = [], [], [], [], []

                  for svc in services:
                      full_svc = f"com.amazonaws.{region}.{svc}"
                      ep_info = existing.get(full_svc)

                      if not ep_info:
                          # --- No existing endpoint → create ---
                          try:
                              print(f"CREATE {svc}: no existing endpoint, creating...")
                              ec2.create_vpc_endpoint(
                                  VpcId=vpc_id,
                                  ServiceName=full_svc,
                                  VpcEndpointType='Interface',
                                  PrivateDnsEnabled=True,
                                  SubnetIds=subnet_ids,
                                  SecurityGroupIds=[sg_id],
                                  TagSpecifications=[{
                                      'ResourceType': 'vpc-endpoint',
                                      'Tags': [
                                          {'Key': 'ManagedBy', 'Value': stack_name},
                                          {'Key': 'Name', 'Value': f'{stack_name}-{svc}-endpoint'},
                                          {'Key': 'Environment', 'Value': environment},
                                      ]
                                  }]
                              )
                              created.append(svc)
                          except Exception as svc_err:
                              errors.append(svc)
                              print(f"WARN  {svc}: create failed ({svc_err}), will retry on next deploy")
                          continue

                      tags = ep_info['Tags']

                      # --- Already managed by us → ensure our SG is registered ---
                      if tags.get('ManagedBy') == stack_name:
                          skipped.append(svc)
                          print(f"SKIP  {svc}: already managed by this stack")
                          try:
                              ensure_sg_on_endpoint(ec2, ep_info['VpcEndpointId'], sg_id)
                          except Exception as sg_err:
                              sg_errors.append(svc)
                              print(f"ERROR {svc}: failed to add SG to endpoint ({sg_err})")
                          continue

                      # --- Native CFN resource from THIS stack → about to be deleted ---
                      # CloudFormation creates the custom resource before deleting the
                      # old native AWS::EC2::VPCEndpoint.  If we just skip here, CFN
                      # will delete the old endpoint and we end up with nothing.
                      # Fix: delete the old endpoint now, wait, then create a replacement
                      # tagged ManagedBy so it survives the stack update.
                      cfn_stack_tag = tags.get('aws:cloudformation:stack-name', '')
                      if cfn_stack_tag == stack_name:
                          old_id = ep_info['VpcEndpointId']
                          try:
                              print(f"REPLACE {svc}: native CFN endpoint {old_id} belongs to "
                                    f"this stack and will be deleted by CFN — replacing now...")
                              ec2.delete_vpc_endpoints(VpcEndpointIds=[old_id])
                              if not wait_for_endpoint_deletion(ec2, old_id):
                                  errors.append(svc)
                                  print(f"WARN  {svc}: timed out waiting for {old_id} deletion")
                                  continue
                              print(f"  Old endpoint {old_id} deleted, creating replacement...")
                              ec2.create_vpc_endpoint(
                                  VpcId=vpc_id,
                                  ServiceName=full_svc,
                                  VpcEndpointType='Interface',
                                  PrivateDnsEnabled=True,
                                  SubnetIds=subnet_ids,
                                  SecurityGroupIds=[sg_id],
                                  TagSpecifications=[{
                                      'ResourceType': 'vpc-endpoint',
                                      'Tags': [
                                          {'Key': 'ManagedBy', 'Value': stack_name},
                                          {'Key': 'Name', 'Value': f'{stack_name}-{svc}-endpoint'},
                                          {'Key': 'Environment', 'Value': environment},
                                      ]
                                  }]
                              )
                              replaced.append(svc)
                              print(f"  Replaced {svc} successfully")
                          except Exception as replace_err:
                              errors.append(svc)
                              print(f"WARN  {svc}: replace failed ({replace_err})")
                          continue

                      # --- Endpoint belongs to someone else or another stack → skip ---
                      # But add our SG so this stack's tasks can reach the endpoint
                      # (PrivateDnsEnabled=true is VPC-wide; without our SG, traffic
                      # from this stack's tasks would be silently dropped).
                      skipped.append(svc)
                      print(f"SKIP  {svc}: endpoint exists in VPC (owned externally)")
                      try:
                          ensure_sg_on_endpoint(ec2, ep_info['VpcEndpointId'], sg_id)
                      except Exception as sg_err:
                          sg_errors.append(svc)
                          print(f"ERROR {svc}: failed to add SG to endpoint ({sg_err})")

                  result = {
                      'Created': ','.join(created) or 'none',
                      'Replaced': ','.join(replaced) or 'none',
                      'Skipped': ','.join(skipped) or 'none',
                      'Warnings': ','.join(errors) or 'none',
                      'SGErrors': ','.join(sg_errors) or 'none',
                  }
                  print(f"Result: {json.dumps(result)}")
                  # Only SG errors are fatal — endpoint create/replace failures are
                  # transient warnings that will be retried on the next deploy.
                  status = cfnresponse.FAILED if sg_errors else cfnresponse.SUCCESS
                  cfnresponse.send(event, context, status, result, physical_id)

              except Exception as e:
                  print(f"Error: {str(e)}")
                  traceback.print_exc()
                  pid = event.get('PhysicalResourceId', 'vpce-manager-error')
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)}, pid)

  # Custom Resource: auto-detect and create Bedrock Runtime VPC Interface Endpoint.
  # Only bedrock-runtime — all other services continue through NAT gateway.
  VPCEndpointManager:
    Type: Custom::VPCEndpointManager
    Condition: CreateVPCInterfaceEndpoints
    DependsOn: VPCEndpointSecurityGroup
    Properties:
      ServiceToken: !GetAtt VPCEndpointManagerFunction.Arn
      VpcId: !Ref VpcId
      Region: !Ref "AWS::Region"
      StackName: !Ref "AWS::StackName"
      Environment: !Ref EnvironmentName
      SecurityGroupId: !Ref VPCEndpointSecurityGroup
      SubnetIds: !If
        - UseExistingSubnets
        - !Ref PrivateSubnetIds
        - [!Ref PrivateSubnet1, !Ref PrivateSubnet2]
      Services:
        - bedrock-runtime
      # Force CloudFormation to re-invoke the Lambda on every stack update.
      # Without this, CFN skips the Update event if no properties changed,
      # and ensure_sg_on_endpoint would never run for existing stacks.
      Version: !Ref PlatformVersion

  # ==============================================
  # SERVICES
  # ==============================================

  # ECS Service
  WebAppECSService:
    Type: AWS::ECS::Service
    DependsOn: ALBListener
    Properties:
      ServiceName: !Sub "${AWS::StackName}-webapp-service"
      Cluster: !Ref WebAppECSCluster
      TaskDefinition: !Ref WebAppTaskDefinition
      DesiredCount: 2
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: !If [UsePrivateSubnetsCondition, DISABLED, ENABLED]
          Subnets: !If
            - UseExistingSubnets
            - !If [
                UsePrivateSubnetsCondition,
                !Ref PrivateSubnetIds,
                !Ref PublicSubnetIds,
              ]
            - !If [
                UsePrivateSubnetsCondition,
                [!Ref PrivateSubnet1, !Ref PrivateSubnet2],
                [!Ref PublicSubnet1, !Ref PublicSubnet2],
              ]
          SecurityGroups:
            - !Ref ECSSecurityGroup
      LoadBalancers:
        - ContainerName: kanu-webapp
          ContainerPort: 3000
          TargetGroupArn: !Ref TargetGroup
      HealthCheckGracePeriodSeconds: 60
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-webapp-service"

  # ==============================================
  # AUTO-UPDATE LAMBDA
  # ==============================================
  # Lambda and IAM role are created unconditionally so users can manually invoke
  # Only EventBridge schedule is conditional on EnableAutoUpdateCondition

  # Service role that CloudFormation assumes to manage stack resources during auto-updates.
  # Uses the same PowerUserAccessCustom pattern as SelfHostedGenerationTaskRole for broad
  # service coverage (future-proof for new resource types) plus scoped IAM permissions.
  # The trust policy restricts this role to CloudFormation only.
  AutoUpdateCfnServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-auto-update-cfn-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudformation.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        # PowerUserAccess equivalent (everything except IAM/Organizations/Account)
        # Same pattern as SelfHostedGenerationTaskRole - accepted by Marketplace
        - PolicyName: PowerUserAccessCustom
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                NotAction:
                  - iam:*
                  - organizations:*
                  - account:*
                Resource: "*"
        # IAM permissions for CloudFormation stack management
        # Mirrors SelfHostedGenerationTaskRole's IAMDeploymentAccess pattern
        - PolicyName: IAMManagement
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Role management permissions (scoped to stack roles)
              - Sid: RoleManagement
                Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:UpdateRole
                  - iam:GetRole
                  - iam:TagRole
                  - iam:UntagRole
                  - iam:UpdateAssumeRolePolicy
                  - iam:ListAttachedRolePolicies
                  - iam:GetRolePolicy
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:ListRolePolicies
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*"
              # Service-linked roles (for ECS, etc.)
              - Sid: ServiceLinkedRoles
                Effect: Allow
                Action:
                  - iam:CreateServiceLinkedRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/*"
              # ListRoles needed for CloudFormation resource lookups
              - Sid: ListRoles
                Effect: Allow
                Action:
                  - iam:ListRoles
                Resource: "*"
              # PassRole - restricted to AWS services that stack resources use
              - Sid: PassRoleToServices
                Effect: Allow
                Action:
                  - iam:PassRole
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-*"
                Condition:
                  StringEquals:
                    iam:PassedToService:
                      - lambda.amazonaws.com
                      - ecs-tasks.amazonaws.com
                      - ec2.amazonaws.com
                      - events.amazonaws.com
                      - codebuild.amazonaws.com
                      - cloudformation.amazonaws.com
                      - states.amazonaws.com
                      - logs.amazonaws.com
              # Policy management (for any managed policies the stack creates)
              - Sid: PolicyManagement
                Effect: Allow
                Action:
                  - iam:CreatePolicy
                  - iam:DeletePolicy
                  - iam:GetPolicy
                  - iam:GetPolicyVersion
                  - iam:ListPolicyVersions
                  - iam:CreatePolicyVersion
                  - iam:DeletePolicyVersion
                  - iam:TagPolicy
                  - iam:UntagPolicy
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:policy/${AWS::StackName}-*"
              # Instance profiles (if EC2 instances are added later)
              - Sid: InstanceProfiles
                Effect: Allow
                Action:
                  - iam:CreateInstanceProfile
                  - iam:DeleteInstanceProfile
                  - iam:GetInstanceProfile
                  - iam:AddRoleToInstanceProfile
                  - iam:RemoveRoleFromInstanceProfile
                  - iam:TagInstanceProfile
                  - iam:UntagInstanceProfile
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:instance-profile/${AWS::StackName}-*"
        # Security guardrails - prevent dangerous actions during stack updates
        - PolicyName: SecurityGuardrails
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Prevent creating persistent IAM credentials (privilege escalation)
              - Sid: DenyPersistentCredentials
                Effect: Deny
                Action:
                  - iam:CreateUser
                  - iam:CreateAccessKey
                  - iam:CreateLoginProfile
                  - iam:UpdateLoginProfile
                Resource: "*"
              # Prevent tampering with audit logs
              - Sid: DenyAuditTampering
                Effect: Deny
                Action:
                  - cloudtrail:DeleteTrail
                  - cloudtrail:StopLogging
                  - cloudtrail:UpdateTrail
                  - cloudtrail:PutEventSelectors
                Resource: "*"
              # Prevent accessing customer secrets (only KANU platform secrets allowed)
              # Note: GetRandomPassword is excluded because it's a service-level API with
              # no resource ARN — NotResource scoping causes it to always match the Deny.
              - Sid: DenyCustomerSecrets
                Effect: Deny
                Action:
                  - secretsmanager:GetSecretValue
                NotResource:
                  - !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${AWS::StackName}-*"
              # Prevent accessing customer SSM parameters (only KANU platform params allowed)
              - Sid: DenyCustomerSSMParams
                Effect: Deny
                Action:
                  - ssm:GetParameter
                  - ssm:GetParameters
                  - ssm:GetParametersByPath
                NotResource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/*"
              # Prevent modifying resources outside the stack (safety net)
              - Sid: DenyModifyingOtherStacks
                Effect: Deny
                Action:
                  - cloudformation:DeleteStack
                  - cloudformation:UpdateStack
                NotResource:
                  - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-auto-update-cfn-role"

  AutoUpdateLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-auto-update-lambda-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !FindInMap [AWSManagedPolicies, IAM, LambdaBasicExecution]
      Policies:
        - PolicyName: CloudFormationAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudformation:DescribeStacks
                  - cloudformation:UpdateStack
                  - cloudformation:DescribeStackEvents
                Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*"
              - Effect: Allow
                Action:
                  - cloudformation:GetTemplate
                  - cloudformation:ValidateTemplate
                  - cloudformation:GetTemplateSummary
                Resource: "*"
        - PolicyName: SSMAccess
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ssm:GetParameter
                Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/platform-version"
        - PolicyName: PassCfnServiceRole
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - iam:PassRole
                Resource: !GetAtt AutoUpdateCfnServiceRole.Arn
                Condition:
                  StringEquals:
                    "iam:PassedToService": "cloudformation.amazonaws.com"
        # Security guardrails - explicit denies for Lambda role
        - PolicyName: SecurityGuardrails
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              # Deny access to customer secrets
              - Sid: DenyCustomerSecrets
                Effect: Deny
                Action:
                  - secretsmanager:GetSecretValue
                  - secretsmanager:GetRandomPassword
                Resource: "*"
              # Deny access to customer SSM parameters (except KANU platform params)
              - Sid: DenyCustomerSSMParams
                Effect: Deny
                Action:
                  - ssm:GetParameter
                  - ssm:GetParameters
                  - ssm:GetParametersByPath
                NotResource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/*"
              # NOTE: No KMS deny - Lambda needs kms:Decrypt for env vars, and customer
              # secrets are already protected by Secrets Manager deny + scoped SSM deny.
              # Deny modifying stacks other than this one
              - Sid: DenyModifyingOtherStacks
                Effect: Deny
                Action:
                  - cloudformation:DeleteStack
                  - cloudformation:UpdateStack
                NotResource:
                  - !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${AWS::StackName}/*"
              # Deny any IAM modifications (Lambda only needs PassRole, which is explicitly allowed above)
              - Sid: DenyIAMModifications
                Effect: Deny
                Action:
                  - iam:CreateUser
                  - iam:CreateAccessKey
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                Resource: "*"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-auto-update-lambda-role"

  AutoUpdateLambda:
    Type: AWS::Lambda::Function
    DependsOn: AutoUpdateLambdaRole
    Properties:
      FunctionName: !Sub "${AWS::StackName}-auto-update"
      Description: "Checks for platform updates daily and triggers CloudFormation stack update if new version available"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt AutoUpdateLambdaRole.Arn
      Timeout: 60
      MemorySize: 256
      Environment:
        Variables:
          STACK_NAME: !Ref AWS::StackName
          VERSION_MANIFEST_URL: "https://kanu-public-templates.s3.us-east-1.amazonaws.com/releases/latest-version.json"
          SSM_PARAMETER_NAME: !Sub "/${AWS::StackName}/platform-version"
          CFN_SERVICE_ROLE_ARN: !GetAtt AutoUpdateCfnServiceRole.Arn
      Code:
        ZipFile: |
          import boto3
          import json
          import os
          import urllib.request
          from botocore.exceptions import ClientError

          cfn = boto3.client('cloudformation')
          ssm = boto3.client('ssm')

          def handler(event, context):
              stack_name = os.environ['STACK_NAME']
              manifest_url = os.environ['VERSION_MANIFEST_URL']
              ssm_param = os.environ['SSM_PARAMETER_NAME']
              cfn_role_arn = os.environ['CFN_SERVICE_ROLE_ARN']

              # Get current version from SSM
              try:
                  current_version = ssm.get_parameter(Name=ssm_param)['Parameter']['Value']
                  print(f"Current version: {current_version}")
              except ssm.exceptions.ParameterNotFound:
                  print("SSM parameter not found, skipping update check")
                  return {'statusCode': 200, 'body': 'No current version found'}
              except Exception as e:
                  print(f"Failed to get SSM parameter: {e}")
                  return {'statusCode': 500, 'body': f'Failed to get SSM parameter: {e}'}

              # Fetch latest version manifest
              try:
                  with urllib.request.urlopen(manifest_url, timeout=10) as response:
                      manifest = json.loads(response.read().decode())
                  print(f"Latest version: {manifest['version']}")
              except Exception as e:
                  print(f"Failed to fetch manifest: {e}")
                  return {'statusCode': 500, 'body': f'Failed to fetch manifest: {e}'}

              # Check if update needed
              if manifest['version'] == current_version:
                  print("Already on latest version, no update needed")
                  return {'statusCode': 200, 'body': 'Already up to date'}

              print(f"Update available: {current_version} -> {manifest['version']}")

              # Get new template's parameter definitions
              try:
                  template_summary = cfn.get_template_summary(TemplateURL=manifest['template_url'])
                  new_template_params = {p['ParameterKey'] for p in template_summary.get('Parameters', [])}
                  print(f"New template has {len(new_template_params)} parameters")
              except Exception as e:
                  print(f"Failed to get template summary: {e}")
                  return {'statusCode': 500, 'body': f'Failed to get template summary: {e}'}

              # Get current stack parameters
              stack = cfn.describe_stacks(StackName=stack_name)['Stacks'][0]
              current_params = {p['ParameterKey']: p for p in stack.get('Parameters', [])}
              print(f"Current stack has {len(current_params)} parameters")

              # Build parameters for update
              # Only include parameters that exist in the NEW template
              update_params = []
              image_params = {'WebAppImage', 'PythonOrchestratorImage'}

              # Parameters to force-set to a specific value during auto-update.
              # This ensures existing stacks pick up critical infrastructure changes
              # (e.g. VPC endpoints to bypass NAT gateway) without manual intervention.
              force_params = {
                  'CreateVPCEndpoints': 'true',
                  'PlatformVersion': manifest['version'],
              }

              for param_key in new_template_params:
                  if param_key in image_params:
                      # Use new image values from manifest
                      if param_key == 'WebAppImage':
                          update_params.append({
                              'ParameterKey': param_key,
                              'ParameterValue': manifest['webapp_image']
                          })
                      elif param_key == 'PythonOrchestratorImage':
                          update_params.append({
                              'ParameterKey': param_key,
                              'ParameterValue': manifest['orchestrator_image']
                          })
                  elif param_key in force_params:
                      # Force this parameter to the desired value
                      print(f"Forcing {param_key}={force_params[param_key]}")
                      update_params.append({
                          'ParameterKey': param_key,
                          'ParameterValue': force_params[param_key]
                          })
                  elif param_key in current_params:
                      # Parameter exists in both - keep existing value
                      update_params.append({
                          'ParameterKey': param_key,
                          'UsePreviousValue': True
                      })
                  # else: parameter only in new template - use default value (don't include in params)

              print(f"Passing {len(update_params)} parameters to UpdateStack")

              # Trigger stack update
              try:
                  cfn.update_stack(
                      StackName=stack_name,
                      TemplateURL=manifest['template_url'],
                      Parameters=update_params,
                      Capabilities=['CAPABILITY_NAMED_IAM'],
                      RoleARN=cfn_role_arn
                  )
                  print(f"Stack update initiated for version {manifest['version']}")
                  return {
                      'statusCode': 200,
                      'body': f"Update initiated: {current_version} -> {manifest['version']}"
                  }
              except ClientError as e:
                  if 'No updates are to be performed' in str(e):
                      print("No stack changes required")
                      return {'statusCode': 200, 'body': 'No stack changes required'}
                  print(f"Failed to update stack: {e}")
                  raise
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}-auto-update-lambda"

  AutoUpdateLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-auto-update"
      RetentionInDays: 30

  AutoUpdateScheduleRule:
    Type: AWS::Events::Rule
    Condition: EnableAutoUpdateCondition
    Properties:
      Name: !Sub "${AWS::StackName}-auto-update-schedule"
      Description: "Triggers daily auto-update check at 11 AM UTC"
      ScheduleExpression: "cron(0 11 * * ? *)"
      State: ENABLED
      Targets:
        - Id: AutoUpdateLambdaTarget
          Arn: !GetAtt AutoUpdateLambda.Arn

  AutoUpdateLambdaPermission:
    Type: AWS::Lambda::Permission
    Condition: EnableAutoUpdateCondition
    Properties:
      FunctionName: !Ref AutoUpdateLambda
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt AutoUpdateScheduleRule.Arn

  # ==============================================
  # MEMORY AGGREGATION
  # ==============================================
  MemoryAggregationLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs/${AWS::StackName}-memory-aggregation"
      RetentionInDays: 7

  # IAM role attached to the aggregation ECS task.
  # Minimal: only allows assuming OrgScopedRole with an org_id session tag.
  # All data access (S3, DynamoDB, CloudWatch, Bedrock) is enforced via ABAC
  # through OrgScopedRole — one assumed session per org per aggregation run.
  AggregationTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-aggregation-task-role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: AssumeOrgScopedRole
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sts:AssumeRole
                  - sts:TagSession
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-org-scoped-role"
        - !If
          - HasAnthropicApiKey
          - PolicyName: AnthropicApiKeySecretAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - secretsmanager:GetSecretValue
                    - secretsmanager:DescribeSecret
                  Resource: !Ref AnthropicApiKeySecret
          - !Ref AWS::NoValue

  AggregationTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    DependsOn: ImageCopyCustomResource
    Properties:
      Family: !Sub "${AWS::StackName}-memory-aggregation"
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      Cpu: "512"
      Memory: "1024"
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      TaskRoleArn: !GetAtt AggregationTaskRole.Arn
      ContainerDefinitions:
        - Name: aggregator
          Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${OrchestratorRepository}:latest"
          Essential: true
          Command:
            - .venv/bin/python
            - "-m"
            - src.memory.scheduler_main
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref MemoryAggregationLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: aggregation
          Environment:
            - Name: AWS_REGION
              Value: !Ref AWS::Region
            - Name: KANU_CODE_BUCKET
              Value: !Ref CodeStorageBucket
            - Name: TASKS_TABLE_NAME
              Value: !Ref TasksTable
            - Name: USER_REPO_CONFIGS_TABLE_NAME
              Value: !Ref UserRepoConfigsTable
            # Bootstrap defaults for Settings() validation at import time.
            # ORG_ID and ORG_IDS are overridden per-org by the dispatcher Lambda
            # via container overrides so each task uses ABAC for its own org.
            - Name: SITE_ID
              Value: memory-aggregation
            - Name: USER_ID
              Value: system
            - Name: ORG_ID
              Value: placeholder
            # RoleProvider uses this to assume OrgScopedRole with the org_id session tag
            - Name: ORG_SCOPED_ROLE_ARN
              Value: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-org-scoped-role"
            # CloudWatch log group for structured Python logs (must match OrgScopedRole policy)
            - Name: CLOUDWATCH_LOG_GROUP
              Value: !Sub "/aws/kanu/${AWS::StackName}/deployments"
            # Bedrock when no Anthropic API key (must match generation / webapp task definitions;
            # scheduler_main and get_pydantic_ai_model_for_task_role depend on USE_BEDROCK)
            - Name: USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "false", "true"]
            - Name: CLAUDE_CODE_USE_BEDROCK
              Value: !If [HasAnthropicApiKey, "0", "1"]
            # ANTHROPIC_API_KEY resolved at runtime from Secrets Manager
            - !If
              - HasAnthropicApiKey
              - Name: ANTHROPIC_API_KEY
                Value: !Sub "{{resolve:secretsmanager:${AnthropicApiKeySecret}:SecretString}}"
              - !Ref AWS::NoValue

  # IAM execution role for the dispatcher Lambda.
  # Discovers all org IDs (DynamoDB scan) and launches one aggregation ECS task per org.
  AggregationDispatcherRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AWS::StackName}-aggregation-dispatcher-role"
      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
      Policies:
        - PolicyName: DiscoverOrgs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:Scan
                Resource: !GetAtt TasksTable.Arn
        - PolicyName: LaunchAggregationTasks
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: ecs:RunTask
                Resource: !Ref AggregationTaskDefinition
                Condition:
                  ArnLike:
                    ecs:cluster: !GetAtt GenerationCluster.Arn
              - Effect: Allow
                Action: iam:PassRole
                Resource:
                  - !GetAtt AggregationTaskRole.Arn
                  - !GetAtt ECSTaskExecutionRole.Arn
                Condition:
                  StringLike:
                    iam:PassedToService: ecs-tasks.amazonaws.com

  # Lambda that fans out one aggregation ECS task per org, each scoped by ABAC.
  # Triggered daily by MemoryAggregationScheduleRule.
  AggregationDispatcherFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "${AWS::StackName}-aggregation-dispatcher"
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt AggregationDispatcherRole.Arn
      Timeout: 60
      Environment:
        Variables:
          TASKS_TABLE_NAME: !Ref TasksTable
          ECS_CLUSTER_ARN: !GetAtt GenerationCluster.Arn
          TASK_DEFINITION_ARN: !Ref AggregationTaskDefinition
          TASK_ROLE_ARN: !GetAtt AggregationTaskRole.Arn
          EXECUTION_ROLE_ARN: !GetAtt ECSTaskExecutionRole.Arn
          SUBNET_IDS: !If
            - UseExistingSubnets
            - !If
              - UsePrivateSubnetsCondition
              - !Join [",", !Ref PrivateSubnetIds]
              - !Join [",", !Ref PublicSubnetIds]
            - !If
              - UsePrivateSubnetsCondition
              - !Join [",", [!Ref PrivateSubnet1, !Ref PrivateSubnet2]]
              - !Join [",", [!Ref PublicSubnet1, !Ref PublicSubnet2]]
          SECURITY_GROUP_IDS: !Ref GenerationSecurityGroup
          ASSIGN_PUBLIC_IP: !If [UsePrivateSubnetsCondition, DISABLED, ENABLED]
      Code:
        ZipFile: |
          import json
          import os
          import boto3

          def handler(event, context):
              dynamodb = boto3.resource("dynamodb")
              ecs = boto3.client("ecs")

              table = dynamodb.Table(os.environ["TASKS_TABLE_NAME"])
              org_ids = set()
              kwargs = {
                  "ProjectionExpression": "org_id",
                  "FilterExpression": "attribute_exists(org_id)",
              }
              while True:
                  resp = table.scan(**kwargs)
                  for item in resp.get("Items", []):
                      oid = item.get("org_id")
                      if oid and oid != "selfhosted":
                          org_ids.add(oid)
                  if "LastEvaluatedKey" not in resp:
                      break
                  kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]

              subnets = [s.strip() for s in os.environ["SUBNET_IDS"].split(",") if s.strip()]
              security_groups = [sg.strip() for sg in os.environ["SECURITY_GROUP_IDS"].split(",") if sg.strip()]
              assign_public_ip = os.environ.get("ASSIGN_PUBLIC_IP", "ENABLED")

              launched, errors = 0, []
              for org_id in sorted(org_ids):
                  try:
                      ecs.run_task(
                          cluster=os.environ["ECS_CLUSTER_ARN"],
                          taskDefinition=os.environ["TASK_DEFINITION_ARN"],
                          launchType="FARGATE",
                          networkConfiguration={
                              "awsvpcConfiguration": {
                                  "subnets": subnets,
                                  "securityGroups": security_groups,
                                  "assignPublicIp": assign_public_ip,
                              }
                          },
                          overrides={
                              "containerOverrides": [{
                                  "name": "aggregator",
                                  "environment": [
                                      {"name": "ORG_ID", "value": org_id},
                                      {"name": "ORG_IDS", "value": org_id},
                                  ],
                              }]
                          },
                      )
                      launched += 1
                      print(f"Launched aggregation task for org_id={org_id}")
                  except Exception as exc:
                      errors.append({"org_id": org_id, "error": str(exc)})
                      print(f"ERROR launching task for org_id={org_id}: {exc}")

              result = {"launched": launched, "errors": errors, "org_count": len(org_ids)}
              print(json.dumps(result))
              if errors:
                  raise RuntimeError(f"Failed to launch tasks for {len(errors)} org(s): {errors}")
              return result

  # Allow EventBridge to invoke the dispatcher Lambda.
  AggregationDispatcherPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt AggregationDispatcherFunction.Arn
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt MemoryAggregationScheduleRule.Arn

  # Daily scheduled rule — fires at 2 AM UTC, invokes dispatcher Lambda which
  # launches one ABAC-scoped aggregation ECS task per org.
  MemoryAggregationScheduleRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub "${AWS::StackName}-memory-aggregation-schedule"
      Description: "Daily memory aggregation — dispatcher Lambda fans out one ABAC-scoped ECS task per org"
      ScheduleExpression: "cron(0 2 * * ? *)"
      State: ENABLED
      Targets:
        - Id: MemoryAggregationDispatcherTarget
          Arn: !GetAtt AggregationDispatcherFunction.Arn

  # MONITORING - CloudWatch Dashboard
  # ==============================================
  KanuDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardName: !Sub "${AWS::StackName}-dashboard"
      DashboardBody: !Sub
        - |
          {
            "widgets": [
              {
                "type": "text", "x": 0, "y": 0, "width": 24, "height": 1,
                "properties": { "markdown": "## Business Metrics" }
              },
              {
                "type": "metric", "x": 0, "y": 1, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"TaskCompleted\"', 'Sum', 300)", "id": "e1", "label": "Completed"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"TaskFailed\"', 'Sum', 300)", "id": "e2", "label": "Failed"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"TaskTimeout\"', 'Sum', 300)", "id": "e3", "label": "Timeout"}]
                  ],
                  "region": "${AWS::Region}", "title": "Task Completions & Failures", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 8, "y": 1, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"LocGenerated\"', 'Sum', 300)", "id": "e1", "label": "LOC Generated"}]
                  ],
                  "region": "${AWS::Region}", "title": "Lines of Code Generated", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 16, "y": 1, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"TokensUsed\"', 'Sum', 300)", "id": "e1", "label": "Total Tokens"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"InputTokens\"', 'Sum', 300)", "id": "e2", "label": "Input Tokens"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"OutputTokens\"', 'Sum', 300)", "id": "e3", "label": "Output Tokens"}]
                  ],
                  "region": "${AWS::Region}", "title": "Token Usage", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 0, "y": 7, "width": 24, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"DeploymentAttempts\"', 'Sum', 300)", "id": "e1", "visible": false}],
                    [{"expression": "AVG(METRICS(\"e1\"))", "id": "e2", "label": "Avg Deployments per Task"}]
                  ],
                  "region": "${AWS::Region}", "title": "Avg Deployment Attempts per Task", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 0, "y": 13, "width": 12, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId,UserId} MetricName=\"ActiveUser\"', 'Sum', 3600)", "id": "e1"}]
                  ],
                  "region": "${AWS::Region}", "title": "Active Users", "view": "timeSeries", "period": 3600
                }
              },
              {
                "type": "text", "x": 0, "y": 20, "width": 24, "height": 1,
                "properties": { "markdown": "### Git Activity" }
              },
              {
                "type": "metric", "x": 0, "y": 21, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitCommits\"', 'Sum', 300)", "id": "e1", "label": "Commits"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitPushes\"', 'Sum', 300)", "id": "e2", "label": "Pushes"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitPushFailures\"', 'Sum', 300)", "id": "e3", "label": "Push Failures"}]
                  ],
                  "region": "${AWS::Region}", "title": "Git Commits & Pushes", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 8, "y": 21, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitLinesAdded\"', 'Sum', 300)", "id": "e1", "label": "Lines Added"}],
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitLinesDeleted\"', 'Sum', 300)", "id": "e2", "label": "Lines Deleted"}]
                  ],
                  "region": "${AWS::Region}", "title": "Git Lines Added / Deleted", "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 16, "y": 21, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{KANU/Orchestrator,OrgId,TaskId} MetricName=\"GitFilesChanged\"', 'Sum', 300)", "id": "e1", "label": "Files Changed"}]
                  ],
                  "region": "${AWS::Region}", "title": "Git Files Changed", "view": "timeSeries"
                }
              },
              {
                "type": "text", "x": 0, "y": 27, "width": 24, "height": 1,
                "properties": { "markdown": "## ECS Web App Health" }
              },
              {
                "type": "metric", "x": 0, "y": 28, "width": 12, "height": 6,
                "properties": {
                  "metrics": [["AWS/ECS", "CPUUtilization", "ClusterName", "${AWS::StackName}-cluster", "ServiceName", "${AWS::StackName}-webapp-service"]],
                  "region": "${AWS::Region}", "title": "Web App CPU Utilization", "stat": "Average", "period": 300, "view": "timeSeries",
                  "annotations": {
                    "horizontal": [
                      { "label": "85% Threshold", "value": 85, "color": "#d13212", "fill": "above" }
                    ]
                  }
                }
              },
              {
                "type": "metric", "x": 12, "y": 28, "width": 12, "height": 6,
                "properties": {
                  "metrics": [["AWS/ECS", "MemoryUtilization", "ClusterName", "${AWS::StackName}-cluster", "ServiceName", "${AWS::StackName}-webapp-service"]],
                  "region": "${AWS::Region}", "title": "Web App Memory Utilization", "stat": "Average", "period": 300, "view": "timeSeries",
                  "annotations": {
                    "horizontal": [
                      { "label": "85% Threshold", "value": 85, "color": "#d13212", "fill": "above" }
                    ]
                  }
                }
              },
              {
                "type": "text", "x": 0, "y": 34, "width": 24, "height": 1,
                "properties": { "markdown": "## ECS Generation Cluster" }
              },
              {
                "type": "metric", "x": 0, "y": 35, "width": 12, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{ECS/ContainerInsights,ClusterName} ClusterName=\"${AWS::StackName}-generation\" MetricName=\"CpuUtilized\"', 'Average', 300)", "id": "e1", "label": "Generation CPU"}]
                  ],
                  "region": "${AWS::Region}", "title": "Generation CPU Utilization", "view": "timeSeries",
                  "annotations": {
                    "horizontal": [
                      { "label": "85% (3482 units)", "value": 3481.6, "color": "#d13212" },
                      { "label": "100% Cap (4096 units)", "value": 4096, "color": "#ff0000", "fill": "above" }
                    ]
                  }
                }
              },
              {
                "type": "metric", "x": 12, "y": 35, "width": 12, "height": 6,
                "properties": {
                  "metrics": [
                    [{"expression": "SEARCH('{ECS/ContainerInsights,ClusterName} ClusterName=\"${AWS::StackName}-generation\" MetricName=\"MemoryUtilized\"', 'Average', 300)", "id": "e1", "label": "Generation Memory"}]
                  ],
                  "region": "${AWS::Region}", "title": "Generation Memory Utilization", "view": "timeSeries",
                  "annotations": {
                    "horizontal": [
                      { "label": "85% (6963 MB)", "value": 6963.2, "color": "#d13212" },
                      { "label": "100% Cap (8192 MB)", "value": 8192, "color": "#ff0000", "fill": "above" }
                    ]
                  }
                }
              },
              {
                "type": "text", "x": 0, "y": 41, "width": 24, "height": 1,
                "properties": { "markdown": "## ALB / HTTP Traffic" }
              },
              {
                "type": "metric", "x": 0, "y": 42, "width": 8, "height": 6,
                "properties": {
                  "metrics": [["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "${ALBFullName}"]],
                  "region": "${AWS::Region}", "title": "Request Count", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 8, "y": 42, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["AWS/ApplicationELB", "HTTPCode_Target_4XX_Count", "LoadBalancer", "${ALBFullName}"],
                    ["AWS/ApplicationELB", "HTTPCode_Target_5XX_Count", "LoadBalancer", "${ALBFullName}"]
                  ],
                  "region": "${AWS::Region}", "title": "HTTP Error Rates (4XX / 5XX)", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 16, "y": 42, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["AWS/ApplicationELB", "TargetResponseTime", "TargetGroup", "${TGFullName}", "LoadBalancer", "${ALBFullName}", { "stat": "Average" }],
                    ["AWS/ApplicationELB", "HealthyHostCount", "TargetGroup", "${TGFullName}", "LoadBalancer", "${ALBFullName}", { "stat": "Average" }],
                    ["AWS/ApplicationELB", "UnHealthyHostCount", "TargetGroup", "${TGFullName}", "LoadBalancer", "${ALBFullName}", { "stat": "Average" }]
                  ],
                  "region": "${AWS::Region}", "title": "Response Time & Host Health", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "text", "x": 0, "y": 48, "width": 24, "height": 1,
                "properties": { "markdown": "## SSE Streaming Health" }
              },
              {
                "type": "metric", "x": 0, "y": 49, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["KANU/SSE", "ConnectAttempt"],
                    ["KANU/SSE", "ConnectError"]
                  ],
                  "region": "${AWS::Region}", "title": "SSE Connect Attempts & Errors", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 8, "y": 49, "width": 8, "height": 6,
                "properties": {
                  "metrics": [["KANU/SSE", "ConnectionDurationMs"]],
                  "region": "${AWS::Region}", "title": "SSE Connection Duration (ms)", "stat": "Average", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 16, "y": 49, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["KANU/SSE", "EventsForwarded"],
                    ["KANU/SSE", "Disconnect"]
                  ],
                  "region": "${AWS::Region}", "title": "SSE Events Forwarded & Disconnects", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "text", "x": 0, "y": 55, "width": 24, "height": 1,
                "properties": { "markdown": "## Orchestrator Logs" }
              },
              {
                "type": "text", "x": 0, "y": 56, "width": 24, "height": 6,
                "properties": {
                  "markdown": "### Orchestrator Logs\n[View Deployment Logs](https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/$252Faws$252Fkanu$252F${AWS::StackName}$252Fdeployments)"
                }
              },
              {
                "type": "text", "x": 0, "y": 62, "width": 24, "height": 1,
                "properties": { "markdown": "## Rate Limiting" }
              },
              {
                "type": "metric", "x": 0, "y": 63, "width": 12, "height": 6,
                "properties": {
                  "metrics": [
                    ["KANU/RateLimiting", "ratelimit.signup_initiate.requests"],
                    ["KANU/RateLimiting", "ratelimit.signup_initiate.limited"]
                  ],
                  "region": "${AWS::Region}", "title": "Signup Rate Limiting", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 12, "y": 63, "width": 12, "height": 6,
                "properties": {
                  "metrics": [
                    ["KANU/RateLimiting", "ratelimit.verification_code.requests"],
                    ["KANU/RateLimiting", "ratelimit.verification_code.limited"]
                  ],
                  "region": "${AWS::Region}", "title": "Verification Code Rate Limiting", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "text", "x": 0, "y": 69, "width": 24, "height": 1,
                "properties": { "markdown": "## DynamoDB Tables" }
              },
              {
                "type": "metric", "x": 0, "y": 70, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["AWS/DynamoDB", "ConsumedReadCapacityUnits", "TableName", "kanu-tasks-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedReadCapacityUnits", "TableName", "kanu-task-activity-logs-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedReadCapacityUnits", "TableName", "kanu-chat-messages-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedReadCapacityUnits", "TableName", "kanu-usage-metrics-${AWS::StackName}"]
                  ],
                  "region": "${AWS::Region}", "title": "DynamoDB Read Capacity", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 8, "y": 70, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["AWS/DynamoDB", "ConsumedWriteCapacityUnits", "TableName", "kanu-tasks-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedWriteCapacityUnits", "TableName", "kanu-task-activity-logs-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedWriteCapacityUnits", "TableName", "kanu-chat-messages-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ConsumedWriteCapacityUnits", "TableName", "kanu-usage-metrics-${AWS::StackName}"]
                  ],
                  "region": "${AWS::Region}", "title": "DynamoDB Write Capacity", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "metric", "x": 16, "y": 70, "width": 8, "height": 6,
                "properties": {
                  "metrics": [
                    ["AWS/DynamoDB", "ThrottledRequests", "TableName", "kanu-tasks-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ThrottledRequests", "TableName", "kanu-task-activity-logs-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ThrottledRequests", "TableName", "kanu-chat-messages-${AWS::StackName}"],
                    ["AWS/DynamoDB", "ThrottledRequests", "TableName", "kanu-usage-metrics-${AWS::StackName}"]
                  ],
                  "region": "${AWS::Region}", "title": "DynamoDB Throttled Requests", "stat": "Sum", "period": 300, "view": "timeSeries"
                }
              },
              {
                "type": "text", "x": 0, "y": 76, "width": 24, "height": 3,
                "properties": {
                  "markdown": "## CloudWatch Log Insights\n- [Web App Logs](https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/$252Fecs$252F${AWS::StackName})\n- [Generation Logs](https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/$252Fecs$252F${AWS::StackName}-generation)\n- [Deployment Logs](https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/$252Faws$252Fkanu$252F${AWS::StackName}$252Fdeployments)\n- [**Unified Task Logs**](https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#logsV2:log-groups/log-group/$252Faws$252Fkanu$252F${AWS::StackName}$252Ftask-logs) ← Per-task + per-agent consolidated logs"
                }
              }
            ]
          }
        - ALBFullName: !GetAtt ApplicationLoadBalancer.LoadBalancerFullName
          TGFullName: !GetAtt TargetGroup.TargetGroupFullName

Outputs:
  ApplicationURL:
    Description: Application URL (custom domain if configured, CloudFront domain otherwise)
    Value: !Sub
      - "https://${Domain}"
      - Domain:
          !If [
            HasCustomDomain,
            !Ref CustomDomainName,
            !GetAtt CloudFrontDistribution.DomainName,
          ]
    Export:
      Name: !Sub "${AWS::StackName}-AppURL"

  LoadBalancerDNS:
    Description: Load Balancer DNS Name
    Value: !GetAtt ApplicationLoadBalancer.DNSName
    Export:
      Name: !Sub "${AWS::StackName}-ALBDNS"

  WebAppECSClusterName:
    Description: ECS Cluster Name
    Value: !Ref WebAppECSCluster
    Export:
      Name: !Sub "${AWS::StackName}-ClusterName"

  WebAppECSServiceName:
    Description: ECS Service Name
    Value: !GetAtt WebAppECSService.Name
    Export:
      Name: !Sub "${AWS::StackName}-ServiceName"

  # Generation Infrastructure Outputs
  GenerationClusterName:
    Description: ECS Cluster for AI generation tasks
    Value: !Ref GenerationCluster
    Export:
      Name: !Sub "${AWS::StackName}-GenerationCluster"

  GenerationTaskDefArn:
    Description: Task definition ARN for Python orchestrator
    Value: !Ref GenerationTaskDefinition
    Export:
      Name: !Sub "${AWS::StackName}-GenerationTaskDef"

  # ABAC Role ARNs
  SelfHostedPowerUserRoleArn:
    Description: IAM role ARN for SelfHosted users (PowerUser permissions for infrastructure deployment)
    Value: !GetAtt SelfHostedPowerUserRole.Arn
    Export:
      Name: !Sub "${AWS::StackName}-SelfHostedPowerUserRoleArn"

  SelfHostedReadOnlyRoleArn:
    Description: IAM role ARN for SelfHosted users (ReadOnly permissions for infrastructure scanning)
    Value: !GetAtt SelfHostedReadOnlyRole.Arn
    Export:
      Name: !Sub "${AWS::StackName}-SelfHostedReadOnlyRoleArn"

  OrgScopedRoleArn:
    Description: IAM role ARN for multi-tenant users (ABAC-restricted)
    Value: !GetAtt OrgScopedRole.Arn
    Export:
      Name: !Sub "${AWS::StackName}-OrgScopedRoleArn"

  CodeBucketName:
    Description: S3 bucket for backend code storage
    Value: !Ref CodeStorageBucket
    Export:
      Name: !Sub "${AWS::StackName}-CodeBucket"

  FrontendBucketName:
    Description: S3 bucket for frontend assets
    Value: !Ref FrontendAssetsBucket
    Export:
      Name: !Sub "${AWS::StackName}-FrontendBucket"

  TasksTableName:
    Description: DynamoDB table for task-based execution
    Value: !Ref TasksTable
    Export:
      Name: !Sub "${AWS::StackName}-TasksTable"

  GeneratorStatusTopicArn:
    Description: SNS topic for generation status updates
    Value: !Ref GeneratorStatusTopic
    Export:
      Name: !Sub "${AWS::StackName}-StatusTopic"

  CloudFrontDomainName:
    Description: CloudFront domain for the application
    Value: !GetAtt CloudFrontDistribution.DomainName
    Export:
      Name: !Sub "${AWS::StackName}-CloudFrontDomain"

  # Cognito Outputs
  CognitoUserPoolId:
    Description: Cognito User Pool ID for authentication
    Value: !Ref CognitoUserPool
    Export:
      Name: !Sub "${AWS::StackName}-CognitoUserPoolId"

  CognitoUserPoolClientId:
    Description: Cognito User Pool Client ID
    Value: !Ref CognitoUserPoolClient
    Export:
      Name: !Sub "${AWS::StackName}-CognitoClientId"

  # DynamoDB Table Outputs
  OrganizationsTableName:
    Description: DynamoDB table for organizations
    Value: !Ref OrganizationsTable
    Export:
      Name: !Sub "${AWS::StackName}-OrganizationsTable"

  SelfServiceUsersTableName:
    Description: DynamoDB table for users
    Value: !Ref SelfServiceUsersTable
    Export:
      Name: !Sub "${AWS::StackName}-UsersTable"

  AwsAccountsTableName:
    Description: DynamoDB table for customer AWS accounts
    Value: !Ref AwsAccountsTable
    Export:
      Name: !Sub "${AWS::StackName}-AwsAccountsTable"

  AwsAccountRolesTableName:
    Description: DynamoDB table for AWS account roles
    Value: !Ref AwsAccountRolesTable
    Export:
      Name: !Sub "${AWS::StackName}-AwsAccountRolesTable"

  UserPermissionsTableName:
    Description: DynamoDB table for user permissions
    Value: !Ref UserPermissionsTable
    Export:
      Name: !Sub "${AWS::StackName}-UserPermissionsTable"

  TaskDecisionsTableName:
    Description: DynamoDB table for task decisions
    Value: !Ref TaskDecisionsTable
    Export:
      Name: !Sub "${AWS::StackName}-TaskDecisionsTable"

  TaskActivityLogsTableName:
    Description: DynamoDB table for task activity logs
    Value: !Ref TaskActivityLogsTable
    Export:
      Name: !Sub "${AWS::StackName}-TaskActivityLogsTable"

  ChatMessagesTableName:
    Description: DynamoDB table for chat messages
    Value: !Ref ChatMessagesTable
    Export:
      Name: !Sub "${AWS::StackName}-ChatMessagesTable"

  UserRepoConfigsTableName:
    Description: DynamoDB table for user repository configurations
    Value: !Ref UserRepoConfigsTable
    Export:
      Name: !Sub "${AWS::StackName}-UserRepoConfigsTable"

  UsageMetricsTableName:
    Description: DynamoDB table for usage metrics
    Value: !Ref UsageMetricsTable
    Export:
      Name: !Sub "${AWS::StackName}-UsageMetricsTable"

  InfraScannerCacheTableName:
    Description: Infrastructure scan cache table name
    Value: !Ref InfraScannerCacheTable
    Export:
      Name: !Sub "${AWS::StackName}-InfraScannerCacheTableName"
  DeploymentLocksTableName:
    Description: DynamoDB table for deployment coordination locks
    Value: !Ref DeploymentLocksTable
    Export:
      Name: !Sub "${AWS::StackName}-DeploymentLocksTable"

  # Billing Tables (SelfServiceAndSelfHosted mode only)
  BillingTableName:
    Condition: IncludeMultiTenantGroups
    Description: DynamoDB table for billing settings
    Value: !Ref BillingTable
    Export:
      Name: !Sub "${AWS::StackName}-BillingTable"

  MonthlyUsageTableName:
    Condition: IncludeMultiTenantGroups
    Description: DynamoDB table for monthly usage aggregation
    Value: !Ref MonthlyUsageTable
    Export:
      Name: !Sub "${AWS::StackName}-MonthlyUsageTable"

  TaskUsageTableName:
    Condition: IncludeMultiTenantGroups
    Description: DynamoDB table for per-task usage
    Value: !Ref TaskUsageTable
    Export:
      Name: !Sub "${AWS::StackName}-TaskUsageTable"

  PromoCodesTableName:
    Condition: IncludeMultiTenantGroups
    Description: DynamoDB table for promotional codes
    Value: !Ref PromoCodesTable
    Export:
      Name: !Sub "${AWS::StackName}-PromoCodesTable"

  PromoRedemptionsTableName:
    Condition: IncludeMultiTenantGroups
    Description: DynamoDB table for promo code redemptions
    Value: !Ref PromoRedemptionsTable
    Export:
      Name: !Sub "${AWS::StackName}-PromoRedemptionsTable"

  GenerationSecurityGroupId:
    Description: Security group ID for generation tasks
    Value: !Ref GenerationSecurityGroup
    Export:
      Name: !Sub "${AWS::StackName}-GenerationSecurityGroup"

  GenerationTaskDefinitionFamily:
    Description: Task definition family name for Python orchestrator
    Value:
      !Select [
        1,
        !Split ["/", !Select [5, !Split [":", !Ref GenerationTaskDefinition]]],
      ]
    Export:
      Name: !Sub "${AWS::StackName}-GenerationTaskDefFamily"

  SubnetIds:
    Description: Comma-separated subnet IDs for generation tasks
    Value: !If
      - UseExistingSubnets
      - !If [
          UsePrivateSubnetsCondition,
          !Join [",", !Ref PrivateSubnetIds],
          !Join [",", !Ref PublicSubnetIds],
        ]
      - !If [
          UsePrivateSubnetsCondition,
          !Join [",", [!Ref PrivateSubnet1, !Ref PrivateSubnet2]],
          !Join [",", [!Ref PublicSubnet1, !Ref PublicSubnet2]],
        ]
    Export:
      Name: !Sub "${AWS::StackName}-SubnetIds"

  SecretsEncryptionKeyArn:
    Description: KMS key ARN for encrypting ExternalId secrets
    Value: !GetAtt SecretsEncryptionKey.Arn
    Export:
      Name: !Sub "${AWS::StackName}-SecretsEncryptionKeyArn"

  BedrockGuardrailId:
    Description: Bedrock Guardrail ID for content safety validation
    Value: !GetAtt KanuBedrockGuardrail.GuardrailId
    Export:
      Name: !Sub "${AWS::StackName}-BedrockGuardrailId"

  BedrockGuardrailArn:
    Description: Bedrock Guardrail ARN
    Value: !GetAtt KanuBedrockGuardrail.GuardrailArn
    Export:
      Name: !Sub "${AWS::StackName}-BedrockGuardrailArn"

  BedrockGuardrailVersion:
    Description: Bedrock Guardrail version number (production)
    Value: !GetAtt KanuBedrockGuardrailVersion.Version
    Export:
      Name: !Sub "${AWS::StackName}-BedrockGuardrailVersion"

  DashboardName:
    Description: CloudWatch Dashboard for KANU platform monitoring
    Value: !Ref KanuDashboard
    Export:
      Name: !Sub "${AWS::StackName}-DashboardName"
