Aggregates every Azure Pipelines approval & check — manual approval, branch control, business hours, exclusive lock, required template, and Invoke REST API — behind one project-scoped boundary, attaching them to any protected resource (environment, service connection, agent queue, repository, secure file, or variable group). Each check type is an independently-optional
for_eachcollection. Built for azuredevops v1.x.
This module declares pipeline approvals & checks that gate when a YAML pipeline stage may consume a protected resource. It manages all six provider check_* resource types as peer collections:
- ✅ Approval (
azuredevops_check_approval) — manual approval by named approvers before a stage runs. - 🌿 Branch control (
azuredevops_check_branch_control) — restrict which branches may use the resource; optionally require branch protection. - 🕒 Business hours (
azuredevops_check_business_hours) — only pass inside a configured time-of-day window on selected days. - 🔒 Exclusive lock (
azuredevops_check_exclusive_lock) — serialize access so only one run uses the resource at a time. - 📐 Required template (
azuredevops_check_required_template) — force pipelines to extend an approved YAML template. - 🌐 REST API (
azuredevops_check_rest_api) — call an external endpoint and gate the run on the response.
💡 Why it matters: checks are configured by the resource owner, not in the pipeline YAML — so a pipeline author cannot remove the gate. This module makes that governance reproducible: production environments, ARM service connections, and secret variable groups get the same enforced approval/branch/time controls on every
apply.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
Checks are cross-cutting: they consume a project_id from the project module and a target_resource_id from whichever module owns the protected resource.
flowchart TD
project["terraform-azuredevops-project<br/>(project_id)"]
env["terraform-azuredevops-environment"]
se["terraform-azuredevops-serviceendpoint-azure"]
vg["terraform-azuredevops-variable-group"]
pool["terraform-azuredevops-agent-pool"]
repo["terraform-azuredevops-git-repository"]
checks["terraform-azuredevops-pipeline-checks"]
project -->|project_id| checks
env -->|target_resource_id<br/>type=environment| checks
se -->|target_resource_id<br/>type=endpoint| checks
vg -->|target_resource_id<br/>type=variablegroup| checks
pool -->|target_resource_id<br/>type=queue| checks
repo -->|target_resource_id<br/>type=repository| checks
style checks fill:#8957E5,color:#fff
style project fill:#0078D4,color:#fff
This is an aggregation module — there is no primary this resource. Every check type is a sibling for_each collection, each fed the shared project_id and a resolved target. Enable any subset; unused collections default to {} and create nothing.
flowchart TD
inputs["project_id<br/>+ target_resource_id / type<br/>(per-entry override)"]
approval["azuredevops_check_approval<br/>.approval { for_each }"]
branch["azuredevops_check_branch_control<br/>.branch_control { for_each }"]
hours["azuredevops_check_business_hours<br/>.business_hours { for_each }"]
lock["azuredevops_check_exclusive_lock<br/>.exclusive_lock { for_each }"]
template["azuredevops_check_required_template<br/>.required_template { for_each }"]
rest["azuredevops_check_rest_api<br/>.rest_api { for_each }"]
inputs --> approval
inputs --> branch
inputs --> hours
inputs --> lock
inputs --> template
inputs --> rest
style approval fill:#8957E5,color:#fff
style branch fill:#8957E5,color:#fff
style hours fill:#8957E5,color:#fff
style lock fill:#8957E5,color:#fff
style template fill:#8957E5,color:#fff
style rest fill:#8957E5,color:#fff
Resource inventory (6 types, all for_each):
azuredevops_check_approval.approvalazuredevops_check_branch_control.branch_controlazuredevops_check_business_hours.business_hoursazuredevops_check_exclusive_lock.exclusive_lockazuredevops_check_required_template.required_templateazuredevops_check_rest_api.rest_api
terraform {
required_version = ">= 1.12.0"
required_providers {
azuredevops = {
source = "microsoft/azuredevops"
version = ">= 1.0, < 2.0"
}
}
}ℹ️ The module declares the provider requirement only — it configures no
provider {}block. The root/spec supplies the org URL + PAT or Azure AD service principal.
Managing approvals & checks goes through the Approvals and Checks (Check Configurations) REST API, gated by the Pipeline Resources PAT scope. The running identity must also hold the Administrator role on the protected resource it attaches checks to.
| Scope / Role | PAT scope | Service-principal role | Required for |
|---|---|---|---|
| Manage checks (create / update / delete) | Pipeline Resources — Use & Manage (vso.pipelineresources_manage) |
Administrator on the target resource type (Endpoint / Environment / Agent Pool / Library / Repository Administrators) | every check_* collection |
| Read / evaluate checks | Pipeline Resources — Use |
User on the target resource | drift detection on terraform plan |
| Open a resource to all pipelines | — | Project Administrators | only if the protected resource is opened project-wide (out of scope here) |
| Org-level resource administration | — | Project Collection Administrators | org-scoped agent pools used as targets |
⚠️ Creating checks requires the resource-type Administrator role (e.g. Endpoint Administrators, Environment Administrators), and org-wide changes require Project Collection Administrators. A plain Build Administrator or Contributor PAT will return403from the check-configuration API.
terraform-azuredevops-pipeline-checks/
├── providers.tf # required_version + azuredevops provider pin (no provider block)
├── variables.tf # project_id, shared target refs, 6 typed for_each collection maps
├── main.tf # 6 for_each check resources named by role (no `this`)
├── outputs.tf # per-role *_ids maps + rest_api_versions + flattened ids
├── SCOPE.md # cross-module contract + required scopes/auth + gotchas
└── README.md # this file
Smallest working call — a manual approval on a production environment, with project_id wired from the project module:
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
approval = {
prod_gate = {
approvers = [module.platform_group.id]
instructions = "Approve production deployment after change review."
}
}
}| Input | Type | Source module |
|---|---|---|
project_id |
string |
terraform-azuredevops-project |
target_resource_id |
string |
the protected-resource module (environment / serviceendpoint_* / agent_pool / variable_group / git_repository) |
| Output | Description | Consumed by |
|---|---|---|
approval_ids |
Map of approval check IDs keyed by collection key | audit / downstream |
branch_control_ids |
Map of branch control check IDs | audit / downstream |
business_hours_ids |
Map of business hours check IDs | audit / downstream |
exclusive_lock_ids |
Map of exclusive lock check IDs | audit / downstream |
required_template_ids |
Map of required template check IDs | audit / downstream |
rest_api_ids |
Map of REST API check IDs | audit / downstream |
rest_api_versions |
Map of REST API check versions | drift inspection |
ids |
Flattened map of all check IDs, keyed <role>/<collection key> |
access review / audit |
- Project-scoped, cross-cutting. Every
check_*resource takesproject_id, but the target it protects is owned by another module. One module instance can protect several resources becausetarget_resource_id/target_resource_typeare overridable per collection entry (the module-level values are defaults). - Target types.
endpoint,environment,queue,repository,securefile,variablegroup. For repository targets the provider expects the composite ID"<project_id>.<repository_id>". - YAML pipelines only. Approvals and checks apply to YAML pipelines; classic pipelines do not honor them.
- Evaluation order (per Azure Pipelines). Categories run sequentially, in parallel within a category: ① static checks (branch control, required template) → ② pre-dynamic approval → ③ dynamic checks (approval, business hours, REST API, Invoke Function) → ④ post-dynamic approval → ⑤ exclusive lock. If a category fails, later categories don't run.
- Immutable fields.
project_id,target_resource_id, andtarget_resource_typeare immutable on every check — changing any forces destroy/recreate. - No Terraform
timeoutsblock. These resources expose no operation-timeouts block; thetimeoutfield is a check resource attribute in minutes (approval/exclusive-lock default43200= 30 days; branch control / business hours / REST API default1440= 1 day). - Branch refs must be fully qualified —
refs/heads/<branch>(e.g.refs/heads/main, refs/heads/releases/*). - REST API secrets are write-context only. Don't embed credentials in
headers/body; surface them through a linked variable group viavariable_group_name.success_criteriaapplies only whencompletion_event = ApiResponse;retry_intervalis irrelevant forCallback. - Eventual consistency. A check can briefly lag visibility on the protected resource after create; re-running
planusually settles it.
1 · Manual approval (single approver)
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
approval = {
prod_gate = {
approvers = [module.platform_group.id]
instructions = "Approve production deployment."
}
}
}2 · Multi-approver with minimum count and self-approval blocked
approval = {
prod_gate = {
approvers = [module.platform_group.id, module.security_group.id]
minimum_required_approvers = 2
requester_can_approve = false
instructions = "Two approvers required; requester may not self-approve."
timeout = 4320 # 3 days, in minutes
}
}3 · Branch control — restrict to main only
branch_control = {
main_only = {
display_name = "Deploy from main only"
allowed_branches = "refs/heads/main"
}
}4 · Branch control — require branch protection on release branches
branch_control = {
protected_releases = {
display_name = "Protected release branches"
allowed_branches = "refs/heads/main, refs/heads/releases/*"
verify_branch_protection = true
ignore_unknown_protection_status = false
}
}5 · Business hours — weekday daytime window
business_hours = {
weekdays = {
display_name = "Business hours only"
start_time = "07:00"
end_time = "18:00"
time_zone = "UTC"
monday = true
tuesday = true
wednesday = true
thursday = true
friday = true
}
}6 · Exclusive lock — serialize deployments
exclusive_lock = {
serialize = {
timeout = 43200 # 30 days, in minutes
}
}7 · Required template (Azure Git)
required_template = {
enforce_prod_template = {
required_templates = [
{
repository_type = "azuregit"
repository_name = "DevOpsInfrastructure"
repository_ref = "refs/heads/main"
template_path = "templates/production.yml"
}
]
}
}8 · Required template (GitHub Enterprise + multiple templates)
required_template = {
enforce = {
required_templates = [
{
repository_type = "githubenterprise"
repository_name = "org/pipeline-templates"
repository_ref = "refs/heads/main"
template_path = "secure/build.yml"
},
{
repository_type = "azuregit"
repository_name = "DevOpsInfrastructure"
repository_ref = "refs/heads/main"
template_path = "templates/deploy.yml"
}
]
}
}9 · REST API check — gate on an external API response
rest_api = {
cmdb_gate = {
display_name = "CMDB change-window check"
connected_service_name_selector = "connectedServiceName"
connected_service_name = module.generic_endpoint.service_endpoint_name
method = "POST"
headers = jsonencode({ contentType = "application/json" })
body = jsonencode({ env = "prod" })
completion_event = "ApiResponse"
success_criteria = "eq(root['status'], 'approved')"
retry_interval = 5
url_suffix = "changes/validate"
}
}10 · REST API check — async Callback completion
rest_api = {
async_gate = {
display_name = "Async approval webhook"
connected_service_name_selector = "connectedServiceNameARM"
connected_service_name = module.serviceendpoint_azure.service_endpoint_name
method = "POST"
completion_event = "Callback" # service calls back when done
url_suffix = "gates/start"
}
}11 · Protect a service connection (project-wired)
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
target_resource_id = module.serviceendpoint_azure.service_endpoint_id
target_resource_type = "endpoint"
branch_control = {
prod_branch = {
display_name = "Prod connection from main"
allowed_branches = "refs/heads/main"
}
}
approval = {
prod_conn = { approvers = [module.platform_group.id] }
}
}12 · Protect a variable group
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
target_resource_id = module.prod_variable_group.variable_group_id
target_resource_type = "variablegroup"
approval = { vg_gate = { approvers = [module.security_group.id] } }
exclusive_lock = { serialize = {} }
}13 · Protect a repository (composite target ID)
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
target_resource_id = "${module.project.project_id}.${module.repo.repository_id}"
target_resource_type = "repository"
required_template = {
enforce = {
required_templates = [{
repository_type = "azuregit"
repository_name = "DevOpsInfrastructure"
repository_ref = "refs/heads/main"
template_path = "templates/ci.yml"
}]
}
}
}14 · Per-entry target override (one module, multiple resources)
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
# no module-level target — each entry sets its own
approval = {
env_gate = {
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
approvers = [module.platform_group.id]
}
conn_gate = {
target_resource_id = module.serviceendpoint_azure.service_endpoint_id
target_resource_type = "endpoint"
approvers = [module.security_group.id]
}
}
}15 · 🎯 End-to-end composition (mandatory finale)
module "project" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-project?ref=v1.0.0"
name = "Payments-Platform"
}
module "platform_group" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-group?ref=v1.0.0"
project_id = module.project.project_id
display_name = "Platform Approvers"
}
module "prod_environment" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-environment?ref=v1.0.0"
project_id = module.project.project_id
name = "production"
}
module "serviceendpoint_azure" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-serviceendpoint-azure?ref=v1.0.0"
project_id = module.project.project_id
#... ARM connection config...
}
# Gate BOTH the environment and the ARM connection from one checks module
module "pipeline_checks" {
source = "git::https://github.com/microsoftexpert/terraform-azuredevops-pipeline-checks?ref=v1.0.0"
project_id = module.project.project_id
approval = {
prod_env = {
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
approvers = [module.platform_group.id]
minimum_required_approvers = 1
instructions = "Approve production deployment."
}
}
branch_control = {
prod_conn = {
target_resource_id = module.serviceendpoint_azure.service_endpoint_id
target_resource_type = "endpoint"
display_name = "Prod ARM connection from main"
allowed_branches = "refs/heads/main"
}
}
business_hours = {
prod_env_window = {
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
display_name = "Deploy during business hours"
start_time = "08:00"
end_time = "17:00"
time_zone = "UTC"
monday = true
tuesday = true
wednesday = true
thursday = true
friday = true
}
}
exclusive_lock = {
prod_env_serial = {
target_resource_id = module.prod_environment.id
target_resource_type = "environment"
}
}
}
output "all_check_ids" {
value = module.pipeline_checks.ids
}Full input schemas
Top-level
| Name | Type | Default | Description |
|---|---|---|---|
project_id |
string |
— (required) | Owning project ID. Immutable on every check. |
target_resource_id |
string |
null |
Default protected-resource ID; overridable per entry. |
target_resource_type |
string |
null |
Default target type: endpoint/environment/queue/repository/securefile/variablegroup. |
approval — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
approvers = list(string) # required
instructions = optional(string)
minimum_required_approvers = optional(number)
requester_can_approve = optional(bool, false)
timeout = optional(number, 43200)
}branch_control — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
display_name = string # required
allowed_branches = optional(string, "*")
verify_branch_protection = optional(bool, false)
ignore_unknown_protection_status = optional(bool, false)
timeout = optional(number, 1440)
}business_hours — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
display_name = string # required
start_time = string # required "HH:MM"
end_time = string # required "HH:MM"
time_zone = string # required
monday..sunday = optional(bool, false)
timeout = optional(number, 1440)
}exclusive_lock — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
timeout = optional(number, 43200)
}required_template — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
required_templates = list(object({ # required, ≥1
template_path = string
repository_name = string
repository_ref = string
repository_type = optional(string, "azuregit") # azuregit|github|githubenterprise|bitbucket
}))
}rest_api — map(object({…})), default {}
{
target_resource_id = optional(string)
target_resource_type = optional(string)
display_name = string # required
connected_service_name_selector = string # required: connectedServiceName|connectedServiceNameARM
connected_service_name = string # required
method = string # required: OPTIONS|GET|HEAD|POST|PUT|DELETE|TRACE|PATCH
body = optional(string)
headers = optional(string)
retry_interval = optional(number)
success_criteria = optional(string)
url_suffix = optional(string)
variable_group_name = optional(string)
completion_event = optional(string, "Callback") # Callback|ApiResponse
timeout = optional(number, 1440)
}| Output | Type | Sensitive | Description |
|---|---|---|---|
approval_ids |
map(string) |
no | Approval check IDs keyed by collection key |
branch_control_ids |
map(string) |
no | Branch control check IDs |
business_hours_ids |
map(string) |
no | Business hours check IDs |
exclusive_lock_ids |
map(string) |
no | Exclusive lock check IDs |
required_template_ids |
map(string) |
no | Required template check IDs |
rest_api_ids |
map(string) |
no | REST API check IDs |
rest_api_versions |
map(string) |
no | REST API check versions |
ids |
map(string) |
no | Flattened all-check IDs keyed <role>/<key> |
ℹ️ No outputs are sensitive — check IDs and versions are not secrets. Secrets used by a REST API check live in a linked variable group, not in this module's state.
- Aggregation, not composite — no dominant resource, so there is no
this; every type is named by role. - Independently optional — each collection defaults to
{}; use any, all, or none. - Type is the contract — deeply-typed
map(object(...))withoptionaldefaults andvalidation {}for every closed value set (target types, HTTP method, selector, completion event, repository type,HH:MMformat). - Secure defaults —
requester_can_approve = false; provider-aligned timeouts; secrets steered to variable groups. - Total renderer —
main.tfis a pure projection; the onlydynamicblock is the repeatingrequired_template.
cd C:\GitHubCode\newazuredevopsmodules\terraform-azuredevops-pipeline-checks
terraform init -backend=false
terraform validate
terraform fmt -check
⚠️ terraform plan/applyrequire live org credentials (org URL + PAT with Pipeline Resources (Use & Manage), or an Azure AD service principal with resource-type Administrator). The offline gate above is sufficient for structural correctness. Never test against the production org — use a dedicated non-production organization.
- ✅
terraform init -backend=false— succeeds. - ✅
terraform validate— Success! The configuration is valid. - ✅
terraform fmt -check— no diff. - 🔁 Post-apply (non-prod org, optional): confirm checks via the Approvals and Checks REST API or the Approvals and checks tab on the protected resource.
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Outputs:
all_check_ids = {
"approval/prod_env" = "12"
"branch_control/prod_conn" = "13"
"business_hours/prod_env_window" = "14"
"exclusive_lock/prod_env_serial" = "15"
}
| Symptom | Likely cause | Fix |
|---|---|---|
403 Forbidden / TF400813 from the check-configuration API |
PAT missing Pipeline Resources (Use & Manage), or identity is not Administrator on the target resource | Re-issue PAT with the scope; grant resource-type Administrator (Endpoint/Environment/etc.). |
The resource with id... does not exist |
Wrong target_resource_id, or org-vs-project mismatch (org-scoped agent pool vs project queue) |
Wire the ID from the owning module's output; use the queue ID (project-scoped), not the org pool ID. |
| Repository check fails to find target | target_resource_id not in composite form |
Use "<project_id>.<repository_id>" with target_resource_type = "repository". |
| Branch control never matches | Branch names not fully qualified | Use refs/heads/<branch> (comma-separated), not short names. |
| REST API check stuck pending | completion_event = "Callback" but the service never calls back |
Use ApiResponse + success_criteria, or ensure the callback is wired. |
| Check created but not enforced on runs | Classic pipeline, or check just created (eventual consistency) | Use a YAML pipeline; re-run plan to settle visibility. |
coalesce error: "all arguments null" |
Neither entry nor module-level target set | Set target_resource_id on the entry or the module. |
- Define approvals and checks
- Resource security — protected resources
- Approvals and Checks REST API
- Terraform provider:
azuredevops_check_approval SCOPE.md(this module) — cross-module contract & required scopes
💙 "Infrastructure as Code should be standardized, consistent, and secure."