diff --git a/.github/workflows/k8s_terraform_apply.yml b/.github/workflows/k8s_terraform_apply.yml new file mode 100644 index 00000000..d49d245f --- /dev/null +++ b/.github/workflows/k8s_terraform_apply.yml @@ -0,0 +1,74 @@ +--- +name: 'Terraform Apply on Kubernetes' +on: + push: + branches: + - main + paths: + - '.github/workflows/k8s_terraform_apply.yml' + - 'kubernetes/terraform/stacks/acm-member-portal/**' + workflow_dispatch: + +permissions: + contents: read + id-token: write + pull-requests: write + +jobs: + terraform: + name: 'Terraform Apply' + env: + ARM_CLIENT_ID: ${{ secrets.AZURE_AD_CLIENT_ID }} + ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + ARM_USE_OIDC: true + TF_WORKSPACE: prod + TF_IN_AUTOMATION: true + TF_INPUT: 0 + runs-on: maid-cafe + environment: prod + + strategy: + matrix: + stack: + - acm-member-portal + + defaults: + run: + shell: bash + working-directory: kubernetes/terraform/stacks/${{ matrix.stack }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Azure CLI setup + uses: azure/login@v1 + with: + client-id: ${{ env.ARM_CLIENT_ID }} + tenant-id: ${{ env.ARM_TENANT_ID }} + subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }} + + - name: 'Terraform Format' + id: fmt + uses: dflook/terraform-fmt-check@v1 + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + + - name: 'Terraform Validate' + id: validate + uses: dflook/terraform-validate@v1 + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + + - name: 'Terraform Apply' + id: apply + uses: dflook/terraform-apply@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + var_file: "kubernetes/terraform/stacks/${{ matrix.stack }}/configuration/${{ env.TF_WORKSPACE }}.tfvars" diff --git a/.github/workflows/k8s_terraform_plan.yml b/.github/workflows/k8s_terraform_plan.yml new file mode 100644 index 00000000..00ca9b46 --- /dev/null +++ b/.github/workflows/k8s_terraform_plan.yml @@ -0,0 +1,70 @@ +--- +name: 'Terraform PRs for Kubernetes' +on: + pull_request: + paths: + - '.github/workflows/k8s_terraform_plan.yml' + - 'kubernetes/terraform/stacks/acm-member-portal/**' + +permissions: + contents: read + id-token: write + pull-requests: write + +jobs: + terraform: + name: 'Terraform PR Plan' + env: + ARM_CLIENT_ID: ${{ secrets.AZURE_AD_CLIENT_ID }} + ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + ARM_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + ARM_USE_OIDC: true + TF_WORKSPACE: prod + TF_IN_AUTOMATION: true + TF_INPUT: 0 + runs-on: maid-cafe + + strategy: + matrix: + stack: + - acm-member-portal + + defaults: + run: + shell: bash + working-directory: kubernetes/terraform/stacks/${{ matrix.stack }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Azure CLI setup + uses: azure/login@v1 + with: + client-id: ${{ env.ARM_CLIENT_ID }} + tenant-id: ${{ env.ARM_TENANT_ID }} + subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }} + + - name: 'Terraform Format' + id: fmt + uses: dflook/terraform-fmt-check@v1 + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + + - name: 'Terraform Validate' + id: validate + uses: dflook/terraform-validate@v1 + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + + - name: 'Terraform Plan' + id: plan + uses: dflook/terraform-plan@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path: "kubernetes/terraform/stacks/${{ matrix.stack }}" + workspace: ${{ env.TF_WORKSPACE }} + var_file: "kubernetes/terraform/stacks/${{ matrix.stack }}/configuration/${{ env.TF_WORKSPACE }}.tfvars" diff --git a/kubernetes/terraform/stacks/acm-member-portal/.terraform.lock.hcl b/kubernetes/terraform/stacks/acm-member-portal/.terraform.lock.hcl new file mode 100644 index 00000000..7fd05583 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/.terraform.lock.hcl @@ -0,0 +1,84 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azuread" { + version = "3.9.0" + constraints = "3.9.0" + hashes = [ + "h1:ZUXJzK5xV1MzRefx1OZCSGAaV2hCbXw5LWJTahi7ifo=", + "zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7", + "zh:39b11a075e4baa4f6ed5c72a8427013d50f43eecc1a7603b73bccf80f952f758", + "zh:41484c196c943b39411f561e70a308bd2a71da18155bfec7381ba0bd61361d34", + "zh:42068e5da223494beea5f7fcb9057c308cbfa92f96e53c50083e2639216479d8", + "zh:464d7da44682443a4b64bfdaf3d0eb53011c6e1471f244f6354c4d5bca18edce", + "zh:49f597ea3fac39931ff91e55afd5b5cc91e449920a03716f82509d588aaab708", + "zh:6092c376accfc50b555b7a0cd56b76c09abc3d65ac9dd5069063d6f9f1e76d3b", + "zh:65326a9f3ac0783c16e05c16422d191f0a926b8d021fd5303c1fdf8dc42f16e9", + "zh:784214ed809347d74562bb38194c0cef57831eaa621ba3b7cdd3fe7a7a76d844", + "zh:b4233f9bc791adc7d6643507fa5b47360a21125763a072d953586151cacb65f9", + "zh:c4ecdd995ff99b7e362e087c45f080816bcf097da5be257c87b912210e45dd3e", + "zh:f0122771f71cb98248e70cdd6c2ccd3bffb34e79d19897fa28b785c86b2312ed", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.38.0" + constraints = "2.38.0" + hashes = [ + "h1:XCkL/mxjWTawg6gg+jlpCQhF/+SNRoCEZxbbkDTj42s=", + "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", + "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", + "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", + "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", + "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", + "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", + "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", + "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", + "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", + "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "3.9.0" + hashes = [ + "h1:lVDv+0AjDjrLfpmaJbWqUmIw/k3/AHXLc3N4m55SNdo=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} + +provider "registry.terraform.io/hashicorp/time" { + version = "0.14.1" + constraints = "0.14.1" + hashes = [ + "h1:YRK+EoKey0ssT+VU2821si0k+fzPFHALvVnfe2atBlU=", + "zh:0837ca5b057e5cff94dff7de2fcccafb4abaa33c45de193fe2853e684818a267", + "zh:15a122f72d9e0f34fc5384cc7ec089319641fee5c319748a3aa02fc42f459969", + "zh:342fb83093a280ea7ee0654feae1f5867c62eb8eebc1ab46f9a7ab0b4c878a62", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:99f169834d3370b8341381c6a9c7a8b01fb26027531faa38e6fb49cc23916f68", + "zh:9f482917c7a28cf2436578be7aa9f04f8c811aba8b5949e0223ea987a2757a91", + "zh:ac6b5b8732826f2d1129a8a4a038ac7a7a9ca7b77d2a4608e5703be1a1e2bff0", + "zh:c54782a27d58ce04f6696c6fc0b2cf1e2fba6bed239fb520521a7bce7d7193cb", + "zh:c8d0ddc8f575ecb44f025d54edbfe118e26397fe328a67be62325766f31eb6e7", + "zh:d043b96f204edd2353bf6b2a34e645ffdee2e9634d9bb747331320444810a538", + "zh:e32c288501ca9a6c9d22b52e839dd391fc7083d54ee6b8dc296ce0e6bd3e57ef", + "zh:e47fcc7bb4e9ab5cc522c3b06e4fa9c0bf94b84be8210bc6b1655c44acb2addc", + "zh:f61bf218322bcbe0bd2d56bba738e7fa485e9b54244e13aa12de741b37d450c0", + ] +} diff --git a/kubernetes/terraform/stacks/acm-member-portal/README.md b/kubernetes/terraform/stacks/acm-member-portal/README.md new file mode 100644 index 00000000..8792edb2 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/README.md @@ -0,0 +1,28 @@ +# ACM member portal Terraform stack + +Entra app registration for the member portal, plus the `portal-secrets` Kubernetes Secret. + +Terraform patches `BETTER_AUTH_SECRET` and `MICROSOFT_*`. Other keys (SMTP, Discord, Windows API) can live on the same Secret and are left alone on apply. + +Helm / Argo CD deploy the app from `kubernetes/argocd/stacks/acm-member-portal`. If `portal-secrets` already exists, import the stub Secret before the first apply: + +```bash +terraform import kubernetes_secret_v1.portal acm-portal/portal-secrets +``` + +## Requirements + +- Azure login with permission to create app registrations (`az login`, or the CI OIDC service principal) +- `KUBE_CONFIG_PATH` or `KUBECONFIG` pointing at the cluster that hosts `acm-portal` +- Namespace `acm-portal` already present (Argo CD creates it) + +## Manual run + +```bash +terraform init +terraform workspace select prod || terraform workspace new prod +terraform plan -var-file configuration/prod.tfvars +terraform apply -var-file configuration/prod.tfvars +``` + +After apply, in Entra: App registrations → `acm-member-portal-prod` → API permissions → Grant admin consent. diff --git a/kubernetes/terraform/stacks/acm-member-portal/app-registration.tf b/kubernetes/terraform/stacks/acm-member-portal/app-registration.tf new file mode 100644 index 00000000..ba2b14d5 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/app-registration.tf @@ -0,0 +1,93 @@ +data "azuread_client_config" "current" {} + +locals { + owners = distinct(concat([data.azuread_client_config.current.object_id], var.additional_owner_ids)) +} + +resource "azuread_application" "portal" { + display_name = "acm-member-portal-${var.deployment_env}" + sign_in_audience = "AzureADMyOrg" + owners = local.owners + + web { + redirect_uris = var.redirect_uris + logout_url = length(var.logout_uris) > 0 ? var.logout_uris[0] : null + } + + # Graph delegated scopes. openid / profile / email are requested at runtime by + # better-auth; grant admin consent in Entra after the first apply. + required_resource_access { + resource_app_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph + + resource_access { + id = "37f7f235-527c-4136-accd-4f02faf2b745" + type = "Scope" # openid + } + resource_access { + id = "64a6cdd6-4a97-4e60-a3e8-d57ef873a778" + type = "Scope" # email + } + } + + optional_claims { + id_token { name = "email" } + id_token { name = "upn" } + id_token { + name = "groups" + additional_properties = ["sam_account_name"] + } + } +} + +resource "azuread_service_principal" "portal" { + client_id = azuread_application.portal.client_id + use_existing = true + owners = local.owners +} + +resource "azuread_application_password" "portal" { + application_id = azuread_application.portal.id + display_name = "portal-${var.deployment_env}" + rotate_when_changed = { + rotation = time_rotating.rotation.id + } +} + +resource "time_rotating" "rotation" { + rotation_days = 180 +} + +resource "random_password" "auth" { + length = 48 + special = false +} + +resource "kubernetes_secret_v1" "portal" { + metadata { + name = "portal-secrets" + namespace = var.kubernetes_namespace + } + + type = "Opaque" + + lifecycle { + ignore_changes = [data, binary_data] + } +} + +resource "kubernetes_secret_v1_data" "portal" { + metadata { + name = kubernetes_secret_v1.portal.metadata[0].name + namespace = kubernetes_secret_v1.portal.metadata[0].namespace + } + + data = { + BETTER_AUTH_SECRET = random_password.auth.result + MICROSOFT_CLIENT_ID = azuread_application.portal.client_id + MICROSOFT_CLIENT_SECRET = azuread_application_password.portal.value + MICROSOFT_TENANT_ID = data.azuread_client_config.current.tenant_id + } + + field_manager = "terraform-acm-member-portal" + force = true +} diff --git a/kubernetes/terraform/stacks/acm-member-portal/backend.tf b/kubernetes/terraform/stacks/acm-member-portal/backend.tf new file mode 100644 index 00000000..6a218129 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "acm-hybridcloud" + storage_account_name = "acmhybridstore" + container_name = "acm-terraform-state" + key = "acm-member-portal.tfstate" + } +} diff --git a/kubernetes/terraform/stacks/acm-member-portal/configuration/prod.tfvars b/kubernetes/terraform/stacks/acm-member-portal/configuration/prod.tfvars new file mode 100644 index 00000000..9463e0a1 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/configuration/prod.tfvars @@ -0,0 +1,16 @@ +deployment_env = "prod" + +redirect_uris = [ + "https://portal.acmuic.org/api/auth/callback/microsoft", +] + +logout_uris = [ + "https://portal.acmuic.org", +] + +kubernetes_namespace = "acm-portal" + +additional_owner_ids = [ + "21ec2c63-534d-4cfb-bc43-83773ce40e54", # terraform-svc + "920bf583-cb9b-4365-a61e-bb33988c5484" # clee231 +] diff --git a/kubernetes/terraform/stacks/acm-member-portal/outputs.tf b/kubernetes/terraform/stacks/acm-member-portal/outputs.tf new file mode 100644 index 00000000..44f2aae7 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/outputs.tf @@ -0,0 +1,15 @@ +output "application_id" { + value = azuread_application.portal.id +} + +output "client_id" { + value = azuread_application.portal.client_id +} + +output "service_principal_object_id" { + value = azuread_service_principal.portal.object_id +} + +output "secret_name" { + value = kubernetes_secret_v1.portal.metadata[0].name +} diff --git a/kubernetes/terraform/stacks/acm-member-portal/providers.tf b/kubernetes/terraform/stacks/acm-member-portal/providers.tf new file mode 100644 index 00000000..85303414 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/providers.tf @@ -0,0 +1,26 @@ +terraform { + required_version = "1.9.8" + required_providers { + azuread = { + source = "hashicorp/azuread" + version = "3.9.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "2.38.0" + } + random = { + source = "hashicorp/random" + version = "3.9.0" + } + time = { + source = "hashicorp/time" + version = "0.14.1" + } + } +} + +provider "azuread" {} + +# Auth via KUBE_CONFIG_PATH / KUBECONFIG (set on the maid-cafe runner). +provider "kubernetes" {} diff --git a/kubernetes/terraform/stacks/acm-member-portal/variables.tf b/kubernetes/terraform/stacks/acm-member-portal/variables.tf new file mode 100644 index 00000000..dd14b844 --- /dev/null +++ b/kubernetes/terraform/stacks/acm-member-portal/variables.tf @@ -0,0 +1,28 @@ +variable "deployment_env" { + type = string + description = "dev | prod. Used in the Entra app display name and client secret name." + default = "prod" +} + +variable "redirect_uris" { + type = list(string) + description = "OAuth2 redirect URIs registered on the Entra app." +} + +variable "logout_uris" { + type = list(string) + description = "Front-channel logout URIs." + default = [] +} + +variable "kubernetes_namespace" { + type = string + description = "Namespace for the portal-secrets Secret. Must already exist (Argo CD creates acm-portal)." + default = "acm-portal" +} + +variable "additional_owner_ids" { + type = list(string) + default = [] + description = "Extra Entra object IDs to set as app / service principal owners." +}