Flat Spell Technologies

Azure engineering · Continuous authorization to operate

How to build a Terraform or Bicep/ARM evidence pipeline for Azure cATO

Preserve intended configuration, approved execution, and observed deployed behavior as separate evidence layers. Use either Terraform or Bicep/ARM to make infrastructure changes reviewable, then verify what is running after the deployment.

1. Version the implementation and its evidence requirements

Use the Azure cATO workflow to define the control map, workload boundary, owners, and acceptance criteria. Keep IaC, policy rules, expected resources, runtime tests, and the evidence contract in reviewed version control. Pin the actual CLI/provider/module versions used by the pipeline and record them with the commit and approval.

Choose one management path for each policy resource. The Terraform and Bicep examples below are alternatives with equivalent names and rules; do not deploy both against the same resources. Built-in policies may evolve independently of your application commit, so capture requested/effective versions and the evaluated assignment configuration. A code commit alone does not identify the entire evaluated baseline.

Use your approved cloud authentication mechanism, such as a supported federated workload identity or managed identity. Verify its cloud and issuer support. Do not paste service-principal secrets into templates, CI logs, sample commands, or evidence. The examples below omit backend and authentication details because those must match your existing approved runner.

2A. Manage a test audit policy with Terraform

Save the rule from the Azure Policy guide as storage-public-network-rule.json beside main.tf. This configuration creates a subscription-level custom audit definition and assignment. enforce = false selects a nonenforcing rollout; policy evaluation can still run. It neither creates a private storage account nor changes an existing account’s network settings.

terraform {
  required_version = ">= 1.5, < 2.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

variable "subscription_id" {
  type = string
}

variable "azure_environment" {
  type    = string
  default = "usgovernment" # Use "public" for the approved commercial cloud.
}

provider "azurerm" {
  features {}
  subscription_id = var.subscription_id
  environment     = var.azure_environment
}

resource "azurerm_policy_definition" "storage_private" {
  name         = "cato-storage-private-audit"
  policy_type  = "Custom"
  mode         = "Indexed"
  display_name = "Audit storage public network access for the approved design"
  policy_rule  = file("${path.module}/storage-public-network-rule.json")
}

resource "azurerm_subscription_policy_assignment" "storage_private" {
  name                 = "cato-storage-private"
  subscription_id      = "/subscriptions/${var.subscription_id}"
  policy_definition_id = azurerm_policy_definition.storage_private.id
  enforce              = false
}

output "policy_assignment_id" {
  value = azurerm_subscription_policy_assignment.storage_private.id
}

The example follows the documented AzureRM 4.x assignment model; choose a reviewed supported release and commit .terraform.lock.hcl. The version range above is not an exact pin. Initialize with a reviewed lockfile and use -lockfile=readonly in routine CI. Review upgrades separately; avoid silently selecting a different provider during each evidence run.

Configure the approved remote backend with access controls, supported authentication, state locking, and recovery procedures. Treat state, saved plans, and terraform show -json outputs as sensitive: marking a value sensitive in output does not make state or plan JSON safe for public publication. Use an approved archive and publish only a reviewed sanitized derivative.

2B. Manage the equivalent policy with Bicep and ARM

Alternatively, save the same rule beside main.bicep. Bicep compiles this declaration to an ARM template. The nonenforcing assignment has no remediation identity because the rule uses Audit; Modify/deployIfNotExists would require additional identity and role configuration.

targetScope = 'subscription'

resource definition 'Microsoft.Authorization/policyDefinitions@2023-04-01' = {
  name: 'cato-storage-private-audit'
  properties: {
    policyType: 'Custom'
    mode: 'Indexed'
    displayName: 'Audit storage public network access for the approved design'
    policyRule: loadJsonContent('./storage-public-network-rule.json')
  }
}

resource assignment 'Microsoft.Authorization/policyAssignments@2024-04-01' = {
  name: 'cato-storage-private'
  properties: {
    policyDefinitionId: definition.id
    enforcementMode: 'DoNotEnforce'
  }
}

output policyAssignmentId string = assignment.id

Check supported API versions in the selected Azure cloud before deployment. Compile using a recorded Bicep version, preserve the generated ARM JSON and the approved parameters, and capture an Azure what-if operation. With ARM JSON directly, preserve the template, parameters, API versions, and deployment-mode semantics in the same way.

Do not assume removing an assignment from a Bicep/ARM incremental template deletes the previously deployed assignment. Document the actual lifecycle/deletion mechanism and verify effective assignments afterward. Terraform also manages only resources represented in its configured state; neither tool should be assumed to account for every inherited or out-of-band policy.

3. Check proposed changes without treating a preview as evidence of deployment

For Terraform, initialize the configured approved backend, validate, and create a saved plan. The following shell example assumes that backend, variables, authentication, and a reviewed dependency lockfile already exist. Its output directory must be new and private.

set -euo pipefail
umask 077
: "${EVIDENCE_PLAN_DIR:?Use a new private plan directory}"
mkdir "$EVIDENCE_PLAN_DIR"
terraform init -input=false -lockfile=readonly
terraform fmt -check -recursive
terraform validate

status=0
terraform plan -input=false -detailed-exitcode \
  -out="$EVIDENCE_PLAN_DIR/reviewed.tfplan" || status=$?
case "$status" in
  0) echo "Plan completed with no changes" ;;
  2) echo "Plan completed with proposed changes; review required" ;;
  *) echo "Plan failed" >&2; exit "$status" ;;
esac
terraform show -json "$EVIDENCE_PLAN_DIR/reviewed.tfplan" \
  > "$EVIDENCE_PLAN_DIR/plan-sensitive.json"

Exit code 2 is a successful plan that found changes, not a tool failure or a compliance pass. Run your reviewed security rules against the protected plan, including known-failing fixtures. Archive the exact reviewed plan before applying it through an approved stage. If state, variables, providers, or the target environment change, stop and replan/review rather than substituting a new unreviewed plan.

For Bicep, use subscription-scope validation and what-if for this example. Authenticate to the selected cloud first and choose an approved deployment metadata location for that cloud:

set -euo pipefail
umask 077
: "${EVIDENCE_PREVIEW_DIR:?Use a new private preview directory}"
: "${APPROVED_SUBSCRIPTION_ID:?Set the approved subscription}"
: "${APPROVED_DEPLOYMENT_LOCATION:?Set a cloud-supported location}"
mkdir "$EVIDENCE_PREVIEW_DIR"
az bicep build --file main.bicep \
  --outfile "$EVIDENCE_PREVIEW_DIR/template.json"
az deployment sub validate \
  --subscription "$APPROVED_SUBSCRIPTION_ID" \
  --location "$APPROVED_DEPLOYMENT_LOCATION" \
  --template-file "$EVIDENCE_PREVIEW_DIR/template.json" --output json \
  > "$EVIDENCE_PREVIEW_DIR/validation.json"
az deployment sub what-if \
  --subscription "$APPROVED_SUBSCRIPTION_ID" \
  --location "$APPROVED_DEPLOYMENT_LOCATION" \
  --template-file "$EVIDENCE_PREVIEW_DIR/template.json" \
  --no-pretty-print \
  > "$EVIDENCE_PREVIEW_DIR/what-if.json"

What-if predicts supported changes and can encounter unresolved expressions, short-circuited analysis, and other documented limitations. Capture and review diagnostics rather than translating an incomplete result into “no risk.” Syntax validation and a clean preview cannot establish runtime connectivity, log delivery, or full control implementation.

4. Join approved execution to observed configuration

After the required review, apply the saved Terraform plan or deploy the reviewed ARM template/parameters through the approved production stage. Record the commit, pipeline run, approver/change reference, target cloud/subscription/scope, tool versions, artifact digests, deployment start/end times, deployment/operation IDs where applicable, and actual outcomes. Preserve failed and partial deployments as evidence of what happened.

Export resource configuration and effective Policy assignments after execution. Use the Policy collection workflow to trigger supported evaluation, follow result pages, and check evaluation freshness and resource coverage. Then run the approved runtime test with synthetic data. For private storage access, verify the actual client’s private DNS/access route and the denied public/unapproved path.

A successful terraform apply or ARM deployment confirms execution of that deployment operation; it does not prove that required logs reached the sink or that every dependent service is private. A policy result can be older than the release. Keep intended, observed, and tested states as distinct fields rather than using deployment success to overwrite a finding.

5. Preserve a useful evidence manifest

Use an artifact index that links each control check to its scope and source. The following is an illustrative schema, not an observed release. Generate actual values at runtime; do not publish credentials, raw sensitive parameters, or unreviewed plan/state contents.

{
  "schema_version": "1.0",
  "run_id": "GENERATED_RUN_ID",
  "source_commit": "ACTUAL_COMMIT_SHA",
  "cloud": "SELECTED_CLOUD",
  "scope": "APPROVED_RESOURCE_SCOPE",
  "control_map_revision": "REVIEWED_CONTROL_MAP_REVISION",
  "change_approval": "APPROVED_CHANGE_REFERENCE",
  "tool_versions": {
    "iac": "RECORDED_VERSION",
    "azure_cli": "RECORDED_VERSION"
  },
  "collection_started_at": "ACTUAL_UTC_TIMESTAMP",
  "collection_completed_at": "ACTUAL_UTC_TIMESTAMP",
  "collection_status": "ACTUAL_SUCCESS_OR_FAILURE",
  "checks": [
    {
      "check_id": "storage-private-access",
      "supports_control": "APPLICABLE_CONTROL_IMPLEMENTATION_REFERENCE",
      "assignment_id": "ACTUAL_ASSIGNMENT_RESOURCE_ID",
      "definition_reference_id": "INITIATIVE_REFERENCE_OR_NOT_APPLICABLE",
      "evaluation_timestamp": "ACTUAL_POLICY_EVALUATION_TIMESTAMP",
      "coverage_status": "ACTUAL_COVERAGE_RESULT",
      "technical_state": "OBSERVED_STATE",
      "runtime_test_reference": "PROTECTED_TEST_ARTIFACT_REFERENCE",
      "exception_reference": "APPROVED_REFERENCE_OR_NONE",
      "review_disposition": "ACTUAL_REVIEW_DISPOSITION"
    }
  ],
  "artifacts": [
    {
      "path": "PROTECTED_ARTIFACT_REFERENCE",
      "sha256": "COMPUTED_DIGEST"
    }
  ]
}

Keep raw artifact references, collected-at timestamps, observed/evaluated-at timestamps, and reviewer dispositions separate. Write a completed manifest only after required retrieval and checks succeed. Protect archive access and deletion, apply the approved retention policy, and record the identity/run that generated the package. Where tamper resistance is required, evaluate supported immutable storage and provenance controls; a digest stored beside a mutable artifact is insufficient on its own.

6. Detect drift without concealing it

Run a scheduled Terraform plan with refresh enabled against the approved backend. A normal plan can show proposed changes to reconcile configuration and observed remote state. A -refresh-only plan proposes updating Terraform’s state/outputs to reflect remote changes; it does not propose restoring the intended configuration. Preserve and review the drift result before approving a refresh-only apply, which could otherwise normalize an unapproved change in the recorded state.

For Bicep/ARM, schedule a supported what-if plus explicit live configuration/Policy/runtime checks. Do not treat what-if as an exhaustive drift inventory: analysis limits, resources omitted from incremental templates, and provider-managed behavior require additional observation. Join changes with the applicable activity and operational records and route findings to an owner.

Automate approved bounded remediation where justified, and retain the task, identity, approval, execution, and retest evidence. Changes to network access, role assignments, keys, and shared resources can disrupt workloads. Define escalation or rollback thresholds with the program instead of silently repairing every deviation or automatically accepting every change.

7. Verify the evidence workflow before handover

  • A deliberately noncompliant fixture fails the correct technical check.
  • A pipeline permission error remains a collection failure, not a resource pass.
  • Stale/missing policy rows and missing expected resources enter the review queue.
  • An approved exemption remains visible with scope, owner, and expiry conditions.
  • An out-of-band change is detected without overwriting the prior release record.
  • An incomplete what-if or failed deployment cannot publish a successful release package.
  • The receiving team can trace an artifact to the change, resource, policy configuration, runtime test, and reviewer decision.

These tests establish useful evidence-automation behavior. Continue the broader vulnerability, identity, application, incident-response, and organizational assurance work required by the program; the IaC pipeline supports that work rather than determining authorization.

Sources and technical review

Public documentation reviewed . Recheck the applicable program requirements and the selected Azure cloud, API, and tool versions before implementation.

The examples illustrate an engineering workflow. No Azure deployment, provider authorization-package review, agency risk acceptance, or cATO approval was performed for this guide.