Brownfield In-Place Compliance Strategy
Scenario: A client has existing Terraform configurations with their own state files. They do NOT want SnowOps to own a separate Terraform configuration. They want compliance controls added directly INTO their existing infrastructure-as-code.
This is fully supported. Here's how.
The Core Insight
SnowOps F-modules, B-modules, and policy assets are all designed to be consumed from a client's own Terraform configurations. The client keeps their own state, their own repo structure, and their own apply workflow. SnowOps modules plug in as remote module sources that the client calls like any other Terraform module.
The three layers work together:
| Layer | What it does | Where it lives |
|---|---|---|
| Policy gates (D3/X3 + D5) | OPA rules run against the client's terraform plan output; enforce compliance without touching code |
Client's CI pipeline |
| Module calls | Client adds module {} blocks in their existing .tf files pointing at SnowOps F/B modules via git ref |
Client's existing main.tf or a new compliance.tf file alongside their infra |
| Existing resource adoption (F12) | Import blocks bring existing resources under SnowOps module management without destroy/recreate | Client's existing config directory |
Option A — Add SnowOps Modules to Existing Configs
The client adds module {} blocks to their existing Terraform files. The module call produces resources that go into the client's existing state file. No separate state. No separate apply.
Example: adding audit logging to an existing environment
In the client's existing main.tf (or a new compliance.tf alongside it):
# Reference SnowOps module via git + semver tag (F11 pin strategy)
module "audit_logs" {
source = "git::https://github.com/snowops/snowops-automation//modules/azure/log-analytics?ref=log-analytics/v1.0.0"
resource_group_name = azurerm_resource_group.main.name # client's existing RG
location = azurerm_resource_group.main.location
workspace_name = "${var.environment}-audit-law"
retention_days = 365
tags = var.tags
}
module "diagnostic_settings" {
source = "git::https://github.com/snowops/snowops-automation//modules/azure/policy-diagnostics?ref=policy-diagnostics/v1.0.0"
log_analytics_workspace_id = module.audit_logs.workspace_id
subscription_id = var.subscription_id
}
The module's resources are added to the client's existing state file. The client's terraform plan shows "will create" for the new resources; terraform apply creates them. No migration, no re-provisioning of existing resources.
Pinning: Always pin to a semver tag (via F11 module registry). Never use ref=main — a floating ref means uncontrolled updates. The apps/module-registry tool audits pins and flags any that are unpinned.
Which modules can be added in-place?
All SnowOps modules are designed to be called this way. The most common ones to add to existing configs for compliance:
| Compliance need | Module to add | What it creates |
|---|---|---|
| Audit logging | modules/azure/log-analytics (J1) |
Log Analytics workspace |
| Diagnostic settings | modules/azure/policy-diagnostics (J2) |
DINE policy for resource logging |
| WORM audit storage | modules/azure/audit-log-archive (J6) |
Immutable blob container |
| Backup policies | modules/azure/backup-policy (L1) |
Recovery Services Vault + backup policies |
| Cross-region DR | modules/azure/cross-region-replication (L2) |
Replication rules on existing accounts |
| Encryption deny | modules/azure/encryption-policy (M1) |
Azure Policy initiative (Deny effect) |
| CMK key management | modules/azure/cmk (M2) |
HSM-backed key + rotation policy |
| TLS enforcement | modules/azure/tls-policy (M3) |
Azure Policy initiative (Deny effect) |
| Identity hardening | modules/azure/conditional-access (H2) |
Conditional Access policies (MFA, geo-block, risk gating) |
| PIM access | modules/azure/pim-azure-resources (B5) |
Eligible role assignments |
| Network isolation | modules/azure/nsg-baseline (N6) |
NSG on existing subnets |
| Public-access deny | modules/azure/private-endpoint-policy (N5) |
Azure Policy initiative (Deny effect) |
| Required tags | modules/azure/tag-policy (U2) |
Azure Policy initiative (Deny effect) |
| Budget alerts | modules/azure/budget-alert (U1) |
Budget + action group on existing subscription |
The Policy modules (M1, M3, N5, U2 — Deny initiatives) are the easiest to add: they don't create new resources on existing infrastructure, they just stop new non-compliant resources from being created and flag existing violations in Defender for Cloud.
The Real-resource modules (J1, J2, J6, L1, L2, M2, N6, U1) create new supporting resources that complement the client's existing infrastructure without touching what's already there.
Option B — Adopt Existing Resources into SnowOps Modules (F12)
If the client wants an existing resource to be fully managed by an SnowOps module (so its configuration is enforced and drifts detected), use F12 import blocks.
This is the right path when:
- The client has an existing Key Vault they want to adopt into modules/azure/key-vault (F5)
- They want drift detection (S1) to catch any manual changes to an existing storage account
- They want the module's hardening defaults applied to an existing resource via terraform apply (not a new resource)
Process:
- Copy the relevant import block from
modules/azure/import-blocks/<module>.tf:
# From modules/azure/import-blocks/key-vault.tf
import {
to = module.kv.azurerm_key_vault.this
id = "/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<name>"
}
- Add the
module {}call to the client's existing config:
module "kv" {
source = "git::https://github.com/snowops/snowops-automation//modules/azure/key-vault?ref=key-vault/v1.0.0"
resource_group_name = "existing-rg"
location = "eastus"
name = "existing-kv-name"
soft_delete_retention_days = 90
purge_protection_enabled = true
# ... other params matching the existing resource's config
}
-
Run
terraform plan— it should show only imports (zero destroys, zero creates): -
Run
terraform applyto complete the import. The resource is now module-managed.
Important: If the plan shows change actions on the existing resource, that means the module's defaults differ from the existing configuration. Review each change — most will be hardening improvements (e.g., purge_protection_enabled: false → true). Apply deliberately; don't blindly accept changes on production resources.
Option C — OPA Policy Enforcement Only (No Module Changes)
If the client doesn't want to add any module calls at all, SnowOps can still improve their compliance posture by enforcing OPA policy rules against their existing terraform plan output.
This is the lightest-touch path: zero changes to the client's Terraform code, just a new CI gate.
Add to the client's CI pipeline:
# In the client's existing CI (GitHub Actions, ADO, etc.)
- name: Install conftest
run: |
curl -sSL \
"https://github.com/open-policy-agent/conftest/releases/download/v0.56.0/conftest_0.56.0_Linux_x86_64.tar.gz" \
| tar -xz -C /tmp conftest
sudo install -m 0755 /tmp/conftest /usr/local/bin/conftest
- name: terraform plan (JSON)
run: terraform plan -out=tfplan.binary && terraform show -json tfplan.binary > plan.json
- name: OPA policy check (D3)
run: |
conftest test plan.json \
--policy <path-to-snowops>/policy/opa/rules \
--data <path-to-snowops>/waivers/exceptions.yaml
What this provides: - Any new resource created must pass the OPA rules (storage encryption, network isolation, etc.) - Existing resources that fail rules are surfaced as findings but don't block plans (they don't appear in the plan unless they're being changed) - D5 waivers give the client time-boxed exceptions for existing violations
This is the right starting point for brownfield clients — enforce compliance on future changes while planning the remediation of existing gaps.
How the SnowOps Evidence Tools Work With the Client's State
All SnowOps evidence and monitoring tools are read-only against Azure, not against Terraform state. They don't need access to the client's state files at all.
| Tool | How it works | What it reads |
|---|---|---|
| E0 (compliance snapshot) | Queries Azure Policy + Defender for Cloud via ARM REST | Azure subscription directly; no TF state |
| S1 (drift detection) | Runs terraform plan against the client's existing configs + state backend |
Client's existing state backend + Azure |
| S2 (compliance dashboard) | Reads E0's snapshot files | compliance/snapshots/ directory |
| L4 (restore drill) | Triggers Azure Backup restore via az CLI |
Azure Backup vault directly |
| G-series (discovery) | Queries Azure Resource Graph, Defender, AAD | Azure subscription directly |
This means you can wire E0, S2, and G-series into a client's environment without touching their Terraform code at all. They provide immediate compliance visibility from day one.
For S1 (drift detection), you point it at the client's existing state backends in the workflow matrix:
# .github/workflows/drift-detection.yml (in the client's repo)
jobs:
drift:
strategy:
matrix:
stack:
- name: networking
working_dir: infra/networking
backend_key: networking.tfstate
- name: compute
working_dir: infra/compute
backend_key: compute.tfstate
- name: data
working_dir: infra/data
backend_key: data.tfstate
Each existing stack gets its own drift detection run. Separate state files are not a problem.
Recommended In-Place Adoption Sequence
Day 1: Wire G-series discovery → posture baseline (zero code changes)
Day 1–3: Wire E0 + S2 → compliance dashboard (zero code changes)
Week 1: Add OPA/conftest gate to existing CI (Option C) + D5 waivers for existing violations
Week 2: Wire S1 drift detection against existing state backends
Week 2–4: Add Policy modules (M1/M3/N5/U2) to existing config — these are Deny initiatives,
no existing resources touched
Week 4–8: Add real-resource modules (J1/J2/J6, L1, U1) to existing config for audit logging,
backup, alerting
Week 8+: Use F12 to import any existing resources the client wants fully module-managed
At each step the client retains full control of their existing state and config structure. SnowOps modules are additive — they slot in alongside existing code, not replacing it.
What Can't Be Done In-Place
| Scenario | Limitation | Workaround |
|---|---|---|
| Client uses Terraform Cloud or Spacelift for state | S1 drift detection needs state backend access | Configure S1 with the relevant VCS/API token; TFC supports remote runs |
| Client's existing resources violate Deny policies (M1/M3/N5) | Adding Deny initiatives won't affect existing non-compliant resources immediately | Use D5 waivers to time-box; Defender flags existing violations; fix in next change |
| Client uses a different Terraform version (< 1.6) | F12 import blocks require TF 1.6+ | B6 prerequisite check catches this; upgrade path is client-managed |
| Client's config uses hardcoded resource names that clash with module outputs | Module + existing resource name collision | Use for_each with unique keys or rename; F12 import handles adoption |
| Client has no CI pipeline at all | OPA gate (Option C) requires a CI trigger | Add a basic CI pipeline first; GitHub Actions is free for public repos |