Skip to content
Open
48 changes: 48 additions & 0 deletions modules/azure-mi/.terraform-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
formatter: "markdown"

version: ""

header-from: docs/header.md
footer-from: docs/footer.md

recursive:
enabled: false
path: modules
include-main: true

sections:
hide: []
show: []

content: ""

output:
file: "README.md"
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->

output-values:
enabled: false
from: ""

sort:
enabled: true
by: name

settings:
anchor: true
color: true
default: true
description: false
escape: true
hide-empty: false
html: true
indent: 2
lockfile: true
read-comments: true
required: true
sensitive: true
type: true
239 changes: 137 additions & 102 deletions modules/azure-mi/README.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions modules/azure-mi/_examples/basic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Basic example — `azure-mi`

Creates one **user-assigned managed identity** with no RBAC assignments, no federated credentials, and no Key Vault access policies.

Set `resource_group` to the name of an existing resource group in your subscription, and set `location` and `name` to valid values. Configure the **`azurerm`** provider before `terraform apply`.
38 changes: 38 additions & 0 deletions modules/azure-mi/_examples/basic/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Replace resource_group, location, and name with values valid in your subscription.

terraform {
required_version = ">= 1.7.0"

required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.16.0"
}
}
}

provider "azurerm" {
features {}
}

module "managed_identity" {
source = "../.."

name = "uami-basicex01"
resource_group = "example-rg"
location = "westeurope"

tags_from_rg = false
tags = {
example = "basic"
}

rbac = []
federated_credentials = []
access_policies = []
}

output "principal_id" {
description = "Object ID of the managed identity (common input for RBAC elsewhere)."
value = module.managed_identity.principal_id
}
13 changes: 13 additions & 0 deletions modules/azure-mi/_examples/comprehensive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Comprehensive reference — `azure-mi`

- [`module.reference.hcl`](./module.reference.hcl) — Illustrative module block with **RBAC**, **federated credentials** (GitHub, Kubernetes, OIDC), and optional **Key Vault access policies**.
- [`values.reference.yaml`](./values.reference.yaml) — Illustrative values for most module inputs (same key names as the module variables).

It is **not** consumed by Terraform automatically. Typical uses:

- Copy fragments into `.tfvars` or HCL `module` arguments.
- Or load with `yamldecode(file(...))` in a root module and **map each field** to the `azure-mi` module arguments (Terraform does not support splatting a map directly into a `module` block).

Replace subscription IDs, scopes, vault IDs, and GitHub org/repo with real values before apply.

**Convention:** keep large YAML samples in `_examples/**` instead of embedding them in the terraform-docs `README.md` body.
55 changes: 55 additions & 0 deletions modules/azure-mi/_examples/comprehensive/module.reference.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Reference-only: replace scopes, vault IDs, and GitHub coordinates with real values.

module "managed_identity" {
source = "../.."

name = "uami-myapp-ref"
resource_group = "my-resource-group"
location = "westeurope"

tags_from_rg = true
tags = {}

rbac = [
{
name = "rg-reader"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group"
roles = ["Reader"]
},
]

audience = ["api://AzureADTokenExchange"]

federated_credentials = [
{
name = "github-main"
type = "github"
organization = "my-org"
repository = "my-repo"
entity = "ref:refs/heads/main"
},
{
name = "k8s-workload"
type = "kubernetes"
issuer = "https://oidc.prod1.azure.com/00000000-0000-0000-0000-000000000000/00000000-0000-0000-0000-000000000000/v2.0"
namespace = "default"
service_account_name = "my-sa"
},
{
name = "custom-oidc"
type = "other"
issuer = "https://example.com"
subject = "my-subject"
},
]

access_policies = [
{
key_vault_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-vault"
key_permissions = ["Get", "List"]
secret_permissions = ["Get", "List"]
certificate_permissions = []
storage_permissions = []
},
]
}
38 changes: 38 additions & 0 deletions modules/azure-mi/_examples/comprehensive/values.reference.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Reference YAML aligned with module input names (use `yamldecode(file(...))` and map each field explicitly to module arguments).
# Replace subscription IDs, scopes, vault IDs, and GitHub org/repo with real values.

name: "uami-myapp-ref"
resource_group: "my-resource-group"
location: "westeurope"
tags_from_rg: true
tags: {}
audience:
- "api://AzureADTokenExchange"
rbac:
- name: "rg-reader"
scope: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group"
roles:
- "Reader"
federated_credentials:
- name: "github-main"
type: "github"
organization: "my-org"
repository: "my-repo"
entity: "ref:refs/heads/main"
- name: "k8s-workload"
type: "kubernetes"
issuer: "https://oidc.prod1.azure.com/00000000-0000-0000-0000-000000000000/00000000-0000-0000-0000-000000000000/v2.0"
namespace: "default"
service_account_name: "my-sa"
- name: "custom-oidc"
type: "other"
issuer: "https://example.com"
subject: "my-subject"
Comment thread
pablosanchezpaz marked this conversation as resolved.
# `access_policies` only works for Key Vaults using the legacy access-policy permission model.
# Do not use this block with vaults that have `enable_rbac_authorization = true`, or provider errors will occur.
access_policies:
- key_vault_id: "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-vault"
key_permissions: ["Get", "List"]
secret_permissions: ["Get", "List"]
certificate_permissions: []
storage_permissions: []
21 changes: 21 additions & 0 deletions modules/azure-mi/docs/footer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Examples

For detailed examples, refer to the [module examples](https://github.com/prefapp/tfm/tree/main/modules/azure-mi/_examples):

- [basic](https://github.com/prefapp/tfm/tree/main/modules/azure-mi/_examples/basic) — Minimal user-assigned identity with empty `rbac`, `federated_credentials`, and `access_policies` (see folder README).
- [comprehensive](https://github.com/prefapp/tfm/tree/main/modules/azure-mi/_examples/comprehensive) — Reference HCL and YAML for RBAC, federated GitHub/Kubernetes/OIDC credentials, and optional Key Vault access policies (`values.reference.yaml`; see folder README).

## Remote resources

Provider constraints for your workspace appear in the **Requirements** and **Providers** tables above. Resource documentation links below use the Terraform Registry **`latest`** path (see `versions.tf` for the module constraint, currently `~> 4.16.0`). Regenerate this README with `terraform-docs .` as described in [README.md generation](https://github.com/prefapp/tfm/blob/main/CONTRIBUTING.md#5-readmemd-generation).

- **Managed identities**: [https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/)
- **azurerm_user_assigned_identity**: [https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/user_assigned_identity)
- **azurerm_role_assignment**: [https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment)
- **azurerm_federated_identity_credential**: [https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/federated_identity_credential)
- **azurerm_key_vault_access_policy**: [https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_access_policy](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_access_policy)
- **Terraform AzureRM provider**: [https://registry.terraform.io/providers/hashicorp/azurerm/latest](https://registry.terraform.io/providers/hashicorp/azurerm/latest)

## Support

For issues, questions, or contributions related to this module, please visit the repository's issue tracker: [https://github.com/prefapp/tfm/issues](https://github.com/prefapp/tfm/issues)
90 changes: 90 additions & 0 deletions modules/azure-mi/docs/header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# **Azure User Assigned Managed Identity Terraform Module**

## Overview

This module creates an **Azure user-assigned managed identity** (`azurerm_user_assigned_identity`) in an existing resource group. It can attach **Azure RBAC role assignments** to arbitrary scopes, configure **federated identity credentials** for GitHub Actions, Kubernetes workload identity, or generic OIDC issuers, use **tags** from the resource group or from a `tags` map (not both at once), and grant **Key Vault access policies** for the identity on one or more vaults.

The module does **not** create the resource group, federated issuers, or Key Vaults; it wires the identity to resources you already manage.

## Key Features

- **User-assigned identity**: Name, location, and tags. When `tags_from_rg` is **true**, identity tags are **only** those on the resource group (`var.tags` is ignored). When **false**, tags come from `var.tags`.
- **RBAC**: `rbac` entries flatten to `azurerm_role_assignment` resources, but the current implementation keys them by assignment `name` + individual `role`. Reusing the same `name` with the same `role` on different `scope` values is therefore **not currently supported** and will cause a duplicate-key error; use distinct assignment names in that case.
- **Federated credentials**: `federated_credentials` entries share `audience`; each entry has `type` `github`, `kubernetes`, or `other` (validated). The variable marks nested fields optional, but **`main.tf` expects real values per type** or plan/apply can fail: **`github`** — set `organization`, `repository`, and `entity` (subject suffix, e.g. `ref:refs/heads/main`); `issuer` defaults to the GitHub Actions OIDC issuer if unset. **`kubernetes`** — set `issuer`, `namespace`, and `service_account_name`. **`other`** — set `issuer` and `subject`.
Comment thread
pablosanchezpaz marked this conversation as resolved.
- **Key Vault access policies**: Optional `access_policies` to grant the identity permissions on existing vaults by `key_vault_id`.
Comment thread
pablosanchezpaz marked this conversation as resolved.
- **Outputs**: Identity **`id`**, **`name`**, **`client_id`**, and **`principal_id`** for use in AKS, role assignments, or application configuration.

## Prerequisites

- Existing **resource group** (`resource_group`) and valid **Azure region** (`location`).
- **azurerm** provider configured for your subscription (`~> 4.16.0`; see `versions.tf`).
- For **federated credentials**, issuers and subjects must match your IdP or cluster configuration.
- For **role assignments**, the deploying principal needs permission to create assignments on each `scope`.

## Basic Usage

### Example (identity only, no RBAC or federated credentials)

```hcl
module "managed_identity" {
source = "git::https://github.com/prefapp/tfm.git//modules/azure-mi?ref=<version>"

name = "uami-myapp-dev"
resource_group = "my-resource-group"
location = "westeurope"
tags_from_rg = false
tags = {
environment = "dev"
}

rbac = []
federated_credentials = []
access_policies = []
}
```

### Example (RBAC role assignments)

```hcl
module "managed_identity_with_rbac" {
source = "git::https://github.com/prefapp/tfm.git//modules/azure-mi?ref=<version>"

name = "uami-myapp-reader"
resource_group = "my-resource-group"
location = "westeurope"
tags_from_rg = false
tags = {}

rbac = [
{
name = "rg-reader"
scope = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-resource-group"
roles = ["Reader"]
},
]

federated_credentials = []
access_policies = []
}
```

Replace `scope` and role names with values valid in your tenant. See the [comprehensive example](https://github.com/prefapp/tfm/tree/main/modules/azure-mi/_examples/comprehensive) for federated credential patterns.

## File structure

```
.
├── CHANGELOG.md
├── main.tf
├── outputs.tf
├── variables.tf
├── versions.tf
├── docs
│ ├── footer.md
│ └── header.md
├── _examples
│ ├── basic
│ └── comprehensive
├── README.md
└── .terraform-docs.yml
```
14 changes: 7 additions & 7 deletions modules/azure-mi/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ resource "azurerm_federated_identity_credential" "that" {

## https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault_access_policy
resource "azurerm_key_vault_access_policy" "access_policy" {
for_each = { for policy in var.access_policies : policy.key_vault_id => policy }
key_vault_id = each.value.key_vault_id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.this.principal_id
key_permissions = each.value.key_permissions
secret_permissions = each.value.secret_permissions
for_each = { for policy in var.access_policies : policy.key_vault_id => policy }
key_vault_id = each.value.key_vault_id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = azurerm_user_assigned_identity.this.principal_id
key_permissions = each.value.key_permissions
secret_permissions = each.value.secret_permissions
certificate_permissions = each.value.certificate_permissions
storage_permissions = each.value.storage_permissions
storage_permissions = each.value.storage_permissions
}
20 changes: 17 additions & 3 deletions modules/azure-mi/outputs.tf
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
## OUTPUTS SECTION
# User Assigned Identity
output "id" {
value = azurerm_user_assigned_identity.this.id
description = "The Azure resource ID of the user-assigned managed identity."
value = azurerm_user_assigned_identity.this.id
}

output "name" {
description = "The name of the user-assigned managed identity."
value = azurerm_user_assigned_identity.this.name
}

output "client_id" {
description = "The client ID (application ID) of the user-assigned managed identity."
value = azurerm_user_assigned_identity.this.client_id
}

output "principal_id" {
description = "The service principal object ID of the user-assigned managed identity (use for RBAC assignments referencing this identity)."
Comment thread
pablosanchezpaz marked this conversation as resolved.
value = azurerm_user_assigned_identity.this.principal_id
Comment thread
pablosanchezpaz marked this conversation as resolved.
Comment thread
pablosanchezpaz marked this conversation as resolved.
}
8 changes: 4 additions & 4 deletions modules/azure-mi/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ variable "rbac" {
variable "access_policies" {
description = "List of access policies for the Key Vault"
type = list(object({
key_vault_id = string
key_permissions = optional(list(string), [])
secret_permissions = optional(list(string), [])
key_vault_id = string
key_permissions = optional(list(string), [])
secret_permissions = optional(list(string), [])
certificate_permissions = optional(list(string), [])
storage_permissions = optional(list(string), [])
storage_permissions = optional(list(string), [])
}))
default = []
}
Expand Down