teriac server Risk and guardrails

Risk and guardrails

A risk level on every plan, notes on the code, change freezes, protected resources, scoped overrides, and a scan for leaked secrets in plan output.

New in 0.2.0. Upgrade teriac server to 0.2.0 or later to use the settings on this page.

These settings go in repos.yaml: at the top (every repo) or in a repo's entry (that repo only). When both are set:

Settings Combined how
Lists add up freezes, protected, policies.rules, risk.stateful_types The repo's entries are added to the server-wide ones
Only stricter secret_scan, policies.mode, risk.size_limits The server-wide value is the minimum; a repo can raise it, never lower it
Repo wins anything else, e.g. risk.production, risk.extra_approval, policies.override The repo's value replaces the server-wide one for that repo

A repo's own teriac.yaml is different: repo owners control it, so it can only tighten. See rules and permissions.

Risk score

Every plan with changes gets a risk level. It comes from fixed rules, so the same plan always gets the same score. The PR comment's Risk column shows the level, or None when nothing risky was found. The reasons are in each project's dropdown and on its check run.

Level When the plan
🔴 Critical destroys or replaces a stateful resource (database, bucket, KMS key, DNS zone, cluster, volume…) in production
🟠 High does that outside production; destroys anything in production; changes access (IAM, roles, policies) in production; changes more than 100 resources (size_limits.high); has high or critical policy findings
🟡 Medium destroys anything elsewhere; changes access or network resources; changes more than 25 resources (size_limits.medium); has medium policy findings; changes production at all
None anything else
risk:                                   # in repos.yaml (server-wide or per repo)
  production: ["prod/**", "live/prod/**"]   # default: folders named prod, production or prd
  stateful_types: ["mongodbatlas_cluster", "snowflake_database", "confluent_kafka_*"]  # added to the built-in list
  size_limits: { medium: 25, high: 100 }    # changed resources that make a plan medium / high
  extra_approval:
    high: { from: team:sre, count: 1 }      # high and critical plans need one SRE approval
    critical: { from: team:sre, count: 2 }  # critical plans need two
Key Default
production folders named prod, production or prd Project globs that count as production
stateful_types built-in list Resource types to treat as stateful, added to the built-in list. Globs work
size_limits { medium: 25, high: 100 } How many changed resources make a plan Medium or High. A repo can lower them, never raise them
extra_approval Approvals a plan needs on top of the project's own. Each applies at its level and above, so high covers critical plans too

What counts as stateful

Built in:

  • databases and clusters: RDS, Aurora, DynamoDB, Cloud SQL, Cosmos DB, Redis, OpenSearch, Kafka…
  • storage: S3, GCS, Azure storage, EBS, EFS, disks
  • keys and secrets: KMS, Key Vault, Secrets Manager
  • DNS zones
  • Kubernetes clusters, namespaces and volumes
  • backup vaults and CloudTrail

stateful_types adds your own.

Policy findings raise it too

Each policy rule has a severity, and findings feed the score. A finding from a critical rule makes the plan at least High.

Notes on the code

The check run also annotates the code. Every resource block that will be destroyed or replaced, every protected resource the plan touches, and every policy finding shows up on its line in the PR's Files changed tab. Reviewers see the risk where the change is.

Change freezes

Stop applies during launches, holidays or weekends. freezes is a list of windows:

freezes:
  - { name: weekend, projects: ["prod/**"], schedule: "0 15 * * 5", hours: 63, timezone: Europe/Berlin, override: [team:sre] }  # Fri 15:00 → Mon 06:00
  - { name: holidays, from: "2026-12-23", to: "2027-01-02" }   # whole days, both included; no override: nobody can skip it
Key
name required Shown when an apply is refused
projects every project Project globs
schedule + hours A recurring window: starts on the cron schedule and lasts this many hours (1 to 336)
from + to Or a date range, YYYY-MM-DD, whole days, both included
timezone UTC IANA name, e.g. Europe/Berlin
override nobody Who may apply anyway with --override

Use either schedule + hours or from + to in one window, not both.

During a freeze, plans still run, but applies and state edits (import, state rm, …) are refused, with the time the freeze ends.

Protected resources

Resources that must never be destroyed or replaced, whatever the plan says. protected is a list:

protected:
  - { projects: ["prod/**"], resources: ["aws_db_instance.*", "aws_kms_key.*"], override: [team:sre] }
  - { resources: ["aws_s3_bucket.audit_logs"] }      # every project; no override: nobody can skip it
Key
resources required Address globs. They match inside modules too: aws_db_instance.* covers module.db.aws_db_instance.main[0]
projects every project Project globs
override nobody Who may destroy or replace them anyway with --override

A plan that would destroy or replace one is refused at apply, and the block is marked on the code.

Overrides

Each freeze window and each protected entry has its own override list. Policy findings use policies.override. Someone on a matching list can apply anyway:

teriac apply -p live/prod/db --override
  • --override skips only the entries whose list includes you. Being on the security team's policy list doesn't get you past an SRE freeze.
  • An entry without an override list can't be skipped by anyone.
  • When something is still blocking, the reply says who can override it.
  • Every use is audited.

Plans built on Terragrunt mock_outputs can never be applied, not even with --override. For a true emergency, see emergency access.

Leaked-secret scan

Before any output reaches a PR comment, a check run or the dashboard, teriac looks for things that look like secrets:

  • AWS access keys and private keys
  • GitHub, GitLab, Slack and npm tokens, and Slack webhooks
  • Google API keys, Stripe keys, JWTs and Azure storage keys
  • password = "…" in plan lines

Terraform's own (sensitive value) is left alone.

secret_scan:
  mode: redact                          # redact (default) | fail | off
  patterns:                             # your own, optional
    - { name: acme-token, regex: "acme_[0-9a-f]{32}" }
Mode What happens
redact (default) Each match is replaced with ‹redacted: aws-access-key›, and a warning is added
fail Also refuses the plan, so it can't be applied until the secret is gone
off No scan

A repo entry can raise redact to fail, never lower it. Every find is audited. The scan covers plan, apply and command output.

Putting it together

A production setup might look like this:

risk:
  production: ["live/prod/**"]
  extra_approval:
    high: { from: team:sre, count: 1 }        # high and critical
    critical: { from: team:sre, count: 2 }
freezes:
  - { name: weekend, projects: ["live/prod/**"], schedule: "0 15 * * 5", hours: 63, timezone: Europe/Berlin, override: [team:sre] }
protected:
  - { projects: ["live/prod/**"], resources: ["aws_db_instance.*", "aws_kms_key.*"], override: [team:sre] }
  - { resources: ["aws_s3_bucket.audit_logs"] }
secret_scan:
  mode: fail
policies:
  mode: enforce
  builtin: [security]
  override: [team:security]

Every key is also in the repos.yaml reference.