Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,13 @@ jobs:
- name: Terraform Format Check
run: terraform fmt -check -recursive terraform/

- name: Test Azure federation identity guards
if: matrix.cloud == 'azure'
run: |
terraform -chdir=iac/federation/azure-target/terraform init -backend=false -lockfile=readonly -input=false
terraform -chdir=iac/federation/azure-target/terraform fmt -check -recursive
terraform -chdir=iac/federation/azure-target/terraform test

- name: Terraform Init
run: |
cd terraform/environments/${{ matrix.cloud }}
Expand Down
7 changes: 5 additions & 2 deletions iac/federation/azure-target/terraform/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

233 changes: 233 additions & 0 deletions iac/federation/azure-target/terraform/tests/identity_guards.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
mock_provider "azuread" {}
mock_provider "http" {}
mock_provider "azurerm" {
mock_data "azurerm_subscription" {
defaults = {
id = "/subscriptions/11111111-1111-1111-1111-111111111111"
subscription_id = "11111111-1111-1111-1111-111111111111"
tenant_id = "22222222-2222-2222-2222-222222222222"
}
}
}

variables {
subscription_id = "11111111-1111-1111-1111-111111111111"
tenant_id = "22222222-2222-2222-2222-222222222222"
cudly_issuer_url = "https://cudly.example.com/oidc"
cudly_api_url = ""
}

run "defaults" {
command = plan
assert {
condition = (
azuread_application_federated_identity_credential.cudly.issuer == var.cudly_issuer_url &&
azuread_application_federated_identity_credential.cudly.subject == "cudly-controller" &&
azuread_application_federated_identity_credential.cudly.audiences == tolist(["api://AzureADTokenExchange"])
)
error_message = "Default identity must be preserved exactly."
}
}

run "literal_claims" {
command = plan
variables {
cudly_issuer_url = "https://CUDly.example.com:8443/a_b/~v1%20/oidc"
cudly_federated_subject = "quote\"backslash\\backtick`apostrophe':é"
cudly_federated_audience = "quote\"backslash\\backtick`apostrophe':é"
}
assert {
condition = (
azuread_application_federated_identity_credential.cudly.issuer == var.cudly_issuer_url &&
azuread_application_federated_identity_credential.cudly.subject == var.cudly_federated_subject &&
azuread_application_federated_identity_credential.cudly.audiences == tolist([var.cudly_federated_audience])
)
error_message = "Literal identity bytes and singleton audience must be preserved."
}
}

run "invalid_claim_0" {
command = plan
variables {
cudly_federated_subject = null
cudly_federated_audience = null
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_1" {
command = plan
variables {
cudly_federated_subject = ""
cudly_federated_audience = ""
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_2" {
command = plan
variables {
cudly_federated_subject = "a b"
cudly_federated_audience = "a b"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_3" {
command = plan
variables {
cudly_federated_subject = "a\tb"
cudly_federated_audience = "a\tb"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_4" {
command = plan
variables {
cudly_federated_subject = "a\nb"
cudly_federated_audience = "a\nb"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_5" {
command = plan
variables {
cudly_federated_subject = "a\rb"
cudly_federated_audience = "a\rb"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_6" {
command = plan
variables {
cudly_federated_subject = "a\u000bb"
cudly_federated_audience = "a\u000bb"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_7" {
command = plan
variables {
cudly_federated_subject = "a\u000cb"
cudly_federated_audience = "a\u000cb"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_8" {
command = plan
variables {
cudly_federated_subject = "a$b"
cudly_federated_audience = "a$b"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_claim_9" {
command = plan
variables {
cudly_federated_subject = "a*b"
cudly_federated_audience = "a*b"
}
expect_failures = [var.cudly_federated_subject, var.cudly_federated_audience]
}

run "invalid_issuer_0" {
command = plan
variables {
cudly_issuer_url = null
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_1" {
command = plan
variables {
cudly_issuer_url = ""
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_2" {
command = plan
variables {
cudly_issuer_url = "http://example.com/oidc"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_3" {
command = plan
variables {
cudly_issuer_url = "https://"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_4" {
command = plan
variables {
cudly_issuer_url = "https://example.com/oidc/"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_5" {
command = plan
variables {
cudly_issuer_url = "https://user@example.com/oidc"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_6" {
command = plan
variables {
cudly_issuer_url = "https://example.com/?q=1"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_7" {
command = plan
variables {
cudly_issuer_url = "https://example.com/#f"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_8" {
command = plan
variables {
cudly_issuer_url = "https://example.com/a b"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_9" {
command = plan
variables {
cudly_issuer_url = "https://example.com/a\nb"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_10" {
command = plan
variables {
cudly_issuer_url = "https://example.com/a\"b"
}
expect_failures = [var.cudly_issuer_url]
}

run "invalid_issuer_11" {
command = plan
variables {
cudly_issuer_url = "https://example.com/a\\b"
}
expect_failures = [var.cudly_issuer_url]
}
19 changes: 19 additions & 0 deletions iac/federation/azure-target/terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,37 @@ variable "app_display_name" {
variable "cudly_issuer_url" {
description = "CUDly OIDC issuer URL (e.g. https://cudly.example.com/oidc). Azure AD fetches JWKS from this issuer to verify client assertion JWTs."
type = string

# CUDly contract, mirrored by the two azure-wif shell templates.
validation {
condition = var.cudly_issuer_url == null ? false : (
can(regex("^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~%/-]*)?$", var.cudly_issuer_url)) &&
!endswith(var.cudly_issuer_url, "/")
)
error_message = "Use a CUDly HTTPS issuer URL without a trailing slash, query, fragment or userinfo."
}
}

variable "cudly_federated_subject" {
description = "Subject claim in the client assertion JWT. Must match what CUDly signs."
type = string
default = "cudly-controller"

validation {
condition = var.cudly_federated_subject == null ? false : can(regex("^[^ \\t\\n\\r\\x0b\\f$*]+$", var.cudly_federated_subject))
error_message = "Subject must be nonempty without ASCII whitespace, dollar signs or asterisks."
}
}

variable "cudly_federated_audience" {
description = "Audience for the federated identity credential."
type = string
default = "api://AzureADTokenExchange"

validation {
condition = var.cudly_federated_audience == null ? false : can(regex("^[^ \\t\\n\\r\\x0b\\f$*]+$", var.cudly_federated_audience))
error_message = "Audience must be nonempty without ASCII whitespace, dollar signs or asterisks."
}
}

variable "cudly_api_url" {
Expand Down
44 changes: 25 additions & 19 deletions internal/iacfiles/templates/azure-wif-cli.sh.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# Usage:
# bash {{.AccountSlug}}-azure-wif-cli.sh
#
# Requirements: az cli logged in to the target subscription.
# Requirements: jq and az cli logged in to the target subscription.
#
# This script configures true Azure Workload Identity Federation —
# no certificate, no private key, no client secret is created or stored.
Expand All @@ -22,11 +22,24 @@ SUBSCRIPTION_ID="${SUBSCRIPTION_ID:-{{.SubscriptionID}}}"
# /.well-known/openid-configuration when resolving the discovery doc,
# so do NOT include the well-known suffix here. Trailing slashes and
# http/https matter for Azure AD validation (AADSTS700213 if mismatched).
CUDLY_ISSUER_URL="${CUDLY_ISSUER_URL:-{{.CUDlyAPIURL}}/oidc}"
CUDLY_FEDERATED_SUBJECT="${CUDLY_FEDERATED_SUBJECT:-cudly-controller}"
CUDLY_FEDERATED_AUDIENCE="${CUDLY_FEDERATED_AUDIENCE:-api://AzureADTokenExchange}"
CUDLY_ISSUER_URL="${CUDLY_ISSUER_URL-{{.CUDlyAPIURL}}/oidc}"
CUDLY_FEDERATED_SUBJECT="${CUDLY_FEDERATED_SUBJECT-cudly-controller}"
CUDLY_FEDERATED_AUDIENCE="${CUDLY_FEDERATED_AUDIENCE-api://AzureADTokenExchange}"

: "${CUDLY_ISSUER_URL:?set CUDLY_ISSUER_URL to the OIDC issuer URL of your CUDly deployment (base URL + /oidc)}"
# Keep this CUDly input contract aligned with azure-target/terraform/variables.tf.
ISSUER_PATTERN='^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~%/-]*)?$'
if [[ ! "${CUDLY_ISSUER_URL}" =~ $ISSUER_PATTERN || "${CUDLY_ISSUER_URL}" == */ ]]; then
echo "ERROR: CUDLY_ISSUER_URL must be a CUDly HTTPS issuer URL without a trailing slash." >&2
exit 1
fi
for CLAIM_NAME in CUDLY_FEDERATED_SUBJECT CUDLY_FEDERATED_AUDIENCE; do
case "${!CLAIM_NAME}" in
''|*[$' \t\n\r\v\f']*|*'$'*|*'*'*)
echo "ERROR: ${CLAIM_NAME} must be nonempty without ASCII whitespace, dollar signs or asterisks." >&2
exit 1 ;;
esac
done
command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required." >&2; exit 1; }

if [[ -n "${SUBSCRIPTION_ID}" && "${SUBSCRIPTION_ID}" != "<SUBSCRIPTION_ID>" ]]; then
az account set --subscription "${SUBSCRIPTION_ID}"
Expand All @@ -41,23 +54,16 @@ echo "Creating service principal..."
SP_OBJECT_ID=$(az ad sp create --id "${APP_ID}" --query id --output tsv)

echo "Adding federated identity credential bound to CUDly OIDC issuer..."
# Idempotent: if the same name exists (re-run of the script), az throws a
# conflict error — catch and continue. A subsequent run with the same
# script is safe.
FEDCRED_PARAMS=$(cat <<JSON
{
"name": "cudly",
"issuer": "${CUDLY_ISSUER_URL}",
"subject": "${CUDLY_FEDERATED_SUBJECT}",
"audiences": ["${CUDLY_FEDERATED_AUDIENCE}"],
"description": "CUDly signs JWTs via its own OIDC issuer (KMS-backed). No secret is stored on the CUDly side."
}
JSON
)
FEDCRED_PARAMS=$(jq -n \
--arg issuer "${CUDLY_ISSUER_URL}" \
--arg subject "${CUDLY_FEDERATED_SUBJECT}" \
--arg audience "${CUDLY_FEDERATED_AUDIENCE}" \
'{name: "cudly", issuer: $issuer, subject: $subject, audiences: [$audience],
description: "CUDly signs JWTs via its own OIDC issuer (KMS-backed). No secret is stored on the CUDly side."}')
az ad app federated-credential create \
--id "${APP_ID}" \
--parameters "${FEDCRED_PARAMS}" \
--output none 2>&1 || echo " (federated credential may already exist — continuing)"
--output none

echo ""
echo "=== Done ==="
Expand Down
Loading
Loading