# The role that lets CI apply THIS repository.
#
# This is the step that retires admin. Until it exists, every infrastructure change needs a person
# holding a permission set, and the account has already once become unreachable while the service
# kept running. After it, losing human access costs console investigation and manual secret entry,
# not the ability to change anything.
#
# The tradeoff is stated in the plan and repeated here because this file is where someone will meet
# it: a GitHub repository gains the ability to change production infrastructure. That is accepted
# because the alternative is one person's quarterly access renewal standing between the company and
# its own infrastructure. What bounds it: the trust policy matches ONE exact subject (this
# repository's main branch, in the org's immutable-id form), and the workflow applies only behind an
# explicit dispatch checkbox. Pull requests get fmt and validate with no AWS credentials at all.

variable "infra_oidc_subject" {
  description = <<-EOT
    The FULL subject claim GitHub puts in the OIDC token for THIS repository's main branch.

    Same shape and same warning as `oidc_subject` (the application repository's claim): the
    tidbcloud organisation emits IMMUTABLE subjects carrying numeric ids, so this cannot be derived
    from the repository name. Read it from a workflow run; docs/DEPLOY.md in the application
    repository records the procedure. Guessing it produces AssumeRoleWithWebIdentity denials that
    name nothing.

    Empty means the role is not created, which is the state before the claim has been read once.
  EOT
  type        = string
  default     = ""

  validation {
    condition     = var.infra_oidc_subject == "" || can(regex("^repo:.+:ref:refs/heads/.+$", var.infra_oidc_subject))
    error_message = "infra_oidc_subject must be a full subject claim, e.g. repo:org@123/name@456:ref:refs/heads/main"
  }
}

variable "infra_repo_slug" {
  description = <<-EOT
    owner/name of THIS repository, the readable half of the pair with `infra_oidc_subject`. Same
    construction as `repo_slug`/`oidc_subject` for the application: the subject carries the name
    beside the numeric id, the validation below forces the two to agree, and anyone asking "which
    repository can apply this stack?" reads the slug instead of decoding an id string.
  EOT
  type        = string
  default     = ""

  validation {
    condition = var.infra_oidc_subject == "" || (
      var.infra_repo_slug != "" &&
      strcontains(var.infra_oidc_subject, "repo:${split("/", var.infra_repo_slug)[0]}@") &&
      strcontains(var.infra_oidc_subject, "/${split("/", var.infra_repo_slug)[1]}@")
    )
    error_message = "infra_oidc_subject is set, so infra_repo_slug must be set and must agree with it."
  }
}

data "aws_iam_policy_document" "terraform_ci_assume" {
  count = var.infra_oidc_subject == "" ? 0 : 1

  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"]
    }

    # StringEquals against the whole claim, for the same reason as the deploy role: StringLike
    # invites a `*` that quietly widens this later, and `environment:`-shaped subjects have already
    # been an escalation path here once.
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = [var.infra_oidc_subject]
    }
  }
}

resource "aws_iam_role" "terraform_ci" {
  count = var.infra_oidc_subject == "" ? 0 : 1

  name               = "mcp-terraform"
  path               = "/mcp/"
  assume_role_policy = data.aws_iam_policy_document.terraform_ci_assume[0].json

  description = "CI terraform plan/apply for mcp-server-infra. Trusts one exact OIDC subject."
}

# The operator policy minus the ability to SEE a secret.
#
# Terraform needs to create secret containers, describe them, and read their resource policies
# (the provider refreshes GetResourcePolicy on every aws_secretsmanager_secret; learned by running
# the exit test, not by reading docs). It never needs the VALUE: values are set out of band
# precisely so they cannot enter state. A pipeline that can read every secret is the same blast
# radius as the secrets themselves, which is why the deploy role cannot either.
#
# IAM write is ENUMERATED to the roles Terraform manages FOR OTHERS, and the list is the boundary.
# A reviewer pointed out what the earlier path wildcard (role/mcp/*) quietly included: this role's
# own ARN and mcp-operator's. With those in scope, one compromised or wrong CI run could attach
# GetSecretValue to itself and read every mcp/* secret in the same run, which turns the
# "never sees a value" claim above into a speed bump. Against the actor this trust policy names
# (someone who can push to main) enumeration changes little, since they can apply anything anyway;
# it is the single bad RUN that now hits a wall instead.
#
# Two consequences, priced in rather than discovered later. Changes to mcp-terraform's own role or
# policy are applied by the operator from a laptop, not by CI, which is the right hands for the
# change that widens CI. And adding a NEW role to this stack means adding its ARN here first, in a
# reviewed commit, which is friction doing its job.
data "aws_iam_policy_document" "terraform_ci" {
  count = var.infra_oidc_subject == "" ? 0 : 1

  statement {
    sid = "ApplyTheStack"
    actions = [
      "ec2:*", "ecs:*", "ecr:*", "elasticloadbalancing:*", "wafv2:*",
      "logs:*", "cloudwatch:*", "acm:*", "sns:*", "budgets:*",
      "application-autoscaling:*",
    ]
    resources = ["*"]
  }

  statement {
    sid = "SecretContainersNeverValues"
    actions = [
      "secretsmanager:CreateSecret",
      "secretsmanager:DescribeSecret",
      "secretsmanager:DeleteSecret",
      "secretsmanager:TagResource",
      "secretsmanager:UntagResource",
      "secretsmanager:GetResourcePolicy",
      "secretsmanager:PutResourcePolicy",
      "secretsmanager:DeleteResourcePolicy",
      "secretsmanager:ListSecretVersionIds",
    ]
    resources = ["arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:mcp/*"]
  }

  # ListSecrets has no resource-level scoping.
  statement {
    sid       = "SecretsList"
    actions   = ["secretsmanager:ListSecrets"]
    resources = ["*"]
  }

  statement {
    sid     = "ManageStackRolesEnumerated"
    actions = ["iam:*"]
    resources = [
      "arn:aws:iam::${var.account_id}:role/mcp/mcp-official-task",
      "arn:aws:iam::${var.account_id}:role/mcp/mcp-poc-task",
      "arn:aws:iam::${var.account_id}:role/mcp/mcp-official-execution",
      "arn:aws:iam::${var.account_id}:role/mcp/mcp-poc-execution",
      "arn:aws:iam::${var.account_id}:role/mcp/mcp-deploy",
    ]
  }

  statement {
    sid       = "ReadIamAndOidc"
    actions   = ["iam:GetOpenIDConnectProvider", "iam:ListOpenIDConnectProviders", "iam:GetRole", "iam:ListRoles", "iam:ListRolePolicies", "iam:ListAttachedRolePolicies", "iam:GetRolePolicy", "iam:GetPolicy", "iam:GetPolicyVersion", "iam:ListInstanceProfilesForRole"]
    resources = ["*"]
  }

  statement {
    sid       = "PassTaskRolesToEcsOnly"
    actions   = ["iam:PassRole"]
    resources = ["arn:aws:iam::${var.account_id}:role/mcp/*"]
    condition {
      test     = "StringEquals"
      variable = "iam:PassedToService"
      values   = ["ecs-tasks.amazonaws.com"]
    }
  }

  # iam:CreateServiceLinkedRole is deliberately ABSENT. The ECS and ELB service-linked roles were
  # created in the admin window, and this role must not be able to create account-level roles
  # outside /mcp/. If a future AWS service needs one, that is an admin-window item, not a widening
  # of this policy.
}

resource "aws_iam_role_policy" "terraform_ci" {
  count = var.infra_oidc_subject == "" ? 0 : 1

  name   = "terraform"
  role   = aws_iam_role.terraform_ci[0].id
  policy = data.aws_iam_policy_document.terraform_ci[0].json
}

output "terraform_ci_role_arn" {
  description = "Assumed by the infra repository's workflow over OIDC. Empty until infra_oidc_subject is set."
  value       = var.infra_oidc_subject == "" ? "" : aws_iam_role.terraform_ci[0].arn
}
