# IAM.
#
# This file is the isolation boundary. The two ALBs and the two security groups separate the
# network paths, but what actually stops the partner POC service reading the official service's
# secrets is that its task role cannot: the policies below are scoped by secret name prefix, and
# nothing else in this stack enforces that.
#
# Everything lives under the IAM path /mcp/. The CI deploy role's own IAM permissions are limited
# to that path, so a role created outside it is unmanageable once the admin window closes. That is
# not a style rule; it is the difference between a change and a request with a lead time.

locals {
  services = {
    official = {
      secret_prefix = "mcp/official"
      task_sg       = aws_security_group.task_official.id
    }
    poc = {
      secret_prefix = "mcp/poc"
      task_sg       = aws_security_group.task_poc.id
    }
  }
}

# ---------------------------------------------------------------------------------------------
# Execution roles: what ECS itself needs to START a task.
#
# Separate per service, and separate from the TASK role, because they answer different questions.
# The execution role pulls the image and reads the secrets ECS injects as environment variables;
# the task role is what the running code can do. Merging them is common and gives the application
# the registry and secret-fetch permissions it has no use for.
# ---------------------------------------------------------------------------------------------

data "aws_iam_policy_document" "ecs_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "execution" {
  for_each = local.services

  name               = "mcp-${each.key}-execution"
  path               = "/mcp/"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

resource "aws_iam_role_policy_attachment" "execution_managed" {
  for_each = aws_iam_role.execution

  role       = each.value.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# The managed policy covers ECR and logs but NOT Secrets Manager, so the secret read is added here,
# scoped to this service's prefix. This is the statement that makes the boundary real.
data "aws_iam_policy_document" "execution_secrets" {
  for_each = local.services

  statement {
    actions   = ["secretsmanager:GetSecretValue"]
    resources = ["arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:${each.value.secret_prefix}/*"]
  }
}

resource "aws_iam_role_policy" "execution_secrets" {
  for_each = local.services

  name   = "read-own-secrets"
  role   = aws_iam_role.execution[each.key].id
  policy = data.aws_iam_policy_document.execution_secrets[each.key].json
}

# ---------------------------------------------------------------------------------------------
# Task roles: what the RUNNING SERVER may do.
#
# Almost nothing, and that is the design. The server holds no AWS credentials of its own: it
# authenticates to TiDB Cloud with the CALLER's API key, which arrives in a request header and is
# never stored. So there is no S3 to read, no database to reach through IAM, no queue.
#
# An empty-ish role is worth creating anyway rather than omitting one. Without a task role, the
# task runs with the execution role's identity available in the metadata endpoint, which hands the
# application the secret-fetch permission it should not have.
# ---------------------------------------------------------------------------------------------

resource "aws_iam_role" "task" {
  for_each = local.services

  name               = "mcp-${each.key}-task"
  path               = "/mcp/"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

# ECS Exec, for getting a shell into a running task during an incident. Not enabled on the service
# by default; the permission exists so that turning it on is a one-line change under pressure
# rather than an IAM edit during an outage.
data "aws_iam_policy_document" "task_exec_channel" {
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel",
    ]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "task_exec_channel" {
  for_each = local.services

  name   = "ecs-exec"
  role   = aws_iam_role.task[each.key].id
  policy = data.aws_iam_policy_document.task_exec_channel.json
}

# ---------------------------------------------------------------------------------------------
# GitHub OIDC deploy role.
#
# This is what removes long-lived AWS keys from GitHub entirely. The provider itself is created by
# hand in the admin window (one per account) and referenced here.
# ---------------------------------------------------------------------------------------------

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
}

data "aws_iam_policy_document" "github_assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.github.arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    # Matched EXACTLY, against the whole subject claim, and taken verbatim from a token rather than
    # assembled from parts.
    #
    # Two separate things are pinned here and both matter. The branch, because a trust policy of
    # `repo:owner/name:*` also matches pull_request runs, which execute code from a fork's branch,
    # and that is how an OIDC deploy role becomes assumable by anyone who opens a PR. And the
    # repository, in whatever form the organisation actually emits.
    #
    # An earlier version also allowed an `:environment:*` subject, written in the same breath as the
    # paragraph above and undoing most of it: any job on any branch that declares an `environment:`
    # key would have matched. Nothing needs it, and it is gone.
    #
    # The exact-match test is deliberate. StringLike invites a `*` that quietly widens this later.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = [var.oidc_subject]
    }
  }
}

resource "aws_iam_role" "deploy" {
  name               = "mcp-deploy"
  path               = "/mcp/"
  assume_role_policy = data.aws_iam_policy_document.github_assume.json

  description = "CI deploy role. Trust policy pins one exact OIDC subject; see var.oidc_subject."
}

data "aws_iam_policy_document" "deploy" {
  # Push images.
  statement {
    sid       = "EcrAuth"
    actions   = ["ecr:GetAuthorizationToken"]
    resources = ["*"]
  }

  statement {
    sid = "EcrPush"
    actions = [
      "ecr:BatchCheckLayerAvailability",
      "ecr:CompleteLayerUpload",
      "ecr:InitiateLayerUpload",
      "ecr:PutImage",
      "ecr:UploadLayerPart",
      "ecr:BatchGetImage",
      "ecr:GetDownloadUrlForLayer",
      "ecr:DescribeImages",
    ]
    resources = [for r in aws_ecr_repository.this : r.arn]
  }

  # Deploy. `RegisterTaskDefinition` has no resource-level scoping in IAM, which is an AWS
  # limitation rather than a choice here; `UpdateService` is scoped to this cluster's services.
  statement {
    sid = "EcsDeploy"
    actions = [
      "ecs:DescribeServices",
      "ecs:DescribeTaskDefinition",
      "ecs:DescribeClusters",
      "ecs:DescribeTasks",
      "ecs:ListTasks",
      "ecs:RegisterTaskDefinition",
      "ecs:UpdateService",
    ]
    resources = ["*"]
  }

  # Pass the task and execution roles to ECS. Scoped to the roles this stack creates: an unscoped
  # `iam:PassRole` lets the holder run a task as ANY role in the account, which turns a deploy
  # credential into a privilege escalation.
  statement {
    sid     = "PassTaskRoles"
    actions = ["iam:PassRole"]
    resources = concat(
      [for r in aws_iam_role.task : r.arn],
      [for r in aws_iam_role.execution : r.arn],
    )
    condition {
      test     = "StringEquals"
      variable = "iam:PassedToService"
      values   = ["ecs-tasks.amazonaws.com"]
    }
  }

  statement {
    sid = "ReadLogs"
    actions = [
      "logs:GetLogEvents",
      "logs:FilterLogEvents",
      "logs:DescribeLogStreams",
      "logs:DescribeLogGroups",
    ]
    resources = ["*"]
  }

  # Secret CONTAINERS may be created and described. Reading VALUES is deliberately absent: CI has
  # no reason to see a secret, and a deploy credential that can read every secret is the same
  # blast radius as the secrets themselves.
  statement {
    sid = "SecretContainers"
    actions = [
      "secretsmanager:CreateSecret",
      "secretsmanager:DescribeSecret",
      "secretsmanager:TagResource",
    ]
    resources = ["arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:mcp/*"]
  }

  statement {
    sid       = "CertificatesForTls"
    actions   = ["acm:RequestCertificate", "acm:DescribeCertificate", "acm:ListCertificates"]
    resources = ["*"]
  }

  # NO S3 statement for Terraform state, deliberately.
  #
  # This role does run `terraform apply`, so it does need to read and write state and to take the
  # lock. That access is granted by the state bucket's RESOURCE policy (runbook step 5), not here.
  # Same-account S3 accepts either, and putting it on the bucket keeps one list of who may touch
  # state, in one place, rather than two that can drift apart.
  #
  # If a post-admin `terraform apply` fails on state access, fix the BUCKET POLICY. Adding an S3
  # statement here would also work and would leave two half-descriptions of the same grant.

  # The design allowed a narrow IAM escape hatch here: `iam:*` limited to the /mcp/ path, so that
  # CI could one day apply Terraform. It is NOT granted, and the omission is the point.
  #
  # "Limited to /mcp/*" reads like a small blast radius and is not, because of what lives in that
  # path. `mcp-operator` does. It is the strongest role in this account, and `iam:*` over its ARN
  # includes `iam:UpdateAssumeRolePolicy`, so a holder can simply write itself into the operator's
  # trust policy and assume it. Path-scoping bounds which resources are reachable; it says nothing
  # about how much authority those particular resources carry.
  #
  # Nothing needs it today. Terraform is applied by a person, not by CI, which the brief states as
  # out of scope. Grant it when that changes, and when it does, scope it to the specific roles CI
  # must pass to ECS rather than to the path, or move mcp-operator out of /mcp/ first.
}

resource "aws_iam_role_policy" "deploy" {
  name   = "deploy"
  role   = aws_iam_role.deploy.id
  policy = data.aws_iam_policy_document.deploy.json
}
