Compliance-as-Code for CCPA: Why Privacy Controls Belong in Your Infrastructure as Code

Compliance-as-code is standard practice for SOC 2, HIPAA, and PCI-DSS. OPA policies, Sentinel rules, policy-as-code checks — security teams have been codifying controls for years. But for CCPA and privacy regulations? The industry is still configuring consent through admin dashboards, documenting controls in spreadsheets, and proving compliance with screenshots. That gap is about to become expensive.

The compliance-as-code gap

If you run infrastructure for a SOC 2-certified company, you've probably seen something like this in your CI pipeline:

# SOC 2 policy check — runs on every terraform plan
policy "encryption_at_rest" {
  source  = "./policies/soc2/encryption.sentinel"
  enforcement_level = "hard-mandatory"
}

policy "no_public_s3" {
  source  = "./policies/soc2/storage-access.sentinel"
  enforcement_level = "hard-mandatory"
}

Security controls are codified. They're version-controlled. They block deploys that violate them. Nobody is manually checking whether S3 buckets are encrypted — the pipeline enforces it.

Now ask yourself: where are your CCPA controls?

If the answer involves a spreadsheet maintained by legal, a Jira board of privacy tasks, or a consent management dashboard you log into separately from your infrastructure — you have the same gap that every mid-market company has. Privacy controls live outside the codebase. They're not version-controlled. They're not tested. And when the California Privacy Protection Agency asks you to prove your consent enforcement works, you're going to show them a screenshot, not a commit hash.

Why CCPA controls belong in code

CCPA isn't vague about what it requires. The regulation specifies concrete technical obligations: honor opt-out signals, process data subject access requests within 45 days, enforce data retention limits, restrict processing based on consent status. These are infrastructure behaviors, not policy documents.

When you express these controls as infrastructure-as-code, you get four properties that spreadsheets can never provide:

  • Version control. Every change to a privacy control is a commit. You can see who changed what, when, and why. When an auditor asks "when did you start honoring GPC signals," the answer is a git log entry, not a calendar invite for the meeting where someone said they'd get to it.
  • Reproducibility. Your staging environment has the same privacy controls as production because they're deployed from the same IaC modules. You don't discover during an audit that your consent enforcement only works in prod because someone manually configured it there.
  • Testability. You can write integration tests that verify your DSAR pipeline actually processes a deletion request end-to-end. You can run those tests on every deploy. Try doing that with a dashboard configuration.
  • Drift detection. If someone manually changes a data retention policy in the AWS console, your IaC pipeline catches it on the next plan. Privacy controls that were compliant yesterday are proven to still be compliant today.

What CCPA compliance-as-code looks like

This isn't theoretical. Here's what it looks like when you express CCPA requirements as infrastructure.

// examples in:

1. Consent signal processing

CCPA requires businesses to honor the Global Privacy Control (GPC) signal as a valid opt-out of sale/sharing. Most companies handle this in application code — a middleware that checks for Sec-GPC: 1 and sets a cookie. The problem: that middleware exists in one service, it's not tested, and nobody knows if it still works after the last refactor.

Deploy it as infrastructure instead. The GPC detection layer runs as an API Gateway authorizer or edge function, deployed and versioned alongside everything else:

# modules/ccpa-consent/gpc-detection.tf

resource "aws_lambda_function" "gpc_detector" {
  function_name = "ccpa-gpc-signal-processor"
  runtime       = "nodejs20.x"
  handler       = "gpc-handler.detect"
  timeout       = 5
  memory_size   = 128

  environment {
    variables = {
      CONSENT_TABLE    = aws_dynamodb_table.consent_state.name
      EVENT_BUS        = aws_sns_topic.consent_events.arn
      ENFORCEMENT_MODE = "block"  # "log" in staging, "block" in prod
    }
  }

  tags = {
    ccpa_control = "consent-signal-processing"
    regulation   = "ccpa-1798.120"
  }
}

resource "aws_apigatewayv2_authorizer" "gpc_check" {
  api_id           = var.api_gateway_id
  authorizer_type  = "REQUEST"
  name             = "ccpa-gpc-authorizer"
  authorizer_uri   = aws_lambda_function.gpc_detector.invoke_arn

  identity_sources = ["$request.header.Sec-GPC"]
}
// lib/ccpa-consent/gpc-detection.ts

import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2";
import { HttpLambdaAuthorizer, HttpLambdaResponseType } from "aws-cdk-lib/aws-apigatewayv2-authorizers";

const gpcDetector = new lambda.Function(this, "GpcDetector", {
  functionName: "ccpa-gpc-signal-processor",
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: "gpc-handler.detect",
  timeout: cdk.Duration.seconds(5),
  memorySize: 128,
  code: lambda.Code.fromAsset("lambda/gpc-handler"),
  environment: {
    CONSENT_TABLE: consentTable.tableName,
    EVENT_BUS: consentTopic.topicArn,
    ENFORCEMENT_MODE: "block",  // "log" in staging, "block" in prod
  },
});

cdk.Tags.of(gpcDetector).add("ccpa_control", "consent-signal-processing");
cdk.Tags.of(gpcDetector).add("regulation", "ccpa-1798.120");

const gpcAuthorizer = new HttpLambdaAuthorizer("GpcCheck", gpcDetector, {
  authorizerName: "ccpa-gpc-authorizer",
  responseTypes: [HttpLambdaResponseType.SIMPLE],
  identitySource: ["$request.header.Sec-GPC"],
});
// ccpa-consent/gpcDetection.ts

import * as aws from "@pulumi/aws";

const gpcDetector = new aws.lambda.Function("gpcDetector", {
  name: "ccpa-gpc-signal-processor",
  runtime: "nodejs20.x",
  handler: "gpc-handler.detect",
  timeout: 5,
  memorySize: 128,
  role: gpcDetectorRole.arn,
  code: new pulumi.asset.FileArchive("./lambda/gpc-handler"),
  environment: {
    variables: {
      CONSENT_TABLE: consentTable.name,
      EVENT_BUS: consentTopic.arn,
      ENFORCEMENT_MODE: "block",  // "log" in staging, "block" in prod
    },
  },
  tags: {
    ccpa_control: "consent-signal-processing",
    regulation: "ccpa-1798.120",
  },
});

const gpcAuthorizer = new aws.apigatewayv2.Authorizer("gpcCheck", {
  apiId: apiGatewayId,
  authorizerType: "REQUEST",
  name: "ccpa-gpc-authorizer",
  authorizerUri: gpcDetector.invokeArn,
  identitySources: ["$request.header.Sec-GPC"],
});

The Lambda handler itself is straightforward — detect the signal, write to the centralized consent store, emit an event:

// gpc-handler.ts
export const detect = async (event: APIGatewayRequestAuthorizerEvent) => {
  const gpcSignal = event.headers["sec-gpc"];
  const accountId = extractAccountId(event);

  if (gpcSignal === "1" && accountId) {
    await consentStore.recordOptOut({
      accountId,
      signal:    "gpc",
      scope:     "sale_and_sharing",
      timestamp: new Date().toISOString(),
      source:    "sec-gpc-header",
    });

    await eventBus.publish("consent.opt_out", {
      accountId,
      regulation: "ccpa",
      right:      "1798.120",  // right to opt out of sale/sharing
    });
  }

  return generatePolicy(accountId, "Allow");
};

Every piece of this — the Lambda, the authorizer, the DynamoDB table, the SNS topic — is defined in infrastructure-as-code. It's reviewed in PRs. It's deployed through CI. It doesn't exist as a checkbox in a consent platform's admin panel.

2. DSAR pipeline infrastructure

CCPA gives consumers the right to request access to, deletion of, and correction of their personal data. You have 45 calendar days to respond. Most companies handle this with a Jira ticket and a runbook. That works until you're processing 200 requests per month and an auditor asks to see your fulfillment timestamps.

Build the DSAR pipeline as infrastructure:

# modules/ccpa-dsar/pipeline.tf

resource "aws_sqs_queue" "dsar_intake" {
  name                       = "ccpa-dsar-intake"
  message_retention_seconds  = 1209600  # 14 days
  visibility_timeout_seconds = 300
  receive_wait_time_seconds  = 20

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dsar_dlq.arn
    maxReceiveCount     = 3
  })

  tags = {
    ccpa_control = "dsar-processing"
    regulation   = "ccpa-1798.100-1798.106"
    sla_days     = "45"
  }
}

resource "aws_sqs_queue" "dsar_dlq" {
  name                      = "ccpa-dsar-intake-dlq"
  message_retention_seconds = 1209600
}

resource "aws_dynamodb_table" "dsar_audit_trail" {
  name         = "ccpa-dsar-audit-trail"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "request_id"
  range_key    = "timestamp"

  attribute {
    name = "request_id"
    type = "S"
  }

  attribute {
    name = "timestamp"
    type = "S"
  }

  attribute {
    name = "account_id"
    type = "S"
  }

  global_secondary_index {
    name            = "account-index"
    hash_key        = "account_id"
    range_key       = "timestamp"
    projection_type = "ALL"
  }

  point_in_time_recovery {
    enabled = true
  }

  tags = {
    ccpa_control = "dsar-audit-trail"
    data_class   = "compliance-critical"
  }
}
// lib/ccpa-dsar/pipeline.ts

import * as sqs from "aws-cdk-lib/aws-sqs";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";

const dsarDlq = new sqs.Queue(this, "DsarDlq", {
  queueName: "ccpa-dsar-intake-dlq",
  retentionPeriod: cdk.Duration.days(14),
});

const dsarIntake = new sqs.Queue(this, "DsarIntake", {
  queueName: "ccpa-dsar-intake",
  retentionPeriod: cdk.Duration.days(14),
  visibilityTimeout: cdk.Duration.seconds(300),
  receiveMessageWaitTime: cdk.Duration.seconds(20),
  deadLetterQueue: {
    queue: dsarDlq,
    maxReceiveCount: 3,
  },
});

cdk.Tags.of(dsarIntake).add("ccpa_control", "dsar-processing");
cdk.Tags.of(dsarIntake).add("regulation", "ccpa-1798.100-1798.106");
cdk.Tags.of(dsarIntake).add("sla_days", "45");

const dsarAuditTrail = new dynamodb.Table(this, "DsarAuditTrail", {
  tableName: "ccpa-dsar-audit-trail",
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  partitionKey: { name: "request_id", type: dynamodb.AttributeType.STRING },
  sortKey: { name: "timestamp", type: dynamodb.AttributeType.STRING },
  pointInTimeRecovery: true,
});

dsarAuditTrail.addGlobalSecondaryIndex({
  indexName: "account-index",
  partitionKey: { name: "account_id", type: dynamodb.AttributeType.STRING },
  sortKey: { name: "timestamp", type: dynamodb.AttributeType.STRING },
  projectionType: dynamodb.ProjectionType.ALL,
});

cdk.Tags.of(dsarAuditTrail).add("ccpa_control", "dsar-audit-trail");
cdk.Tags.of(dsarAuditTrail).add("data_class", "compliance-critical");
// ccpa-dsar/pipeline.ts

import * as aws from "@pulumi/aws";

const dsarDlq = new aws.sqs.Queue("dsarDlq", {
  name: "ccpa-dsar-intake-dlq",
  messageRetentionSeconds: 1209600,
});

const dsarIntake = new aws.sqs.Queue("dsarIntake", {
  name: "ccpa-dsar-intake",
  messageRetentionSeconds: 1209600,  // 14 days
  visibilityTimeoutSeconds: 300,
  receiveWaitTimeSeconds: 20,
  redrivePolicy: dsarDlq.arn.apply(arn =>
    JSON.stringify({
      deadLetterTargetArn: arn,
      maxReceiveCount: 3,
    })
  ),
  tags: {
    ccpa_control: "dsar-processing",
    regulation: "ccpa-1798.100-1798.106",
    sla_days: "45",
  },
});

const dsarAuditTrail = new aws.dynamodb.Table("dsarAuditTrail", {
  name: "ccpa-dsar-audit-trail",
  billingMode: "PAY_PER_REQUEST",
  hashKey: "request_id",
  rangeKey: "timestamp",
  attributes: [
    { name: "request_id", type: "S" },
    { name: "timestamp", type: "S" },
    { name: "account_id", type: "S" },
  ],
  globalSecondaryIndexes: [{
    name: "account-index",
    hashKey: "account_id",
    rangeKey: "timestamp",
    projectionType: "ALL",
  }],
  pointInTimeRecovery: {
    enabled: true,
  },
  tags: {
    ccpa_control: "dsar-audit-trail",
    data_class: "compliance-critical",
  },
});

The processor Lambda handles intake, identity verification, data discovery across your datastores, and writes every step to the audit trail:

// dsar-processor.ts
export const process = async (event: SQSEvent) => {
  for (const record of event.Records) {
    const request: DSARRequest = JSON.parse(record.body);

    await auditTrail.log(request.id, "received", {
      type:       request.type,  // "access" | "deletion" | "correction"
      accountId:  request.accountId,
      receivedAt: request.timestamp,
      deadline:   addDays(request.timestamp, 45),
    });

    // Discover personal data across all registered datastores
    const datastores = await registry.getDatastores(request.accountId);
    for (const store of datastores) {
      await auditTrail.log(request.id, "scanning", {
        datastore: store.name,
        type:      store.type,
      });

      const records = await store.findPersonalData(request.accountId);
      await auditTrail.log(request.id, "found", {
        datastore:    store.name,
        recordCount: records.length,
        categories:  records.map(r => r.category),
      });
    }

    await auditTrail.log(request.id, "processing_complete");
  }
};

Every DSAR that enters the system is timestamped, logged, and tracked against the 45-day SLA. The dead-letter queue catches failures. The audit trail is immutable (point-in-time recovery enabled). When CalPrivacy asks "show me your DSAR fulfillment records," you query a DynamoDB table, not a Jira export.

3. Data retention enforcement

CCPA requires that businesses only retain personal data for as long as reasonably necessary for the disclosed purpose. Most companies have a data retention policy document. Almost none of them enforce it technically.

S3 lifecycle rules and DynamoDB TTL settings aren't new technology. The CCPA-specific piece is mapping retention periods to data categories and making those mappings auditable:

# modules/ccpa-retention/lifecycle.tf

variable "retention_policies" {
  description = "CCPA data retention periods by category"
  type = map(object({
    retention_days  = number
    purpose         = string
    ccpa_category   = string
    auto_delete     = bool
  }))

  default = {
    transaction_records = {
      retention_days = 2555  # 7 years — tax/financial obligation
      purpose        = "Financial recordkeeping per tax obligations"
      ccpa_category  = "commercial_information"
      auto_delete    = true
    }
    browsing_behavior = {
      retention_days = 90
      purpose        = "Product analytics and personalization"
      ccpa_category  = "internet_activity"
      auto_delete    = true
    }
    support_tickets = {
      retention_days = 365
      purpose        = "Customer support quality and dispute resolution"
      ccpa_category  = "personal_identifiers"
      auto_delete    = true
    }
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "retention" {
  for_each = var.retention_policies

  bucket = aws_s3_bucket.data_stores[each.key].id

  rule {
    id     = "ccpa-retention-${each.key}"
    status = each.value.auto_delete ? "Enabled" : "Disabled"

    expiration {
      days = each.value.retention_days
    }

    filter {
      prefix = "personal-data/"
    }
  }
}
// lib/ccpa-retention/lifecycle.ts

import * as s3 from "aws-cdk-lib/aws-s3";

interface RetentionPolicy {
  retentionDays: number;
  purpose: string;
  ccpaCategory: string;
  autoDelete: boolean;
}

const retentionPolicies: Record<string, RetentionPolicy> = {
  transaction_records: {
    retentionDays: 2555,  // 7 years — tax/financial obligation
    purpose: "Financial recordkeeping per tax obligations",
    ccpaCategory: "commercial_information",
    autoDelete: true,
  },
  browsing_behavior: {
    retentionDays: 90,
    purpose: "Product analytics and personalization",
    ccpaCategory: "internet_activity",
    autoDelete: true,
  },
  support_tickets: {
    retentionDays: 365,
    purpose: "Customer support quality and dispute resolution",
    ccpaCategory: "personal_identifiers",
    autoDelete: true,
  },
};

for (const [key, policy] of Object.entries(retentionPolicies)) {
  new s3.Bucket(this, `DataStore-${key}`, {
    lifecycleRules: policy.autoDelete ? [{
      id: `ccpa-retention-${key}`,
      enabled: true,
      prefix: "personal-data/",
      expiration: cdk.Duration.days(policy.retentionDays),
    }] : [],
  });
}
// ccpa-retention/lifecycle.ts

import * as aws from "@pulumi/aws";

interface RetentionPolicy {
  retentionDays: number;
  purpose: string;
  ccpaCategory: string;
  autoDelete: boolean;
}

const retentionPolicies: Record<string, RetentionPolicy> = {
  transaction_records: {
    retentionDays: 2555,  // 7 years — tax/financial obligation
    purpose: "Financial recordkeeping per tax obligations",
    ccpaCategory: "commercial_information",
    autoDelete: true,
  },
  browsing_behavior: {
    retentionDays: 90,
    purpose: "Product analytics and personalization",
    ccpaCategory: "internet_activity",
    autoDelete: true,
  },
  support_tickets: {
    retentionDays: 365,
    purpose: "Customer support quality and dispute resolution",
    ccpaCategory: "personal_identifiers",
    autoDelete: true,
  },
};

for (const [key, policy] of Object.entries(retentionPolicies)) {
  new aws.s3.BucketLifecycleConfigurationV2(`retention-${key}`, {
    bucket: dataStoreBuckets[key].id,
    rules: [{
      id: `ccpa-retention-${key}`,
      status: policy.autoDelete ? "Enabled" : "Disabled",
      expiration: {
        days: policy.retentionDays,
      },
      filter: {
        prefix: "personal-data/",
      },
    }],
  });
}

The retention periods are declared in one place, applied uniformly, and visible in every plan/diff output. If someone changes the browsing behavior retention from 90 days to 365 days, that shows up as a diff in the PR — and your compliance team can review it before it merges.

4. Policy-as-code validation

The infrastructure modules handle deployment. But you also need guardrails that prevent non-compliant changes from reaching production. This is where policy-as-code tools like OPA close the loop:

# policies/ccpa/encryption.rego
package ccpa.encryption

import rego.v1

# All DynamoDB tables containing personal data must have
# encryption at rest with a customer-managed KMS key
deny contains msg if {
  resource := input.resource_changes[_]
  resource.type == "aws_dynamodb_table"
  resource.change.after.tags.data_class == "personal-data"

  not resource.change.after.server_side_encryption[_].enabled
  msg := sprintf(
    "CCPA: Table '%s' contains personal data but lacks encryption at rest",
    [resource.address]
  )
}

# Personal data buckets must have lifecycle policies
deny contains msg if {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket"
  resource.change.after.tags.ccpa_control

  lifecycle_configs := [r |
    r := input.resource_changes[_]
    r.type == "aws_s3_bucket_lifecycle_configuration"
    r.change.after.bucket == resource.change.after.id
  ]

  count(lifecycle_configs) == 0
  msg := sprintf(
    "CCPA: Bucket '%s' has CCPA controls but no lifecycle policy",
    [resource.address]
  )
}

These policies run in your CI pipeline alongside your SOC 2 policies. A deploy that creates a DynamoDB table tagged as personal data without encryption? Blocked. An S3 bucket with CCPA tags but no lifecycle configuration? Blocked. The compliance check is automated, not a quarterly review.

The enforcement advantage

CalPrivacy enforcement actions are accelerating. The agency has moved from educational letters to six- and seven-figure fines. When an investigation lands on your desk, the question isn't "are you compliant?" — it's "prove it."

Here's what IaC-based compliance evidence looks like versus the alternative:

  • Who approved this control? Dashboard: "Someone in IT configured it, we think in Q3." IaC: git blame modules/ccpa-consent/gpc-detection.tf — specific engineer, specific PR, specific approval.
  • When was it deployed? Dashboard: "We don't have exact timestamps." IaC: CI/CD pipeline logs show the exact deployment time, the commit SHA, and the plan output.
  • Has it changed since the last audit? Dashboard: "We'd have to check." IaC: terraform plan shows zero drift, or it shows exactly what drifted and when.
  • Does it work the same in every environment? Dashboard: "We manually configured each one." IaC: Same modules, same variables, same state. terraform apply is deterministic.

This isn't hypothetical advantage. Look at the Disney settlement: $2.75M because opt-out signals didn't propagate across services. If Disney's consent infrastructure had been deployed as infrastructure as code — a single module used by every service — the propagation gap wouldn't have existed. Every service would have inherited the same consent event subscription from the same module.

What's stopping you

The tools exist. Infrastructure-as-code, OPA, Sentinel, policy-as-code validation — none of this is new. The AWS services exist. Lambda, SQS, DynamoDB, S3 lifecycle rules, API Gateway — you're probably already using all of them.

What doesn't exist is the mapping layer. Nobody has taken the CCPA's requirements — all 50+ sections of specific technical obligations — and translated them into IaC modules with the right tagging, the right event-driven architecture, the right policy-as-code checks, and the right audit trail structure.

That's a specific, technical problem. And it's what we do.

We build and deploy infrastructure-as-code modules purpose-built for CCPA compliance: consent signal processing, DSAR pipelines, data retention enforcement, encryption controls, and vendor data flow management. Each module is tagged to specific CCPA sections, tested in CI, and designed to produce the audit evidence that regulators actually ask for.

Your security team already thinks in infrastructure-as-code. Your privacy controls should too.

// Free CCPA gap assessment — we'll audit your current privacy infrastructure and map the gaps between what CCPA requires and what your IaC actually deploys. 60 minutes, 48-hour gap report.