Skip to content

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:

  1. 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>"
}
  1. 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
}
  1. Run terraform plan — it should show only imports (zero destroys, zero creates):

    Plan: 0 to add, 0 to change, 0 to destroy.
    Importing...
      # module.kv.azurerm_key_vault.this will be imported
    

  2. Run terraform apply to 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.


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