diff --git a/aws/github-token-broker/README.md b/aws/github-token-broker/README.md index bff5d66..4203e08 100644 --- a/aws/github-token-broker/README.md +++ b/aws/github-token-broker/README.md @@ -1,5 +1,12 @@ # aws/github-token-broker +> Deprecated: the GitHub token broker is now owned by `aws/keycloak` through +> the reusable `meigma/github-token-broker` Terraform module. Keep this stack +> only long enough to destroy the legacy `glab-github-token-broker` resources +> in AWS before applying `aws/keycloak`. The legacy state may also own the lab +> GitHub Actions OIDC provider; do not run a full destroy unless you have +> confirmed that provider is not shared. + OpenTofu stack for the GitHub token broker Lambda in the `lab` account. This stack creates: diff --git a/aws/keycloak/.terraform.lock.hcl b/aws/keycloak/.terraform.lock.hcl index b80adaa..37fbe38 100644 --- a/aws/keycloak/.terraform.lock.hcl +++ b/aws/keycloak/.terraform.lock.hcl @@ -3,7 +3,7 @@ provider "registry.opentofu.org/hashicorp/aws" { version = "5.100.0" - constraints = "~> 5.90" + constraints = ">= 5.0.0, ~> 5.90, < 7.0.0" hashes = [ "h1:BrNG7eFOdRrRRbHdvrTjMJ8X8Oh/tiegURiKf7J2db8=", "zh:1a41f3ee26720fee7a9a0a361890632a1701b5dc1cf5355dc651ddbe115682ff", @@ -18,3 +18,21 @@ provider "registry.opentofu.org/hashicorp/aws" { "zh:ea851a3c072528a4445ac6236ba2ce58ffc99ec466019b0bd0e4adde63a248e4", ] } + +provider "registry.opentofu.org/hashicorp/null" { + version = "3.2.4" + constraints = ">= 3.2.0" + hashes = [ + "h1:i+WKhUHL2REY5EGmiHjfUljJB8UKZ9QdhdM5uTeUhC4=", + "zh:1769783386610bed8bb1e861a119fe25058be41895e3996d9216dd6bb8a7aee3", + "zh:32c62a9387ad0b861b5262b41c5e9ed6e940eda729c2a0e58100e6629af27ddb", + "zh:339bf8c2f9733fce068eb6d5612701144c752425cebeafab36563a16be460fb2", + "zh:36731f23343aee12a7e078067a98644c0126714c4fe9ac930eecb0f2361788c4", + "zh:3d106c7e32a929e2843f732625a582e562ff09120021e510a51a6f5d01175b8d", + "zh:74bcb3567708171ad83b234b92c9d63ab441ef882b770b0210c2b14fdbe3b1b6", + "zh:90b55bdbffa35df9204282251059e62c178b0ac7035958b93a647839643c0072", + "zh:ae24c0e5adc692b8f94cb23a000f91a316070fdc19418578dcf2134ff57cf447", + "zh:b5c10d4ad860c4c21273203d1de6d2f0286845edf1c64319fa2362df526b5f58", + "zh:e05bbd88e82e1d6234988c85db62fd66f11502645838fff594a2ec25352ecd80", + ] +} diff --git a/aws/keycloak/README.md b/aws/keycloak/README.md index 6d6ed51..f8a7159 100644 --- a/aws/keycloak/README.md +++ b/aws/keycloak/README.md @@ -10,6 +10,9 @@ This stack creates: - a security group that exposes HTTPS only to lab CIDRs - a private Route 53 `A` record for `id.glab.lol` - scoped Route 53 permissions for ACME DNS-01 validation in `acme.glab.lol` +- a GitHub token broker Lambda, sourced from + `meigma/github-token-broker`, for short-lived `GilmanLab/secrets` access +- Keycloak instance-role permission to invoke the token broker - an SSM-driven Docker Compose deployment for Postgres, Keycloak, and Traefik This stack intentionally stops at starting Keycloak. It does **not** configure @@ -29,6 +32,12 @@ the delegated TXT record for this hostname. - `GLAB_AWS_STATE_BUCKET` set to the pre-created S3 backend bucket in the `lab` account - the `aws/lab-foundation` and `aws/subnet-router` stacks already applied +- the GitHub App SSM parameters created outside Terraform: + `/glab/bootstrap/github-app/client-id`, + `/glab/bootstrap/github-app/installation-id`, and + `/glab/bootstrap/github-app/private-key-pem` +- `gh` and `sha256sum` on the apply host so the broker module can download and + verify the pinned release asset - Cloudflare delegates `acme.glab.lol` to the `aws/lab-foundation` `acme_zone_name_servers` output and sets `_acme-challenge.id.glab.lol` as a CNAME to @@ -36,6 +45,34 @@ the delegated TXT record for this hostname. The expected local operator flow is to export both values via `direnv`. +## Legacy token broker cleanup + +The old `aws/github-token-broker` stack used the same default Lambda name, +`glab-github-token-broker`, and was originally published from +`GilmanLab/platform`. Do not apply both stacks at the same time. + +If the old Lambda is still active, retire it before applying this stack: + +```sh +cd ../github-token-broker +just init +tofu plan -destroy \ + -target=aws_lambda_function.broker \ + -target=aws_cloudwatch_log_group.broker \ + -target=aws_iam_policy.invoke \ + -target=aws_iam_role.execution \ + -target=aws_iam_role.publisher \ + -target=aws_iam_role_policy.execution_logs \ + -target=aws_iam_role_policy.execution_ssm \ + -target=aws_iam_role_policy.publisher \ + -out=destroy-broker-only.tfplan +tofu apply destroy-broker-only.tfplan +``` + +Then apply `aws/keycloak`. This removes the legacy Lambda resources while +leaving the GitHub Actions OIDC provider alone; only run a full destroy after +confirming that provider has no shared use. + ## Usage ```sh diff --git a/aws/keycloak/github_token_broker.tf b/aws/keycloak/github_token_broker.tf new file mode 100644 index 0000000..3c80e19 --- /dev/null +++ b/aws/keycloak/github_token_broker.tf @@ -0,0 +1,39 @@ +module "github_token_broker" { + source = "github.com/meigma/github-token-broker//terraform?ref=v2.0.0" + + function_name = var.github_token_broker_function_name + repository_owner = "GilmanLab" + repository_name = "secrets" + + release_repository = var.github_token_broker_release_repository + lambda_artifact = { + release_version = var.github_token_broker_release_version + } + + ssm_parameter_paths = var.github_token_broker_ssm_parameter_paths + kms_key_arn = var.github_token_broker_private_key_kms_key_arn + permissions = var.github_token_broker_permissions + log_retention_days = var.github_token_broker_log_retention_days + + tags = merge(local.common_tags, { + "glab:purpose" = "keycloak-github-token-broker" + }) +} + +data "aws_iam_policy_document" "keycloak_github_token_broker_invoke" { + statement { + sid = "AllowInvokeGitHubTokenBroker" + actions = [ + "lambda:InvokeFunction", + ] + resources = [ + module.github_token_broker.function_arn, + ] + } +} + +resource "aws_iam_role_policy" "keycloak_github_token_broker_invoke" { + name = "${var.iam_role_name}-github-token-broker-invoke" + policy = data.aws_iam_policy_document.keycloak_github_token_broker_invoke.json + role = aws_iam_role.keycloak.id +} diff --git a/aws/keycloak/outputs.tf b/aws/keycloak/outputs.tf index 19160e8..7ac2580 100644 --- a/aws/keycloak/outputs.tf +++ b/aws/keycloak/outputs.tf @@ -8,6 +8,26 @@ output "iam_role_arn" { value = aws_iam_role.keycloak.arn } +output "github_token_broker_function_arn" { + description = "ARN of the GitHub token broker Lambda deployed for Keycloak bootstrap access." + value = module.github_token_broker.function_arn +} + +output "github_token_broker_function_name" { + description = "Name of the GitHub token broker Lambda deployed for Keycloak bootstrap access." + value = module.github_token_broker.function_name +} + +output "github_token_broker_log_group_name" { + description = "CloudWatch log group for the GitHub token broker Lambda." + value = module.github_token_broker.log_group_name +} + +output "github_token_broker_release_version" { + description = "GitHub token broker release version deployed by this stack." + value = module.github_token_broker.deployed_version +} + output "instance_id" { description = "EC2 instance ID of the Keycloak host." value = aws_instance.keycloak.id diff --git a/aws/keycloak/tests/main.tftest.hcl b/aws/keycloak/tests/main.tftest.hcl index 6fa0042..19a9597 100644 --- a/aws/keycloak/tests/main.tftest.hcl +++ b/aws/keycloak/tests/main.tftest.hcl @@ -7,12 +7,31 @@ mock_provider "aws" { } } + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + name = "us-west-2" + } + } + mock_data "aws_iam_policy_document" { defaults = { json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" } } + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/mock-role" + id = "mock-role" + } + } + mock_data "aws_route53_zone" { defaults = { zone_id = "Z00000000000000000" @@ -122,6 +141,21 @@ run "plan_defaults" { condition = alltrue([for rule in aws_vpc_security_group_ingress_rule.keycloak_https : rule.cidr_ipv4 != "0.0.0.0/0" && rule.from_port == 443 && rule.to_port == 443 && rule.ip_protocol == "tcp"]) error_message = "Keycloak ingress should expose only HTTPS to non-public lab CIDRs." } + + assert { + condition = module.github_token_broker.function_name == "glab-github-token-broker" + error_message = "The Keycloak stack should deploy the shared GitHub token broker name." + } + + assert { + condition = module.github_token_broker.deployed_version == "v2.0.0" + error_message = "The Keycloak stack should pin the current broker release." + } + + assert { + condition = aws_iam_role_policy.keycloak_github_token_broker_invoke.role == aws_iam_role.keycloak.id + error_message = "The Keycloak instance role should be allowed to invoke the token broker." + } } run "plan_overrides" { @@ -132,9 +166,10 @@ run "plan_overrides" { } variables { - instance_name = "glab-aws-keycloak-staging" - instance_type = "t4g.medium" - lab_cidrs = ["10.10.0.0/16", "10.20.0.0/16"] + instance_name = "glab-aws-keycloak-staging" + instance_type = "t4g.medium" + github_token_broker_function_name = "glab-keycloak-staging-github-token-broker" + lab_cidrs = ["10.10.0.0/16", "10.20.0.0/16"] operator_tailscale_cidrs = { laptop = "100.64.1.1/32" studio = "100.64.1.2/32" @@ -176,6 +211,11 @@ run "plan_overrides" { condition = local.acme_challenge_record_name == "_acme-challenge.id.staging.acme.glab.lol" error_message = "The delegated ACME challenge record should follow the private hostname override." } + + assert { + condition = module.github_token_broker.function_name == "glab-keycloak-staging-github-token-broker" + error_message = "The broker function name override should propagate." + } } run "reject_invalid_lab_cidr" { diff --git a/aws/keycloak/variables.tf b/aws/keycloak/variables.tf index 9469ba6..77a4957 100644 --- a/aws/keycloak/variables.tf +++ b/aws/keycloak/variables.tf @@ -105,6 +105,103 @@ variable "instance_type" { default = "t4g.small" } +variable "github_token_broker_function_name" { + description = "Name of the GitHub token broker Lambda deployed with the Keycloak stack." + type = string + default = "glab-github-token-broker" + + validation { + condition = can(regex("^[A-Za-z0-9_-]{1,64}$", var.github_token_broker_function_name)) + error_message = "github_token_broker_function_name must be 1-64 characters and contain only letters, numbers, hyphens, and underscores." + } +} + +variable "github_token_broker_log_retention_days" { + description = "CloudWatch log retention, in days, for the GitHub token broker Lambda log group." + type = number + default = 30 + + validation { + condition = contains( + [1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 2192, 2557, 2922, 3288, 3653, 0], + var.github_token_broker_log_retention_days, + ) + error_message = "github_token_broker_log_retention_days must be one of the values accepted by CloudWatch Logs, or 0 for never expire." + } +} + +variable "github_token_broker_permissions" { + description = "GitHub App installation token permissions requested by the broker." + type = map(string) + default = { contents = "read" } + + validation { + condition = alltrue([ + for k, v in var.github_token_broker_permissions : length(trimspace(k)) > 0 && length(trimspace(v)) > 0 + ]) + error_message = "github_token_broker_permissions entries must have non-empty keys and values." + } +} + +variable "github_token_broker_private_key_kms_key_arn" { + description = "Optional customer-managed KMS key or alias ARN used by SSM to encrypt the GitHub App private key parameter." + type = string + default = null + + validation { + condition = ( + var.github_token_broker_private_key_kms_key_arn == null || + can(regex("^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:[0-9]{12}:(key/[A-Za-z0-9-]+|alias/[A-Za-z0-9/_-]+)$", var.github_token_broker_private_key_kms_key_arn)) + ) + error_message = "github_token_broker_private_key_kms_key_arn must be a literal KMS key or alias ARN without wildcard characters." + } +} + +variable "github_token_broker_release_repository" { + description = "OWNER/REPO GitHub repository that publishes the GitHub token broker release asset." + type = string + default = "meigma/github-token-broker" + + validation { + condition = can(regex("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", var.github_token_broker_release_repository)) + error_message = "github_token_broker_release_repository must be a literal OWNER/REPO value." + } +} + +variable "github_token_broker_release_version" { + description = "Release tag of meigma/github-token-broker to deploy." + type = string + default = "v2.0.0" + + validation { + condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+(-[A-Za-z0-9.-]+)?$", var.github_token_broker_release_version)) + error_message = "github_token_broker_release_version must be a semver tag such as v2.0.0." + } +} + +variable "github_token_broker_ssm_parameter_paths" { + description = "SSM parameter paths holding the GitHub App credentials used by the broker." + type = object({ + client_id = string + installation_id = string + private_key = string + }) + default = { + client_id = "/glab/bootstrap/github-app/client-id" + installation_id = "/glab/bootstrap/github-app/installation-id" + private_key = "/glab/bootstrap/github-app/private-key-pem" + } + + validation { + condition = alltrue([ + can(regex("^/[A-Za-z0-9_.\\-/]+$", var.github_token_broker_ssm_parameter_paths.client_id)), + can(regex("^/[A-Za-z0-9_.\\-/]+$", var.github_token_broker_ssm_parameter_paths.installation_id)), + can(regex("^/[A-Za-z0-9_.\\-/]+$", var.github_token_broker_ssm_parameter_paths.private_key)), + ]) + error_message = "github_token_broker_ssm_parameter_paths entries must be absolute literal SSM paths." + } +} + variable "keycloak_admin_username" { description = "Temporary bootstrap admin username passed to Keycloak on first startup." type = string