Compliance at scale
Inherited guardrails, secure-by-default accounts.
A supermarket chain with sixty stores does not check freezer temperatures by driving to each store. Every freezer reports its own temperature to one screen at head office, and a manager reads sixty numbers in a second. Compliance at scale works the same way. An auditor opens your SOC 2 renewal (Service Organization Control 2, the audited report customers ask for before they trust you with their data) with a request that sounds small: prove storage encryption is enforced on every database and every bucket, across all sixty of your AWS accounts (Amazon Web Services accounts, each one its own billing and security boundary), for the whole audit period. You cannot log into sixty accounts by hand. Green screenshots from three of them say nothing about the other fifty-seven. The answer is a single query, and that query only works because the control behind it was written once, shipped everywhere, evaluated in every account, and rolled back up to one place you can read in seconds.
Two halves: locks and cameras
Compliance work splits in two, like locks and cameras on a building. The preventive half is the lock. A policy engine running in CI (continuous integration, the automated build that fires on every change) or at admission refuses a bad Terraform plan (the preview file showing what a change is about to create or alter) or a bad Kubernetes manifest before it can merge. The detective half is the camera. It keeps watching what actually exists and reports the drift the lock never saw: a resource somebody clicked into being in the console, an older resource that predates the policy, a control the pipeline gate cannot check by reading a plan file. Both halves have the same scaling problem and the same fix. Write each control in exactly one place, then make every account and every pipeline read that one definition instead of a copy that quietly wanders off. The rest of this lesson is that idea done twice, once for detection and once for prevention.
Detective half: roll every account into one query
AWS Config is a tape recorder for infrastructure. It writes down each resource's configuration as it changes and keeps re-checking it against Config rules, which are named checks (managed ones AWS ships, or custom ones you write) such as s3-bucket-server-side-encryption-enabled. Every resource comes out stamped COMPLIANT or NON_COMPLIANT. On its own, that recorder is deaf outside its own account and its own region, which gets you nowhere with sixty accounts. An aggregator is what makes it estate-wide: a read-only viewing window that collects evaluation results from every account and every region in your AWS Organization (the tree that owns all your accounts under one root) into a single place. You create it once, in a delegated administrator account (the one account the organization nominates to run this on everyone's behalf), using an organization aggregation source. Newly vended accounts, the ones your account factory stamps out later, then show up on their own and you never touch the aggregator again. Nothing here blocks anything. An aggregator watches and totals, which is exactly the population-level evidence an auditor wants to see.
# One aggregator that sees ALL org accounts + all regions, foreveraws configservice put-configuration-aggregator \--configuration-aggregator-name org-compliance \--organization-aggregation-source \RoleArn=arn:aws:iam::210987654321:role/AWSConfigAggregatorRole,AllAwsRegions=true
{"ConfigurationAggregator": {"ConfigurationAggregatorName": "org-compliance","OrganizationAggregationSource": {"RoleArn": "arn:aws:iam::210987654321:role/AWSConfigAggregatorRole","AllAwsRegions": true},"CreationTime": "2026-07-14T08:02:11.503000+00:00"}}
Aggregation is eventually consistent. Results trickle in over minutes rather than landing the instant you press enter, so give it time to collect before you read anything into a thin answer. Then ask the rollup question. describe-aggregate-compliance-by-config-rules returns one row per rule, per account, per region, with a contributor count of how many resources are failing. That is your scoreboard for the whole estate: one command, every account.
aws configservice describe-aggregate-compliance-by-config-rules \--configuration-aggregator-name org-compliance \--filters ComplianceType=NON_COMPLIANT \--query 'AggregateComplianceByConfigRules[].[ConfigRuleName,AccountId,AwsRegion,Compliance.ComplianceContributorCount.CappedCount]' \--output table
---------------------------------------------------------------------------------| DescribeAggregateComplianceByConfigRules |+---------------------------------------------+---------------+-----------+------+| rds-storage-encrypted | 111122223333 | us-east-1| 3 || s3-bucket-server-side-encryption-enabled | 111122223333 | us-east-1| 4 || s3-bucket-server-side-encryption-enabled | 444455556666 | eu-west-1| 1 || cloudtrail-enabled | 777788889999 | us-east-1| 1 |+---------------------------------------------+---------------+-----------+------+
The scoreboard names the failing account. get-aggregate-compliance-details-by-config-rule names the failing resources, so the ticket you file lists four buckets by name instead of saying 'something in prod is unencrypted'. Notice that this detail call takes one account and one region, and will not take more. That limit is on purpose. It stops a single command from trying to hand back millions of results across the whole organization, and it makes you walk the accounts deliberately, one at a time.
aws configservice get-aggregate-compliance-details-by-config-rule \--configuration-aggregator-name org-compliance \--config-rule-name s3-bucket-server-side-encryption-enabled \--account-id 111122223333 --aws-region us-east-1 \--compliance-type NON_COMPLIANT \--query 'AggregateEvaluationResults[].EvaluationResultIdentifier.EvaluationResultQualifier.ResourceId'
["acme-logs-staging","acme-analytics-scratch","acme-tmp-exports","acme-user-uploads-dev"]
A rollup is only ever as complete as the rules and recorders running underneath it. The way to guarantee that coverage without touching accounts by hand is a conformance pack, a versioned collection of Config rules deployed through the organization so every account you have today and every account you create next month runs the same set. Deploy the recorder and the pack organization-wide first. The aggregator then has something real to total in each account. Get that order wrong and the rollup will cheerfully report 100% compliant for accounts that never ran the rule at all. Auditors do accept an aggregator report as evidence, because it is machine-generated, timestamped, and covers the whole population instead of a hand-picked sample. They will also ask what that population was, so keep the account list and the conformance-pack deployment status ready to sit next to the percentage. On its own, the percentage is evidence toward the control and nothing more. The auditor still writes the narrative and still chooses how to sample.
Preventive half: ship one versioned policy everywhere
Detection tells you what already broke. To stop it happening again you put a gate in every pipeline, ahead of the merge. Conftest runs Rego policies (Rego is the rule language of Open Policy Agent, or OPA, a general-purpose engine for writing rules about structured data) against config that has structure: Terraform plans, Kubernetes manifests, Dockerfiles. When a control fails, it exits non-zero. The scale trap here is copy-paste. A hundred repositories each carrying their own copy of the encryption policy is a hundred policies, and they start drifting apart the day someone fixes one of them. The fix is a policy bundle. Write the controls once in a central repository, build a versioned artifact from that directory, publish it to an OCI registry (Open Container Initiative, the same registry format your container images already use), and have every pipeline pull a pinned version. The control is now byte-identical in all hundred repositories, and rolling out a new control across the estate is a one-line version bump instead of a hundred pull requests.
package main# SOC 2 CC6.1 / CIS AWS 2.3.1 — RDS instances must encrypt storage at restdeny contains msg if {some rc in input.resource_changesrc.type == "aws_db_instance"not rc.change.after.storage_encryptedmsg := sprintf("RDS '%s': storage_encrypted must be true (CIS 2.3.1)", [rc.address])}
Build the policy directory into a versioned artifact and publish it once. The version tag on the OCI reference is the pin every pipeline consumes. If you also run an OPA bundle server, opa build --revision stamps that same version string into every decision log, so any single allow or deny can be traced back to the exact policy that produced it. That provenance is what auditors keep asking for. Build once, push once, and v3.2.0 becomes the one copy that matters.
# For an OPA bundle server: stamp the revision into every decision logopa build -b policy/ -o bundle.tar.gz -r v3.2.0# For CI distribution: publish the policy dir to an OCI registry the org can readconftest push ghcr.io/acme/compliance-bundle:v3.2.0
$ ls -lh bundle.tar.gz-rw-r--r-- 1 ci ci 4.1K Jul 14 08:20 bundle.tar.gz$ conftest push ghcr.io/acme/compliance-bundle:v3.2.02026/07/14 08:20:04 pushing bundle to: ghcr.io/acme/compliance-bundle:v3.2.02026/07/14 08:20:04 pushed bundle with digest: sha256:9f4c1e42...b7a2
Every pipeline's compliance step is now the same three lines, pinned to a version. The repository holds a tag, not a copy of the policy. conftest pull downloads the pinned bundle into ./policy, and conftest test runs it against the plan. Pass and it exits 0, so the stage goes green. Fail and it exits 1, and because CI treats any non-zero exit as a failed stage, the merge is blocked. That exit code is the entire enforcement mechanism. Drop it, or soften the run with --no-fail, and what you are left with is a linter nobody reads.
terraform show -json tfplan > plan.jsonconftest pull oci://ghcr.io/acme/compliance-bundle:v3.2.0 # downloads to ./policyconftest test plan.jsonecho "exit=$?"
FAIL - plan.json - main - RDS 'aws_db_instance.analytics': storage_encrypted must be true (CIS 2.3.1)4 tests, 3 passed, 0 warnings, 1 failure, 0 exceptionsexit=1
Central distribution cuts both ways. Pinning versions is what keeps the estate consistent, and it is also what saves you the morning a stricter v3.3.0 that every pipeline auto-pulls turns a hundred merge requests red before lunch. One central policy means one central blast radius. Treat the bundle like any other production artifact. Promote a new version through a canary set of repositories first. Write approved, time-boxed deviations into the central policy as explicit exceptions, rather than letting teams scatter per-repo skips nobody can audit later. Let the slow teams sit on v3.2.0 until they upgrade on purpose. Pinning is the seatbelt that lets a shared policy keep evolving without taking the whole estate down in one synchronized bang.
What you eventually hand the auditor is three artifacts that have to agree with each other: the aggregator query and its result, the bundle version that produced every pipeline decision, and the account list the query actually ran over. That package is evidence toward the control, not a passed audit, because the auditor still writes the narrative and still picks the sample. Assembling it deliberately, before anyone asks, is where the next lesson on audit readiness begins.
Try this
Work through “Preventive half: ship one versioned policy everywhere” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a green rollup can hide accounts nobody ever checked. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.