AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  Lacuna Bulk Signer on Amazon ECS Fargate - one task behind an Application Load Balancer, the
  operational store in RDS for SQL Server, the work share and the signing certificate in one S3
  bucket, the signing key in AWS KMS, and optionally Lacuna Signer and Microsoft Entra ID.
  Sample from docs/samples in the Lacuna Bulk Signer repository.

# What this creates is what the "Amazon ECS Fargate + Lacuna Signer" page builds by hand in its
# steps 2 (bucket), 5 (security groups), 6.1-6.2 (database and its login secret), 7 (secrets and
# roles), 8.2 (load balancer) and 9 (log group, cluster, database creation, task definition and
# service), plus the ACM certificate of step 8.1 when no certificate ARN is given.
#
# Made before the stack, outside it:
#   * the KMS key, by Import-PfxToKms.ps1 with -SignerPrincipalArn arn:aws:iam::<account>:role/<Prefix>-task
#     and without -S3Bucket: the stack uploads the .cer itself, after checking it against the key;
#   * the container image, copied into ECR;
#   * the secrets whose values a person types: the PKI SDK licence, the Lacuna Signer API key and,
#     optionally, the Entra ID client secret. Their ARNs are parameters; their values never are.
# Made after it: the DNS record (see the DnsRecord output), the signing profile and the webhook.
#
# Kept when the stack is deleted, on purpose: the bucket with the work share (DeletionPolicy
# RetainExceptOnCreate, so a first create that rolls back leaves nothing behind) and a final
# snapshot of the database (DeletionPolicy Snapshot). Everything else is deleted.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: { default: Deployment }
        Parameters: [Prefix, HostName, ImageUri, AppEnvironment, TrustLacunaTestRoot]
      - Label: { default: HTTPS }
        Parameters: [CertificateArn, Route53HostedZoneId]
      - Label: { default: Signing key (made by Import-PfxToKms.ps1) }
        Parameters: [KmsKeyAlias, SigningCertificate]
      - Label: { default: Secrets entered by hand (ARNs only) }
        Parameters: [PkiSdkLicenseSecretArn, SignerApiKeySecretArn]
      - Label: { default: Lacuna Signer (optional) }
        Parameters: [SignerEndpoint]
      - Label: { default: Microsoft Entra ID sign-in (optional) }
        Parameters: [EntraTenantId, EntraClientId, EntraClientSecretArn]
      - Label: { default: Database }
        Parameters: [DatabaseEngine, DatabaseEngineVersion, DatabaseInstanceClass]

Parameters:
  Prefix:
    Type: String
    Default: bulksigner
    AllowedPattern: "^[a-z][a-z0-9-]{0,19}[a-z0-9]$"
    ConstraintDescription: 2 to 21 lowercase letters, digits or hyphens, starting with a letter.
    Description: >-
      Prefix of every resource name. The task role is named <Prefix>-task, which is the role
      Import-PfxToKms.ps1 was given as -SignerPrincipalArn; the bucket is <Prefix>-<account id>.
  HostName:
    Type: String
    AllowedPattern: "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
    Description: Public hostname of the deployment, e.g. bulksigner.example.com. Lacuna Signer calls it.
  ImageUri:
    Type: String
    AllowedPattern: "^\\d{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com/.+:.+$"
    Description: The Bulk Signer image in this account's ECR, with an explicit version tag (2.20.0 or later).
  AppEnvironment:
    Type: String
    Default: Production
    AllowedValues: [Production, Staging]
    Description: ASPNETCORE_ENVIRONMENT. Staging only for a homologation that signs with Lacuna test certificates.
  TrustLacunaTestRoot:
    Type: String
    Default: "false"
    AllowedValues: ["false", "true"]
    Description: Signing:TrustLacunaTestRoot. Refused by Bulk Signer under Production.
  CertificateArn:
    Type: String
    Default: ""
    AllowedPattern: "^(arn:aws[a-z-]*:acm:[a-z0-9-]+:\\d{12}:certificate/.+)?$"
    Description: >-
      An issued ACM certificate for HostName, in this Region. Empty to have the stack request one
      with DNS validation: stack creation then waits until the validation record exists.
  Route53HostedZoneId:
    Type: String
    Default: ""
    Description: >-
      Only when the stack requests the certificate and the zone that answers for HostName is a
      Route 53 hosted zone in this account: the stack then creates the validation record itself.
  KmsKeyAlias:
    Type: String
    Default: alias/bulksigner
    AllowedPattern: "^alias/[a-zA-Z0-9/_-]+$"
    Description: The alias Import-PfxToKms.ps1 created, with the alias/ prefix.
  SigningCertificate:
    Type: String
    MinLength: 100
    MaxLength: 4096
    Description: >-
      The .cer Import-PfxToKms.ps1 wrote, base64-encoded on one line. The stack refuses it unless
      its public key is the KMS key's, and uploads it to signing-material/<Prefix>.cer.
  PkiSdkLicenseSecretArn:
    Type: String
    AllowedPattern: "^arn:aws[a-z-]*:secretsmanager:[a-z0-9-]+:\\d{12}:secret:.+$"
    Description: ARN of the secret holding the PKI SDK licence, as plaintext.
  SignerEndpoint:
    Type: String
    Default: ""
    AllowedPattern: "^(https://[^/\\s]+(/[^\\s]*[^/\\s])?)?$"
    Description: >-
      Address of the Lacuna Signer API, without a trailing slash. Empty for a deployment that
      signs files only: no Signer settings and no webhook secret.
  SignerApiKeySecretArn:
    Type: String
    Default: ""
    AllowedPattern: "^(arn:aws[a-z-]*:secretsmanager:[a-z0-9-]+:\\d{12}:secret:.+)?$"
    Description: ARN of the secret holding the Lacuna Signer API key (<application>|<secret>). Required with SignerEndpoint.
  EntraTenantId:
    Type: String
    Default: ""
    AllowedPattern: "^([0-9a-fA-F-]{36})?$"
    Description: Optional. Directory (tenant) ID, from New-BulkSignerEntraApp.ps1.
  EntraClientId:
    Type: String
    Default: ""
    AllowedPattern: "^([0-9a-fA-F-]{36})?$"
    Description: Application (client) ID. Required with EntraTenantId.
  EntraClientSecretArn:
    Type: String
    Default: ""
    AllowedPattern: "^(arn:aws[a-z-]*:secretsmanager:[a-z0-9-]+:\\d{12}:secret:.+)?$"
    Description: ARN of the secret holding the Entra client secret. Required with EntraTenantId.
  DatabaseEngine:
    Type: String
    Default: sqlserver-ex
    AllowedValues: [sqlserver-ex, sqlserver-web, sqlserver-se]
    Description: Express is enough for a homologation; production needs Web or Standard.
  DatabaseEngineVersion:
    Type: String
    Default: "16.00"
    Description: 16.00 is SQL Server 2022; RDS picks its current minor version.
  DatabaseInstanceClass:
    Type: String
    Default: db.t3.small
    Description: RDS instance class.

Rules:
  SignerNeedsApiKey:
    RuleCondition: !Not [!Equals [!Ref SignerEndpoint, ""]]
    Assertions:
      - Assert: !Not [!Equals [!Ref SignerApiKeySecretArn, ""]]
        AssertDescription: SignerEndpoint requires SignerApiKeySecretArn.
  EntraNeedsClient:
    RuleCondition: !Not [!Equals [!Ref EntraTenantId, ""]]
    Assertions:
      - Assert: !And
          - !Not [!Equals [!Ref EntraClientId, ""]]
          - !Not [!Equals [!Ref EntraClientSecretArn, ""]]
        AssertDescription: EntraTenantId requires EntraClientId and EntraClientSecretArn.
  TestRootNotInProduction:
    RuleCondition: !Equals [!Ref TrustLacunaTestRoot, "true"]
    Assertions:
      - Assert: !Equals [!Ref AppEnvironment, Staging]
        AssertDescription: TrustLacunaTestRoot is refused by Bulk Signer under Production; use Staging.

Conditions:
  RequestCertificate: !Equals [!Ref CertificateArn, ""]
  ValidateInRoute53: !And
    - !Condition RequestCertificate
    - !Not [!Equals [!Ref Route53HostedZoneId, ""]]
  # The Rules above make each pair all-or-nothing; naming both halves here also tells cfn-lint so.
  UseSigner: !And
    - !Not [!Equals [!Ref SignerEndpoint, ""]]
    - !Not [!Equals [!Ref SignerApiKeySecretArn, ""]]
  UseEntra: !And
    - !Not [!Equals [!Ref EntraTenantId, ""]]
    - !Not [!Equals [!Ref EntraClientSecretArn, ""]]
  UseTestRoot: !Equals [!Ref TrustLacunaTestRoot, "true"]

Resources:
  # ---------------------------------------------------------------------------------------------
  # Provisioner: one Lambda function behind the four custom resources that CloudFormation has no
  # resource type for - finding the default VPC, the watched-folder marker, checking and uploading
  # the certificate, and creating the database. It is not in the VPC; it only calls AWS APIs.
  # ---------------------------------------------------------------------------------------------
  ProvisionerLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${Prefix}-provisioner
      RetentionInDays: 30

  ProvisionerRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: { Service: lambda.amazonaws.com }
            Action: sts:AssumeRole
      Policies:
        - PolicyName: provisioner
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt ProvisionerLogGroup.Arn
              - Effect: Allow
                Action: [ec2:DescribeVpcs, ec2:DescribeSubnets]
                Resource: "*"
              - Effect: Allow
                Action: [kms:DescribeKey, kms:GetPublicKey]
                Resource: "*"
                Condition:
                  StringEquals: { "kms:RequestAlias": !Ref KmsKeyAlias }
              - Effect: Allow
                Action: [s3:PutObject, s3:DeleteObject]
                Resource:
                  - !Sub arn:${AWS::Partition}:s3:::${Prefix}-${AWS::AccountId}/inputs/default/
                  - !Sub arn:${AWS::Partition}:s3:::${Prefix}-${AWS::AccountId}/signing-material/${Prefix}.cer
              - Effect: Allow
                Action: [secretsmanager:GetSecretValue, secretsmanager:PutSecretValue]
                Resource: !Ref SqlAppSecret
              - Effect: Allow
                Action: ecs:RunTask
                Resource: !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/${Prefix}-dbsetup:*
              - Effect: Allow
                Action: ecs:DescribeTasks
                Resource: !Sub arn:${AWS::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:task/${Prefix}/*
              - Effect: Allow
                Action: iam:PassRole
                Resource: !GetAtt ExecutionRole.Arn

  Provisioner:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${Prefix}-provisioner
      Description: Bulk Signer stack custom resources (default VPC, S3 markers, certificate check, database creation)
      Runtime: python3.13
      Handler: index.handler
      Timeout: 900
      MemorySize: 256
      Role: !GetAtt ProvisionerRole.Arn
      LoggingConfig:
        LogGroup: !Ref ProvisionerLogGroup
      Code:
        ZipFile: |
          import base64, json, time, urllib.request
          import boto3

          def send(event, status, reason="", data=None, physical=None):
              body = json.dumps({
                  "Status": status, "Reason": reason[:3000],
                  "PhysicalResourceId": physical or event.get("PhysicalResourceId") or event["LogicalResourceId"],
                  "StackId": event["StackId"], "RequestId": event["RequestId"],
                  "LogicalResourceId": event["LogicalResourceId"], "NoEcho": False, "Data": data or {},
              }).encode()
              req = urllib.request.Request(event["ResponseURL"], data=body, method="PUT",
                                           headers={"Content-Type": "", "Content-Length": str(len(body))})
              urllib.request.urlopen(req, timeout=30)

          def default_vpc(p, event, ctx):
              ec2 = boto3.client("ec2")
              vpcs = ec2.describe_vpcs(Filters=[{"Name": "is-default", "Values": ["true"]}])["Vpcs"]
              if not vpcs:
                  raise Exception("This Region has no default VPC.")
              vpc = vpcs[0]
              subnets = ec2.describe_subnets(Filters=[{"Name": "vpc-id", "Values": [vpc["VpcId"]]},
                                                      {"Name": "default-for-az", "Values": ["true"]}])["Subnets"]
              if len(subnets) < 2:
                  raise Exception("The default VPC needs default subnets in at least two Availability Zones.")
              ids = sorted(s["SubnetId"] for s in subnets)
              return vpc["VpcId"], {"VpcId": vpc["VpcId"], "CidrBlock": vpc["CidrBlock"], "SubnetIds": ",".join(ids)}

          def folder_marker(p, event, ctx):
              boto3.client("s3").put_object(Bucket=p["Bucket"], Key=p["Key"], Body=b"")
              return "s3://%s/%s" % (p["Bucket"], p["Key"]), {}

          def der_length(b, i):
              n = b[i]
              if n < 0x80:
                  return n, i + 1
              k = n & 0x7F
              return int.from_bytes(b[i + 1:i + 1 + k], "big"), i + 1 + k

          def signing_material(p, event, ctx):
              text = "".join(l for l in p["Certificate"].split() if not l.startswith("-----") and "CERTIFICATE" not in l)
              cert = base64.b64decode(text, validate=True)
              if cert[:1] != b"\x30":
                  raise Exception("SigningCertificate is not a base64-encoded DER certificate.")
              n, start = der_length(cert, 1)
              cert = cert[:start + n]
              kms = boto3.client("kms")
              meta = kms.describe_key(KeyId=p["KeyAlias"])["KeyMetadata"]
              if meta["KeyState"] != "Enabled" or meta["KeyUsage"] != "SIGN_VERIFY" or not meta["KeySpec"].startswith("RSA_"):
                  raise Exception("%s is %s, %s, %s; Bulk Signer needs an Enabled RSA SIGN_VERIFY key."
                                  % (p["KeyAlias"], meta["KeyState"], meta["KeyUsage"], meta["KeySpec"]))
              spki = kms.get_public_key(KeyId=p["KeyAlias"])["PublicKey"]
              if spki not in cert:
                  raise Exception("The public key of SigningCertificate is not the public key of %s. "
                                  "Pass the .cer Import-PfxToKms.ps1 wrote for this key." % p["KeyAlias"])
              boto3.client("s3").put_object(Bucket=p["Bucket"], Key=p["Key"], Body=cert,
                                            ContentType="application/pkix-cert")
              return "s3://%s/%s" % (p["Bucket"], p["Key"]), {"KeyArn": meta["Arn"], "KeySpec": meta["KeySpec"]}

          SCRIPT_NOTE = "aws logs tail /ecs/%s --log-stream-name-prefix dbsetup"

          def database(p, event, ctx):
              sm = boto3.client("secretsmanager")
              value = json.loads(sm.get_secret_value(SecretId=p["SecretArn"])["SecretString"])
              cs = ("Server=tcp:%s,1433;Initial Catalog=BulkSigner;User ID=%s;Password=%s;"
                    "Encrypt=True;TrustServerCertificate=True;") % (p["DbHost"], value["username"], value["password"])
              if value.get("connectionString") != cs:
                  value["connectionString"] = cs
                  sm.put_secret_value(SecretId=p["SecretArn"], SecretString=json.dumps(value))
              old = event.get("OldResourceProperties") or {}
              if event["RequestType"] == "Update" and old.get("DbHost") == p["DbHost"]:
                  return event["PhysicalResourceId"], {}
              ecs = boto3.client("ecs")
              for attempt in (1, 2, 3):
                  run = ecs.run_task(cluster=p["Cluster"], taskDefinition=p["TaskDefinition"], launchType="FARGATE",
                                     networkConfiguration={"awsvpcConfiguration": {
                                         "subnets": p["Subnets"].split(","), "securityGroups": [p["SecurityGroup"]],
                                         "assignPublicIp": "ENABLED"}})
                  if run.get("failures") or not run.get("tasks"):
                      raise Exception("RunTask failed: %s" % run.get("failures"))
                  arn = run["tasks"][0]["taskArn"]
                  task_id = arn.split("/")[-1]
                  while True:
                      if ctx.get_remaining_time_in_millis() < 60000:
                          raise Exception("The database setup task %s did not stop in time." % task_id)
                      time.sleep(10)
                      task = ecs.describe_tasks(cluster=p["Cluster"], tasks=[arn])["tasks"][0]
                      if task["lastStatus"] == "STOPPED":
                          break
                  code = task["containers"][0].get("exitCode")
                  if code == 0:
                      return p["DbHost"] + "/BulkSigner", {}
                  reason = task.get("stoppedReason", "")
                  # A task that never started (exit code empty) because a just-created role policy
                  # had not propagated yet is worth another try; a failed sqlcmd is not.
                  if code is None and "ResourceInitializationError" in reason and attempt < 3:
                      print("Attempt %d did not start (%s); retrying." % (attempt, reason))
                      time.sleep(30)
                      continue
                  raise Exception("The database setup task %s stopped with exit code %s (%s). Log: %s"
                                  % (task_id, code, reason, SCRIPT_NOTE % p["Prefix"]))

          HANDLERS = {"Custom::DefaultVpc": default_vpc, "Custom::FolderMarker": folder_marker,
                      "Custom::SigningMaterial": signing_material, "Custom::Database": database}

          def handler(event, ctx):
              print(json.dumps({k: event.get(k) for k in ("RequestType", "ResourceType", "LogicalResourceId")}))
              try:
                  p = event["ResourceProperties"]
                  if event["RequestType"] == "Delete":
                      # Remove the two objects this function wrote, so that a create that rolls back
                      # can delete the bucket; the retained bucket's work share is not touched. The
                      # database is snapshotted, and the VPC is not the stack's.
                      if event["ResourceType"] in ("Custom::FolderMarker", "Custom::SigningMaterial"):
                          try:
                              boto3.client("s3").delete_object(Bucket=p["Bucket"], Key=p["Key"])
                          except Exception as e:
                              print("Not removed: %r" % e)
                      send(event, "SUCCESS")
                      return
                  physical, data = HANDLERS[event["ResourceType"]](p, event, ctx)
                  send(event, "SUCCESS", data=data, physical=physical)
              except Exception as e:
                  print("FAILED: %r" % e)
                  send(event, "FAILED", reason=str(e) or repr(e))

  # ---------------------------------------------------------------------------------------------
  # Network: the default VPC and its default subnets (one per Availability Zone), as on the page.
  # ---------------------------------------------------------------------------------------------
  Network:
    Type: Custom::DefaultVpc
    Properties:
      ServiceToken: !GetAtt Provisioner.Arn

  AlbSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${Prefix}-alb
      GroupDescription: Public HTTPS into the load balancer
      VpcId: !GetAtt Network.VpcId
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 }
        - { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0 }

  TaskSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${Prefix}-task
      GroupDescription: Bulk Signer, reachable only from the load balancer
      VpcId: !GetAtt Network.VpcId
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSecurityGroup }

  DatabaseSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${Prefix}-db
      GroupDescription: RDS, reachable only from Bulk Signer
      VpcId: !GetAtt Network.VpcId
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 1433, ToPort: 1433, SourceSecurityGroupId: !Ref TaskSecurityGroup }

  # ---------------------------------------------------------------------------------------------
  # Bucket (page step 2): signing-material/, work/ and inputs/default/, which must not nest.
  # ---------------------------------------------------------------------------------------------
  Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: RetainExceptOnCreate
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub ${Prefix}-${AWS::AccountId}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        IgnorePublicAcls: true
        BlockPublicPolicy: true
        RestrictPublicBuckets: true
      LifecycleConfiguration:
        Rules:
          - Id: abort-incomplete-multipart
            Status: Enabled
            AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }

  DefaultInputFolder:
    Type: Custom::FolderMarker
    Properties:
      ServiceToken: !GetAtt Provisioner.Arn
      Bucket: !Ref Bucket
      Key: inputs/default/

  SigningMaterial:
    Type: Custom::SigningMaterial
    Properties:
      ServiceToken: !GetAtt Provisioner.Arn
      KeyAlias: !Ref KmsKeyAlias
      Certificate: !Ref SigningCertificate
      Bucket: !Ref Bucket
      Key: !Sub signing-material/${Prefix}.cer

  # ---------------------------------------------------------------------------------------------
  # Database (page step 6): RDS SQL Server with an RDS-managed master password, and the
  # application login's secret. The provisioner adds connectionString to that secret once the
  # endpoint is known, then creates the database and the login (page step 9.2).
  # ---------------------------------------------------------------------------------------------
  Database:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    UpdateReplacePolicy: Snapshot
    Properties:
      DBInstanceIdentifier: !Ref Prefix
      Engine: !Ref DatabaseEngine
      EngineVersion: !Ref DatabaseEngineVersion
      LicenseModel: license-included
      DBInstanceClass: !Ref DatabaseInstanceClass
      StorageType: gp3
      AllocatedStorage: "20"
      StorageEncrypted: true
      MasterUsername: bsadmin
      ManageMasterUserPassword: true
      VPCSecurityGroups: [!Ref DatabaseSecurityGroup]
      PubliclyAccessible: false
      BackupRetentionPeriod: 7

  SqlAppSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub ${Prefix}/sql-app
      Description: Bulk Signer application login; connectionString is added by the stack's provisioner
      GenerateSecretString:
        SecretStringTemplate: '{"username":"bulksigner"}'
        GenerateStringKey: password
        PasswordLength: 40
        ExcludePunctuation: true
        RequireEachIncludedType: true

  # ---------------------------------------------------------------------------------------------
  # Generated secrets (page step 7.1). Read them with get-secret-value; the template never sees them.
  # ---------------------------------------------------------------------------------------------
  AuthApiKeySecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub ${Prefix}/auth-api-key
      Description: Bulk Signer REST X-API-Key and dashboard sign-in key
      GenerateSecretString:
        PasswordLength: 48
        ExcludePunctuation: true

  SignerWebhookSecret:
    Type: AWS::SecretsManager::Secret
    Condition: UseSigner
    Properties:
      Name: !Sub ${Prefix}/signer-webhook-secret
      Description: Bearer token Lacuna Signer presents on POST /api/signer/notifications
      GenerateSecretString:
        PasswordLength: 48
        ExcludePunctuation: true
        RequireEachIncludedType: true

  # ---------------------------------------------------------------------------------------------
  # Roles (page step 7.2)
  # ---------------------------------------------------------------------------------------------
  ExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${Prefix}-exec
      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::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:*" }
      ManagedPolicyArns:
        - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  ExecutionRoleSecrets:
    Type: AWS::IAM::RolePolicy
    Properties:
      RoleName: !Ref ExecutionRole
      PolicyName: read-deployment-secrets
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action: secretsmanager:GetSecretValue
            Resource:
              - !Ref SqlAppSecret
              - !Ref AuthApiKeySecret
              - !Ref PkiSdkLicenseSecretArn
              - !If [UseSigner, !Ref SignerApiKeySecretArn, !Ref AWS::NoValue]
              - !If [UseSigner, !Ref SignerWebhookSecret, !Ref AWS::NoValue]
              - !If [UseEntra, !Ref EntraClientSecretArn, !Ref AWS::NoValue]
              # The RDS-managed master secret, for the database setup task only.
              - !GetAtt Database.MasterUserSecret.SecretArn

  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${Prefix}-task
      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::Partition}:ecs:${AWS::Region}:${AWS::AccountId}:*" }
      Policies:
        - PolicyName: bulksigner-app
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: [kms:DescribeKey, kms:GetPublicKey, kms:Sign]
                Resource: !GetAtt SigningMaterial.KeyArn
              - Effect: Allow
                Action: s3:ListBucket
                Resource: !GetAtt Bucket.Arn
              - Effect: Allow
                Action: [s3:GetObject, s3:PutObject, s3:DeleteObject, s3:AbortMultipartUpload]
                Resource: !Sub ${Bucket.Arn}/*

  # ---------------------------------------------------------------------------------------------
  # HTTPS (page step 8)
  # ---------------------------------------------------------------------------------------------
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Condition: RequestCertificate
    Properties:
      DomainName: !Ref HostName
      ValidationMethod: DNS
      DomainValidationOptions: !If
        - ValidateInRoute53
        - - DomainName: !Ref HostName
            HostedZoneId: !Ref Route53HostedZoneId
        - !Ref AWS::NoValue

  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Ref Prefix
      Type: application
      Scheme: internet-facing
      Subnets: !Split [",", !GetAtt Network.SubnetIds]
      SecurityGroups: [!Ref AlbSecurityGroup]
      LoadBalancerAttributes:
        - { Key: idle_timeout.timeout_seconds, Value: "300" }
        - { Key: routing.http.drop_invalid_header_fields.enabled, Value: "true" }

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Ref Prefix
      Protocol: HTTP
      Port: 8080
      VpcId: !GetAtt Network.VpcId
      TargetType: ip
      HealthCheckPath: /api/ready
      HealthCheckIntervalSeconds: 30
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 3
      Matcher: { HttpCode: "200" }
      TargetGroupAttributes:
        - { Key: deregistration_delay.timeout_seconds, Value: "30" }

  HttpsListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Protocol: HTTPS
      Port: 443
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates:
        - CertificateArn: !If [RequestCertificate, !Ref Certificate, !Ref CertificateArn]
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

  HttpListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Protocol: HTTP
      Port: 80
      DefaultActions:
        - Type: redirect
          RedirectConfig: { Protocol: HTTPS, Port: "443", StatusCode: HTTP_301 }

  # ---------------------------------------------------------------------------------------------
  # Service (page step 9)
  # ---------------------------------------------------------------------------------------------
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /ecs/${Prefix}
      RetentionInDays: 30

  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Ref Prefix
      CapacityProviders: [FARGATE]

  DatabaseSetupTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub ${Prefix}-dbsetup
      NetworkMode: awsvpc
      RequiresCompatibilities: [FARGATE]
      Cpu: "256"
      Memory: "512"
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      ContainerDefinitions:
        - Name: sqlcmd
          Image: mcr.microsoft.com/mssql-tools
          Essential: true
          EntryPoint: [sh, -c]
          # Idempotent. Bulk Signer creates its tables but never its database. The login gets
          # db_datareader, db_datawriter and db_ddladmin, not db_owner.
          Command:
            - |
              set -e
              run() { /opt/mssql-tools/bin/sqlcmd -S "tcp:$DB_HOST,1433" -U "$MASTER_USER" -P "$MASTER_PASSWORD" -N -C -b "$@"; }
              run -d master -Q "IF DB_ID('BulkSigner') IS NULL CREATE DATABASE [BulkSigner];"
              run -d master -Q "ALTER DATABASE [BulkSigner] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;"
              run -d master -Q "IF SUSER_ID('bulksigner') IS NULL CREATE LOGIN [bulksigner] WITH PASSWORD = '$APP_PASSWORD', CHECK_POLICY = ON;"
              run -d BulkSigner -Q "IF USER_ID('bulksigner') IS NULL CREATE USER [bulksigner] FOR LOGIN [bulksigner]; ALTER ROLE db_datareader ADD MEMBER [bulksigner]; ALTER ROLE db_datawriter ADD MEMBER [bulksigner]; ALTER ROLE db_ddladmin ADD MEMBER [bulksigner];"
              echo "BulkSigner database and bulksigner login are in place."
          Environment:
            - { Name: DB_HOST, Value: !GetAtt Database.Endpoint.Address }
            - { Name: MASTER_USER, Value: bsadmin }
          Secrets:
            - { Name: MASTER_PASSWORD, ValueFrom: !Sub "${Database.MasterUserSecret.SecretArn}:password::" }
            - { Name: APP_PASSWORD, ValueFrom: !Sub "${SqlAppSecret}:password::" }
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: dbsetup

  DatabaseSetup:
    Type: Custom::Database
    DependsOn: ExecutionRoleSecrets
    Properties:
      ServiceToken: !GetAtt Provisioner.Arn
      Prefix: !Ref Prefix
      SecretArn: !Ref SqlAppSecret
      DbHost: !GetAtt Database.Endpoint.Address
      Cluster: !Ref Cluster
      TaskDefinition: !Ref DatabaseSetupTaskDefinition
      Subnets: !GetAtt Network.SubnetIds
      SecurityGroup: !Ref TaskSecurityGroup

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Ref Prefix
      NetworkMode: awsvpc
      RequiresCompatibilities: [FARGATE]
      Cpu: "1024"
      Memory: "2048"
      RuntimePlatform: { CpuArchitecture: X86_64, OperatingSystemFamily: LINUX }
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      ContainerDefinitions:
        - Name: bulksigner
          Image: !Ref ImageUri
          Essential: true
          StopTimeout: 60
          PortMappings: [{ ContainerPort: 8080, Protocol: tcp }]
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: bulksigner
          Environment:
            - { Name: ASPNETCORE_ENVIRONMENT, Value: !Ref AppEnvironment }
            - !If [UseTestRoot, { Name: Signing__TrustLacunaTestRoot, Value: "true" }, !Ref AWS::NoValue]
            - { Name: Database__Provider, Value: SqlServer }
            - { Name: Auth__SessionKeyRing__Placement, Value: OperationalStore }
            - { Name: Hosting__ForwardedHeaders__Enabled, Value: "true" }
            - { Name: Hosting__ForwardedHeaders__KnownNetworks__0, Value: !GetAtt Network.CidrBlock }
            - { Name: Storage__Provider, Value: S3 }
            - { Name: Storage__S3__BucketName, Value: !Ref Bucket }
            - { Name: Storage__S3__Prefix, Value: work }
            - { Name: Storage__S3__Region, Value: !Ref AWS::Region }
            - { Name: Storage__S3__Credential, Value: InstanceRole }
            - { Name: Storage__Inputs__0__Name, Value: default }
            - { Name: Storage__Inputs__0__Path, Value: inputs/default }
            - { Name: Storage__Inputs__0__PollIntervalSeconds, Value: "30" }
            - { Name: Signing__Certificate__Source, Value: AwsKms }
            - { Name: Signing__Certificate__AwsKms__Region, Value: !Ref AWS::Region }
            - { Name: Signing__Certificate__AwsKms__KeyId, Value: !Ref KmsKeyAlias }
            - { Name: Signing__Certificate__AwsKms__Credential, Value: InstanceRole }
            - { Name: Signing__Certificate__AwsKms__S3__Bucket, Value: !Ref Bucket }
            - { Name: Signing__Certificate__AwsKms__S3__Key, Value: !Sub "signing-material/${Prefix}.cer" }
            - { Name: Signing__Certificate__AwsKms__S3__Region, Value: !Ref AWS::Region }
            - { Name: Signing__Certificate__AwsKms__S3__Credential, Value: InstanceRole }
            - !If [UseSigner, { Name: Signer__Endpoint, Value: !Ref SignerEndpoint }, !Ref AWS::NoValue]
            - !If [UseSigner, { Name: Signer__FolderSweepIntervalSeconds, Value: "300" }, !Ref AWS::NoValue]
            - !If [UseEntra, { Name: Auth__EntraId__TenantId, Value: !Ref EntraTenantId }, !Ref AWS::NoValue]
            - !If [UseEntra, { Name: Auth__EntraId__ClientId, Value: !Ref EntraClientId }, !Ref AWS::NoValue]
          Secrets:
            - { Name: ConnectionStrings__Default, ValueFrom: !Sub "${SqlAppSecret}:connectionString::" }
            - { Name: Auth__ApiKey, ValueFrom: !Ref AuthApiKeySecret }
            - { Name: Signing__PkiSdkLicense, ValueFrom: !Ref PkiSdkLicenseSecretArn }
            - !If [UseSigner, { Name: Signer__ApiKey, ValueFrom: !Ref SignerApiKeySecretArn }, !Ref AWS::NoValue]
            - !If [UseSigner, { Name: Signer__WebhookSecret, ValueFrom: !Ref SignerWebhookSecret }, !Ref AWS::NoValue]
            - !If [UseEntra, { Name: Auth__EntraId__ClientSecret, ValueFrom: !Ref EntraClientSecretArn }, !Ref AWS::NoValue]

  Service:
    Type: AWS::ECS::Service
    # The listener associates the target group with the load balancer; the database, its login
    # and the connectionString key must exist before the first boot applies the migrations.
    DependsOn: [HttpsListener, DatabaseSetup, DefaultInputFolder]
    Properties:
      ServiceName: bulksigner
      Cluster: !Ref Cluster
      TaskDefinition: !Ref TaskDefinition
      LaunchType: FARGATE
      # One task, never two - not even during a deployment. Do not raise it, and attach no scaling.
      DesiredCount: 1
      DeploymentConfiguration:
        MinimumHealthyPercent: 0
        MaximumPercent: 100
        DeploymentCircuitBreaker: { Enable: true, Rollback: false }
      AvailabilityZoneRebalancing: DISABLED
      HealthCheckGracePeriodSeconds: 300
      NetworkConfiguration:
        AwsvpcConfiguration:
          Subnets: !Split [",", !GetAtt Network.SubnetIds]
          SecurityGroups: [!Ref TaskSecurityGroup]
          AssignPublicIp: ENABLED
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup
          ContainerName: bulksigner
          ContainerPort: 8080

Outputs:
  Url:
    Description: The deployment's address, once the DNS record exists.
    Value: !Sub https://${HostName}/
  LoadBalancerDnsName:
    Description: The load balancer's DNS name.
    Value: !GetAtt LoadBalancer.DNSName
  DnsRecord:
    Description: The record to create in the zone that answers for HostName (or a Route 53 alias record).
    Value: !Sub "${HostName}. CNAME ${LoadBalancer.DNSName}"
  BucketName:
    Value: !Ref Bucket
  KmsKeyArn:
    Value: !GetAtt SigningMaterial.KeyArn
  AuthApiKeySecretArn:
    Description: The dashboard sign-in key and the REST X-API-Key.
    Value: !Ref AuthApiKeySecret
  SignerWebhookSecretArn:
    Condition: UseSigner
    Description: The token to register, as Bearer, in the Lacuna Signer webhook.
    Value: !Ref SignerWebhookSecret
  WebhookUrl:
    Condition: UseSigner
    Value: !Sub https://${HostName}/api/signer/notifications
  LogGroupName:
    Value: !Ref LogGroup
