Plugin-framework provider covering projects, environments, applications, Compose stacks, managed databases, domains, mounts, ports, redirects, basic auth, registries, SSH keys, certificates and backup destinations, over Dokploy's tRPC-over-REST API. The shim package exposes the provider to other Go modules, which is how pulumi-dokploy bridges it.
95 KiB
name, description, license, metadata
| name | description | license | metadata | ||||
|---|---|---|---|---|---|---|---|
| tfpolicy-author | Expert agent for authoring Terraform Policies — from natural-language requirements or Sentinel source. Covers the full workflow write new policies, convert existing Sentinel policies, parameterize with inputs, structure cross-resource checks, apply operation scoping, and produce remediation-focused error messages. | MPL-2.0 |
|
tfpolicy-author
Description
Expert agent for writing Terraform Policies — from either a natural-language requirement or an existing Sentinel .sentinel source. Covers the full authoring and conversion workflow: translate requirements or Sentinel logic into resource_policy, module_policy, or provider_policy HCL with correct cross-resource patterns, operation scoping, core:: functions, parameterized inputs, and remediation-focused error messages.
Note: This skill supersedes
sentinel-to-tfpolicy. All Sentinel conversion knowledge is now consolidated here to ensure consistent behaviour when generating policies from Sentinel sources.
Use When
- The user describes an enforcement rule and wants a
.policy.hclfile ("block public RDS", "require encryption", "deny instance types outside an allowlist"). - The user has a Sentinel
.sentinelfile (or snippet) and wants the Terraform Policy equivalent. - The user is migrating a Sentinel policy library to tfpolicy and needs per-policy assessment.
- The user is writing a new
resource_policy,module_policy, orprovider_policyblock. - The user is asking about tfpolicy language features —
filter,locals,enforce,input,operations,prior_attrs,core::*functions, list comprehensions, version constraints, time/date functions. - The user is asking how to structure a policy that depends on related resources via
core::getresources()orcore::getdatasource(). - The user is comparing Sentinel and tfpolicy capabilities ("can I express X in tfpolicy?").
Do not use this skill when:
- The user is writing or debugging a
.policytest.hcltest file — usetfpolicy-test.
Capabilities
1. Write Policies from User Intent
Turn natural-language requirements into resource_policy, module_policy, or provider_policy blocks with appropriate filters, locals, enforce blocks, and remediation-focused error messages.
2. Convert Sentinel Policies to Terraform Policy
Translate Sentinel constructs into tfpolicy equivalents, flag non-convertible patterns with practical alternatives, produce idiomatic .policy.hcl from existing Sentinel sources, and apply a quality label to each conversion.
3. Apply Operation Scoping Correctly
Use operations = ["create", "update", "delete"] and prior_attrs.<name> to scope policies to the right plan actions and read pre-change state when relevant.
4. Parameterize with input Blocks
Replace hardcoded allowlists, version constraints, and thresholds with input blocks so policy sets can override values per environment.
5. Structure Cross-Resource Checks Safely
Use core::getresources() at the top level for plan-time joins, with value-based filters when the filter is a known literal or existing ID. Use inline core::getresources() inside resource_policy for apply-time parent+child lookups, when the filter depends on the current resource's own attribute (e.g. {bucket = attrs.id}). Consult the decision table (line 232) to choose the correct pattern; both top-level and inline are first-class options for their respective scenarios. Understand when cross-references will be unresolved at plan time.
6. Surface Runtime Pitfalls Up Front
Steer the user away from documented runtime hazards — meta.address is undefined in resource_policy, core::try() defaults can silently mask non-compliant resources, sets must be converted to lists for indexing, multi-line boolean expressions break the parser, etc.
Knowledge Base
Policy Types
resource_policy "<resource_type>" "<policy_name>" { }
module_policy "<module_pattern>" "<policy_name>" { }
provider_policy "<provider_pattern>" "<policy_name>" { }
Core Structure
resource_policy "aws_ebs_volume" "encryption_check" {
# Optional: pre-filter resources before evaluation
filter = attrs.encrypted != null
# Optional: locals for readable logic
locals {
encrypted = core::try(attrs.encrypted, false)
}
# One or more enforce blocks
enforce {
condition = local.encrypted == true
error_message = "EBS volumes must have encryption enabled."
}
}
Available Attribute Surfaces
| Surface | Available in | Notes |
|---|---|---|
attrs.* |
resource / module / provider | Planned values for the current target. Wrap optional fields in core::try(). |
prior_attrs.* |
resource_policy with operations ⊉ ["create"] |
Pre-change values. Use for delete and update scopes. |
meta.provider_type |
resource_policy | e.g. "aws". Useful for cross-provider wildcard rules. |
meta.tfe_workspace.tags["<name>"] |
resource_policy only | Workspace-scoped routing (env, team, etc.). ❌ Not available in module_policy or provider_policy. |
meta.address |
❌ | UNDEFINED in resource_policy real-plan evaluation. Never interpolate it into error_message. |
input.<name> |
all | Values from input {} blocks; overridable per policy set. |
Operation Scoping and prior_attrs
# Skip destroy
resource_policy "tfe_workspace" "require_tags" {
operations = ["create", "update"]
enforce {
condition = core::length(core::try(attrs.tag_names, [])) > 0
error_message = "Workspace must have at least one tag."
}
}
# Delete-gate
resource_policy "tfe_workspace" "deny_delete_without_tag" {
operations = ["delete"] # prior_attrs available when "create" not in operations
locals {
prior_tag_names = core::try(prior_attrs.tag_names, [])
}
enforce {
condition = core::contains(local.prior_tag_names, "delete")
error_message = "Add 'delete' tag before destroying the workspace."
}
}
Rules:
operations = ["create", "update"]— fires on create/update, skips destroy.operations = ["delete"]— fires only on destroy;prior_attrsholds pre-change state.operations = ["update"]— fires only on updates;prior_attrsavailable.- Default (no
operations) = create and update (never destroy). prior_attrsis only accessible when"create"is NOT inoperations.
input Blocks — Parameterization
input "allowed_instance_types" {
type = list(string)
default = ["t3.micro", "t3.small", "t3.medium"]
}
resource_policy "aws_instance" "allowed_types" {
enforce {
condition = core::contains(input.allowed_instance_types, attrs.instance_type)
error_message = "Instance type '${attrs.instance_type}' is not in the allowed list."
}
}
Policy sets can override input defaults without editing the policy file. Use input for: allowlists, blocklists, version constraints, numeric thresholds — values an operator may need to tune per environment.
Rule — input vs hardcoded locals:
- Use
input {}only for operator-tunable values: allowlists, blocklists, thresholds, time windows. If a value varies per environment or policy set, it belongs ininput. - Use
localsor inline literals for invariant enforcement constants — values that are part of the policy logic itself and should NOT be overridden (e.g. a fixed list of well-known dangerous ports defined by a security standard, a required protocol name). - Do NOT promote fixed enforcement constants to
inputunless the requirement explicitly says they are configurable.
core:: Functions — Common Idioms
-
Null safety:
core::try(attrs.field, default)— single layer; don't nest. 🔴 MANDATORY: always use the two-step pattern below when the attribute may be explicitlynull. -
Membership:
core::contains(list, value)— for lists. For string substring usecore::contains_substring. -
Strings:
core::startswith,core::endswith,core::contains_substring,core::regex(throws on no match — wrap incore::try),core::split(separator, string)(use withcore::parseint()for numeric decomposition — seeverified-syntax.mdSection 2 for full examples). ❌ Never use+for string concatenation —+is numeric addition only; using it with strings throwsError: Unsuitable value for left operand: a number is required. ✅ Use"${local.var}"string interpolation instead: e.g."table/${local.table_name}"not"table/" + local.table_name. -
Aggregates:
core::length(list_or_map).❌core::alltrue()andcore::anytrue()DO NOT EXIST in tfpolicy runtime — usecore::length()with list comprehension:core::length([for b in list : b if b]) > 0instead ofanytrue,core::length([for b in list : b if !b]) == 0instead ofalltrue. -
Ranges:
core::range(limit)→[0, 1, …, limit-1];core::range(lower, upper)→[lower, lower+1, …, upper-1];core::range(lower, upper, step)→ step-incremented list fromlowerup to (but not including)upper. Works with hardcoded integer literals. ⚠️ With dynamicattrs.*integer values (e.g.attrs.from_port,attrs.to_port)core::range()silently returns an empty list in the policytest framework — prefer the count approach for port-range policies (seeverified-syntax.mdMistake 23). -
Time:
core::timestamp(),core::formatdate("EEEE", core::timestamp())(weekday, UTC),core::parseint(core::formatdate("HH", core::timestamp()), 10)(hour, UTC). -
Semver:
core::semverconstraint(version, "~> 4.67.0")— supports=,>=,<,~>, range,!=. Always wrap incore::try(..., false)to handle non-semver or unparseable version strings gracefully. ⚠️ Prefer this overcore::split+core::parseintfor all version range checks converted from Sentinel string comparisons — manual integer parsing fails silently for non-numeric version suffixes and null/empty inputs.🔴 NEVER place
core::semverconstraintdirectly in afilter =expression. Even with!= nulland!= ""guards in the same expression,semverconstraintis evaluated regardless of short-circuit ordering in thefiltercontext and throws a parse error when the version string is malformed (e.g. a non-semver string like"x.y") ornull/empty. Always move it intolocalsand wrap withcore::try(..., false):# ❌ Wrong — crashes when version string is non-semver, null, or empty: filter = core::try(attrs.version, null) != null && core::try(attrs.version, "") != "" && core::semverconstraint(core::try(attrs.version, "0.0"), "< 2.0") # ✅ Correct — filter guards null/empty only; semverconstraint lives in locals: filter = core::try(attrs.version, null) != null && core::try(attrs.version, "") != "" locals { version = core::try(attrs.version, "") # core::try wraps semverconstraint to safely handle non-semver strings → false is_old = core::try(core::semverconstraint(local.version, "< 2.0"), false) # When version is old, enforce the required attribute; when version >= 2.0, always compliant is_compliant = !local.is_old || local.required_attr_set } -
Null safety — two-step pattern (MANDATORY for any attribute that may be explicitly
null):core::try(attrs.field, default)triggers the fallback only when the attribute access throws an error (key absent). When an attribute is explicitly set tonull,core::tryreturnsnull— not the default. This is a silent pitfall:core::length(null)crashes withInvalid value for "collection" parameter;null == falseevaluates asnull(nottrue), causing enforce to trigger unexpectedly.Always use the explicit two-step pattern:
# Collection attribute (list/map) — safe pattern: field_raw = core::try(attrs.field, null) field = local.field_raw != null ? local.field_raw : [] # ✅ Safe to pass to: core::length(), core::contains(), for expressions # Scalar boolean attribute — safe pattern: flag_raw = core::try(attrs.flag, null) flag = local.flag_raw == null ? false : local.flag_raw # ✅ Safe to use in: condition = !local.flag, condition = local.flag == falseFor
filterexpressions that must exclude bothnulland empty-string values:filter = core::try(attrs.field, null) != null && core::try(attrs.field, "") != "". The two-step rule applies at every nesting level. When a scalar is accessed through a nested path (e.g.core::try(local.list[0].scalar_attr, default)), apply the same pattern: if the attribute may benull, usecore::try(..., null)and normalize explicitly. -
JSON:
core::jsondecode(string)— parses a JSON string into an object/list.core::jsonencode(value)— encodes a value as a JSON string. ❌json::unmarshaldoes not exist — usecore::jsondecodeinstead. -
Nested block attribute schema — object vs list: Terraform provider schemas define nested blocks as either a list of objects (
[{ ... }]) or a single object ({ ... }). Always check the provider schema before accessing nested attributes:- List block (e.g.
encryption_config = [{ provider = [{ key_arn = "..." }] }]): access via indexattrs.encryption_config[0].provider[0].key_arn. Usecore::try(attrs.field, [])andfield[0].subattr. - Object block (e.g.
redirect = { port = "443", protocol = "HTTPS" }): access directlyattrs.redirect.port. Usecore::try(attrs.redirect.port, ""). - ❌ Never call
core::length()on an object —core::lengthrequires a list, map, or tuple. Callingcore::length(attrs.redirect)whenredirectis an object crashes withcollection must be a list, a map or a tuple. To check presence of an object block, usecore::try(attrs.redirect, null) != nullinstead. - ❌ Never iterate over an object block with a
forexpression.for r in core::try(attrs.block, [])— whenattrs.blockis an object, this iterates over the object's scalar values, not the object as an element.core::try(r.sub_attr, "")on a string silently returns"". Use direct attribute access instead:core::try(attrs.block.sub_attr, ""). - When Sentinel mocks use
redirect = { ... }(object), the TFPolicy test mock and policy must treat it as an object. When Sentinel mocks useredirect = [{ ... }](list), use list indexing. Mismatching the shape causes either runtime crashes or silent wrong results.
- List block (e.g.
IAM Policy Checks
When enforcing IAM content rules (e.g. "no admin *:* allowed", "no wildcard actions"), you MUST cover all 4 inline policy resource types — not just aws_iam_policy.
🔴 Always write 4 resource_policy blocks for IAM content enforcement:
| Resource type | When it's used | policy attribute |
|---|---|---|
aws_iam_policy |
Standalone managed policy | JSON string — core::jsondecode(core::try(attrs.policy, "{}")) |
aws_iam_role_policy |
Inline policy attached to a role | JSON string — same pattern |
aws_iam_user_policy |
Inline policy attached to a user | JSON string — same pattern |
aws_iam_group_policy |
Inline policy attached to a group | JSON string — same pattern |
Rule: A policy that only checks aws_iam_policy misses inline policies on roles/users/groups. An admin could bypass it by using aws_iam_role_policy instead of aws_iam_policy.
Note on aws_iam_policy_document: This resource type requires careful distinction between two different use cases:
- As a Terraform
datablock (the common case in real Terraform configurations) —data_policydoes not exist in tfpolicy (Mistake 31). Do NOT writedata_policy "aws_iam_policy_document". In this scenario, the policy document content is consumed by one of the 4 inline/managed policy resource types above, and enforcement should target those resource types. - As a
resourcein.policytest.hclmocks and when the Sentinel source reads it viatfstate/v2—resource_policy "aws_iam_policy_document"is valid and directly targets the document'sstatementattribute (lowercaseactions, notAction). When a Sentinel policy readsaws_iam_policy_documentfromtfstate, the correct TFPolicy conversion isresource_policy "aws_iam_policy_document"— do NOT substitute inline/managed policy resource types.
Decision rule: Check the Sentinel import statement. If the Sentinel uses tfstate/v2 to read aws_iam_policy_document resources, convert to resource_policy "aws_iam_policy_document". If the Sentinel reads the consuming resource (aws_iam_role_policy, etc.), target those 4 types instead.
# ✅ CORRECT — define the check logic once, repeat for all 4 resource types
# (Each resource_policy block is independent; locals are block-scoped)
resource_policy "aws_iam_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_role_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM role inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_user_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM user inline policies must not grant full admin privileges (*:* on *)."
}
}
resource_policy "aws_iam_group_policy" "no_admin_privileges" {
locals {
statements = core::try(core::jsondecode(core::try(attrs.policy, "{}")).Statement, [])
admin_stmts = [for s in local.statements : s if core::lower(core::try(s.Effect, "Allow")) == "allow" && core::contains(core::try(s.Action, []), "*") && core::contains(core::try(s.Resource, []), "*")]
}
enforce {
condition = core::length(local.admin_stmts) == 0
error_message = "IAM group inline policies must not grant full admin privileges (*:* on *)."
}
}
Cross-Resource Lookups
🛑 Self-check — run this BEFORE writing any resource_policy involving a companion/child resource:
"What is the enforcement goal?"
- "Every existing instance of
<child_type>must have correct attributes" (e.g., "everyaws_lb_listenermust use HTTPS") → anchorresource_policydirectly on the child type- "Every parent must have at least one compliant child" (e.g., "every S3 bucket must have a
public_access_blockwith all four flags set") → anchorresource_policyon the parent; a child-only policy silently misses parents with no child in the plan
Decision table — choose the correct pattern first:
| Enforcement goal | Who gets the resource_policy? |
Pattern |
|---|---|---|
"Every existing instance of <child_type> must have correct attributes" (e.g., "every aws_lb_listener must use HTTPS") |
Child type directly | Direct child resource_policy |
"Every parent must have at least one compliant child" (e.g., "every S3 bucket must have a public_access_block with all four flags set") |
Parent resource | Apply-time: inline core::getresources("child", {bucket = attrs.id}) inside resource_policy |
"Parent has a companion identified by a known literal / stable attribute" (e.g., versioning bucket name matches attrs.bucket) |
Parent resource | Plan-time: top-level core::getresources("companion", {}) + HCL for-loop filter inside resource_policy |
⚠️ Critical: Never write a resource_policy "child_type" to enforce "every parent must have a child" — if the child resource is absent entirely, the policy never fires for that parent and the violation is silently missed.
🔴 Named companion resources — ALWAYS use the parent as resource_policy anchor when the goal is presence enforcement:
The following Terraform resource types are companion-only — a parent resource can exist in a plan without them. NEVER anchor a standalone resource_policy on these types when checking for their presence:
❌ Never use as standalone resource_policy anchor (for presence) |
✅ Always anchor on | Lookup key |
|---|---|---|
aws_s3_bucket_public_access_block |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_acl |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_logging |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_server_side_encryption_configuration |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) |
aws_s3_bucket_versioning |
aws_s3_bucket |
plan-time top-level + v.bucket == attrs.bucket filter |
aws_s3_bucket_policy |
aws_s3_bucket |
{ bucket = attrs.id } (apply-time inline) — even for content enforcement (e.g., checking Principal: "*"), inspect the child's policy attr via core::jsondecode() inside the parent block |
This pattern applies to all resource families, not just S3. Non-S3 examples: when checking "every VPC has a flow log" → anchor on
aws_vpcwith inlinecore::getresources("aws_flow_log", {vpc_id = attrs.id}); when checking "every LB has at least one compliant listener" → anchor onaws_lbwith inlinecore::getresources("aws_lb_listener", {load_balancer_arn = attrs.arn}).
Why this matters: Even if the Sentinel source iterates over the companion type, TFPolicy must anchor on the parent. A Terraform plan can declare an
aws_s3_bucketwith NO companion resource — anchoring on the companion silently misses that bucket entirely.
⚠️ Parent-anchor means parent-ONLY: Once the decision table says "parent resource" is the anchor, generate resource_policy blocks exclusively on the parent type. Do NOT also generate standalone resource_policy blocks for companion types alongside the parent blocks. Adding companion blocks in parallel:
- still silently misses parents that have no companion
- double-reports violations
All enforcement logic — including every companion check — must live inside the parent resource_policy block, using inline or top-level lookups.
Plan-time pattern — use core::getresources() at the top level only when the filter value is a known constant (not derived from attrs.*). When the filter depends on the current resource's own attribute, use the inline pattern below instead. ❌ Top-level {} (empty filter) + for-loop or lookup map filtered by attrs.* inside resource_policy is the prohibited anti-pattern — see verified-syntax.md Mistake 13 for both variants.
⚠️ Exception — "companion absence is itself a violation": When the goal is to enforce that a companion resource exists AND has matching attributes (e.g. a parent resource must have a linked companion with a specific qualifying attribute set), the inline filter pattern produces false negatives: if the companion is absent or has a mismatching linking key, core::getresources() returns [], and !has_companion is true → a condition like !has_companion || check_attr == "VALUE" incorrectly passes. Use the top-level collect-then-filter pattern instead:
# Top-level: collect ALL companions globally, filter to those with the qualifying attribute,
# then check membership inside resource_policy.
locals {
all_companions = core::getresources("aws_companion_resource", {})
# Filter to companions that have the qualifying attribute set (e.g. non-empty elb)
qualifying_companions = [for c in local.all_companions : c
if core::try(c.qualifying_attr, null) != null && core::try(c.qualifying_attr, "") != ""]
# Collect the linking attribute values from qualifying companions only
companion_names = [for c in local.qualifying_companions : core::try(c.linking_attr, "")]
# Use qualifying companions (not all) to decide whether to evaluate
has_qualifying = core::length(local.qualifying_companions) > 0
}
resource_policy "aws_parent_resource" "example" {
# Only evaluate when qualifying companions exist in the plan
filter = local.has_qualifying
locals {
parent_name = core::try(attrs.name, "")
is_linked = core::length([for n in local.companion_names : n if n == local.parent_name]) > 0
# 🔴 Condition must be POSITIVE: both linked AND check_attr correct
# Do NOT use: !is_linked || check_attr == "EXPECTED" ← this passes when companion absent
is_compliant = local.is_linked && core::try(attrs.check_attr, "") == "EXPECTED"
}
enforce {
condition = local.is_compliant
error_message = "..."
}
}
This pattern correctly detects:
- Companion absent entirely →
has_qualifying = false→filter = false→ parent skipped ✓ - Companion present but with no qualifying attribute →
qualifying_companions = []→has_qualifying = false→ parent skipped ✓ - Companion with wrong
linking_attrvalue →is_linked = false→is_compliant = false→ violation ✓ - Companion with correct linking and correct
check_attr→is_compliant = true→ passes ✓
⚠️ Choose filter scope carefully: filter = local.has_qualifying (qualifying companions only) skips all parents when no qualifying companions exist — this matches a Sentinel that returns early when no qualifying companions are found. If the Sentinel does NOT skip when non-qualifying companions exist (e.g. it evaluates parents even when only ALB/NLB attachments are present), use filter = core::length(local.all_companions) > 0 instead. With this broader filter, unlinked parents (is_linked = false) are evaluated and correctly fail is_linked && check_attr == VALUE.
🔴 Condition polarity rule: When using the top-level collect-then-filter pattern, always use a positive condition (is_linked && check_attr == VALUE), not a negative condition (!is_linked || check_attr == VALUE). The negative form silently passes any parent that is not linked — which is the opposite of the intended enforcement. With the positive form: unlinked parent → is_linked = false → is_compliant = false → violation (correct). With the negative form: unlinked parent → !false || any = true → is_compliant = true → passes (incorrect).
Apply-time pattern — when the filter value is the current resource's own attribute (e.g. bucket = attrs.id, or event_bus_name = attrs.name), use an inline core::getresources() call with the direct filter inside resource_policy. The filter cannot resolve at plan time (the attribute value is unknown until apply), but fully resolves once the resource is provisioned. The linking attribute may reference attrs.id, attrs.arn, or attrs.name — check the child resource's Terraform Registry docs to determine which one.
S3 cross-resource note: Always use
attrs.id(notattrs.bucket) when filtering S3 child resources such asaws_s3_bucket_public_access_block,aws_s3_bucket_acl,aws_s3_bucket_server_side_encryption_configuration, andaws_s3_bucket_policy. Terraform providers set the child resource's linking attribute (bucket) to the parent bucket's.id. Usingattrs.idensures the filter matches the actual value stored in the child resource's plan.
# NOTE: This policy contains a cross-resource reference that will not resolve during plan time,
# but the policy will run successfully during apply time.
resource_policy "aws_s3_bucket" "s3_block_public_access" {
locals {
public_access_block = core::getresources("aws_s3_bucket_public_access_block", {
bucket = attrs.id
})
block_public_acls = core::try(local.public_access_block[0].block_public_acls, false)
block_public_policy = core::try(local.public_access_block[0].block_public_policy, false)
ignore_public_acls = core::try(local.public_access_block[0].ignore_public_acls, false)
restrict_public_buckets = core::try(local.public_access_block[0].restrict_public_buckets, false)
}
enforce {
condition = local.block_public_acls && local.block_public_policy && local.ignore_public_acls && local.restrict_public_buckets
error_message = "S3 bucket does not have all public access block settings enabled."
}
}
Error Message Rules
- ✅ Static strings:
"S3 buckets must enable versioning." - ✅ Safe interpolation:
"Instance type '${attrs.instance_type}' is not allowed." - ❌ Never interpolate
${meta.address}— it is UNDEFINED inresource_policyand crashes at runtime.tfpolicy testwill NOT catch this; only a realterraform planwill. - Use
error_messagefor all enforceable violations (condition can be false). - Use
info_messageonly in non-convertible stub blocks wherecondition = trueand no real enforcement is possible. Never useinfo_messagein a block that can actually fail a resource.
Comment Conventions — # LIMITATION: vs # NOTE:
These two markers have distinct meanings — do not interchange them:
# LIMITATION:— tfpolicy cannot fully express or enforce the requirement. Part of the original Sentinel logic had to be omitted or approximated. Always accompanies aSimplifyorNot convertiblequality label.- Example: "LIMITATION: The bucket policy IAM document check is non-convertible — tfpolicy does not expose reference metadata."
# NOTE:— The enforcement is complete but has a runtime caveat that does not reduce coverage.- Example: "NOTE: This policy contains a cross-resource reference that will not resolve during plan time, but will run successfully during apply time."
Output Structure Rules — Consistency-Critical
These rules eliminate the most common sources of non-deterministic output between runs:
Rule 1 — One resource_policy block per resource type.
Multiple checks on the same resource type MUST be combined into a single resource_policy block using separate locals and multiple enforce blocks. Never split checks into two resource_policy blocks for the same type.
# ✅ Correct — two checks, one block
resource_policy "aws_ecs_task_definition" "secure_networking" {
filter = core::try(attrs.network_mode, "") == "host"
locals {
containers = core::try(core::jsondecode(attrs.container_definitions), [])
non_privileged = [for c in local.containers : c if core::try(c.privileged, false) != true]
insecure_user = [for c in local.containers : c if core::try(c.user, "") == "" || core::try(c.user, "") == "root"]
}
enforcement_level = "advisory"
enforce {
condition = core::length(local.non_privileged) == core::length(local.containers)
error_message = "ECS task definition containers must not run as privileged."
}
enforce {
condition = core::length(local.insecure_user) == 0
error_message = "ECS task definition containers must define a non-root user."
}
}
# ❌ Wrong — same resource type split across two blocks
resource_policy "aws_ecs_task_definition" "check_privileged" { ... }
resource_policy "aws_ecs_task_definition" "check_user" { ... }
Rule 2 — Non-convertible checks are comments, not stub blocks.
When a specific check cannot be converted (reference metadata, etc.), document it as a # LIMITATION: comment inside the existing resource_policy block — do NOT create a separate resource_policy block of the same type as a stub. A dedicated stub block (with condition = true) is only appropriate when the entire policy has no convertible checks at all.
Rule 3 — Per-resource enforcement for all checks.
Do NOT aggregate across all resources of a type at the plan level (e.g. "at least one trail in the whole plan is compliant = pass all"). Each resource_policy must evaluate each individual resource independently. tfpolicy's evaluation model is per-resource — plan-level aggregation via top-level core::getresources() to pass/fail based on a count across all resources is not idiomatic and produces non-deterministic results.
# ✅ Correct — each aws_cloudtrail resource evaluated independently
resource_policy "aws_cloudtrail" "s3_dataevents_enabled" {
locals { ... }
enforce {
condition = local.is_compliant
error_message = "This CloudTrail trail must log S3 data events."
}
}
# ❌ Wrong — plan-level aggregation ("at least one compliant trail")
locals {
all_trails = core::getresources("aws_cloudtrail", {})
num_compliant = core::length([for t in local.all_trails : t if ...])
any_compliant = local.num_compliant > 0
}
resource_policy "aws_cloudtrail" "s3_dataevents_enabled" {
enforce {
condition = local.any_compliant # Wrong: passes every trail if any one trail is compliant
error_message = "..."
}
}
Sentinel → Terraform Policy Conversion
Use this section when the input is an existing Sentinel .sentinel file. Follow Steps 1–5 below; apply the authoring guidance above when generating the policy HCL.
Sentinel → Terraform Policy construct mapping
| Sentinel | Terraform Policy |
|---|---|
import "tfplan/v2" |
Native policy context — no import needed |
tfplan.resource_changes loops |
Usually one resource_policy per resource type; split multi-type Sentinel rules when needed |
filter tfplan.resource_changes |
Resource type in the policy declaration plus optional filter for attribute-based preconditions |
as address, rc |
attrs.* and meta.provider_type for the current resource. ⚠️ meta.address is UNDEFINED — do not use it. |
rc.change.after.<attr> |
attrs.<attr> |
rc.change.before.<attr> |
prior_attrs.<attr> — available when operations does NOT include "create" |
rc.change.actions is ["delete"] |
operations = ["delete"] — fires only on destroy |
rc.change.actions is not ["delete"] |
operations = ["create", "update"] — skips destroy |
param allowed_list default [...] |
input "allowed_list" { type = list(string); default = [...] } |
time.now.weekday_name |
core::formatdate("EEEE", core::timestamp()) — UTC weekday name |
time.now.hour |
core::parseint(core::formatdate("HH", core::timestamp()), 10) — UTC hour as int |
strings.has_prefix(s, p) |
core::startswith(s, p) — arg order: full string first, prefix second (same as Sentinel). Note: meta.version in provider_policy is the resolved version (e.g. "6.50.0"), NOT the constraint string. Sentinel's strings.has_prefix(p.version_constraint, ">") is non-convertible — tfpolicy does not expose the constraint string. Use core::semverconstraint(meta.version, ...) instead. |
strings.has_suffix(s, suffix) |
core::endswith(s, suffix) |
rc.provider_name |
meta.provider_type |
all/any expressions |
List comprehensions with filtered counts — neither core::alltrue() nor core::anytrue() exist. Use core::length([for x in list : x if !x]) == 0 for "all true" and core::length([for x in list : x if x]) > 0 for "any true". |
else clause |
Multiple enforce blocks |
maps.get(obj, key, default) |
core::try(obj.key, default) |
collection.reject(items, predicate) |
List comprehension with if — [for item in items : item if !<predicate>] |
collection.reject(items, predicate) is empty |
🔴 ALL condition — every element satisfies the predicate. ⚠️ Do NOT convert this to an ANY condition (length([for item in items : item if <predicate>]) > 0) — that inverts the semantics. The correct pattern is: non_compliant = [for item in items : item if !<predicate>] / is_compliant = core::length(local.non_compliant) == 0. Example: collection.reject(log_opts, func(o) { o.enabled and o.log_type is "AUDIT_LOGS" }) is empty → non_compliant = [for o in local.log_opts : o if !(core::try(o.enabled, false) && core::try(o.log_type,"") == "AUDIT_LOGS")] / condition = core::length(local.log_opts) > 0 && core::length(local.non_compliant) == 0. When the predicate tests a boolean flag, verify the Sentinel default value and match it in core::try(attr, <same_default>). |
collection.filter(items, predicate) |
List comprehension with if — [for item in items : item if <predicate>] |
strings.split(sep, str) |
core::split(separator, string) — splits a string into a list of substrings at each occurrence of separator. Example: core::split("-", "80-443") → ["80","443"]. ⚠️ For version range checks, prefer core::semverconstraint() over core::split + core::parseint — see the Semver note in the core:: Functions section. |
When converting Sentinel
summary {}output orprint()statements, do not reproduce address-listing behavior. Terraform Policy diagnostics already identify the failing resource — prefer remediation-focused messages instead.
Sentinel features that ARE convertible
- Time-based rules —
core::timestamp()+core::formatdate()+core::parseint()cover Sentinel'stimeimport. All values are UTC; document that assumption in policy comments. paramblocks — direct equivalent:inputblocks withtypeanddefault.rc.change.beforefor update/delete —prior_attrsis available whenoperationsdoes NOT include"create".- Integer range checks — Sentinel policies that check whether all ports within
[from_port, to_port]are authorized CAN be converted. Use the count approach: filterauthorized_portsto those within the range and compare the count toto_port - from_port + 1. Do not usecore::range()with dynamicattrs.*values. Seeverified-syntax.mdMistake 23. tfconfig/v2reference count — each resource reference is stored twice in.references(once asresource.name, once asresource.name.id). When simplifying a reference-count check to a directcore::length(attrs.attribute)check, halve the threshold:references > 2→core::length(attrs.attribute) >= 2.
Plan-Time vs Apply-Time Policies
Terraform Policy can enforce controls at plan time (before terraform apply) or apply time (during terraform apply). Most policies are plan-time, but cross-resource lookups that depend on newly-created resource IDs only fully resolve at apply time.
| Scenario | Enforcement time | Quality label |
|---|---|---|
| Single-resource attribute checks | Plan time | Perfect / Good |
| Cross-resource lookup where the filter value is a known literal or existing resource ID | Plan time | Good |
Cross-resource lookup where the filter value is a newly-created resource ID (e.g. bucket = attrs.id for a bucket created in the same plan) |
Apply time | Good |
Reference metadata / graph traversal (res.config.attribute["references"]) |
Not convertible | — |
When a Sentinel policy uses cross-resource references with a value-based filter from a newly-created resource:
- Generate the policy using an inline
core::getresources()call with the direct filter insideresource_policy. - Add this note in the conversion report (not a limitation label): "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."
- Do not label this as Simplify or Not convertible — it is a valid Good conversion.
For cross-resource patterns, apply the registry check (Steps A and B) described in "Cross-Resource Lookups" above. The registry check fully determines the policy structure, regardless of whether the Sentinel's violations iterated the parent or the child type. Dependent child resource types must never have a standalone resource_policy block.
Cannot Convert (Explain the Alternative)
- Mocking/testing infrastructure (
import "tfconfig-functions") — tfpolicy uses.policytest.hcl. See the tfpolicy-test skill. - Custom Sentinel imports — limited plugin support; use HTTP plugins or native functions if available.
- Sentinel simulator / built-in test framework — replace with
.policytest.hcltest files. - Cross-workspace data access — tfpolicy evaluates a single plan. Use workspace tags (
meta.tfe_workspace.tags) or external plugins. print()/ debug statements — no debug output mechanism; rely on conciseerror_message/info_messagetext only when it adds remediation context.- Stateful logic across evaluations — policies are stateless; use external systems via plugins if state is required.
rc.change.beforeoutside delete/update — for first-time creates there is no pre-state.- Cross-resource reference navigation via reference metadata (
res.config.attribute["references"],res.config.to) — tfpolicy does not expose which Terraform resource a value points to. When the Sentinel policy uses the resolved value of an attribute (not the reference path itself), convert usingcore::getresources()with a value-based filter — see "Plan-Time vs Apply-Time Policies" above. - Data source content inspection by address —
core::getdatasource()requires filter attributes and cannot query by Terraform address. - Complex resource-graph traversal via reference metadata — cannot traverse the Terraform resource graph by reference (e.g. "find all subnets that reference this VPC"). Only resolved attribute values are available. If the Sentinel policy traverses by resolved attribute value (e.g.
bucket = attrs.id), convert usingcore::getresources()with a value-based filter and mark as an apply-time policy — see "Plan-Time vs Apply-Time Policies" above.
⚠️ Partial reference dependence — do not skip the whole policy. Items 8 and 10 apply to the specific check that uses reference metadata, not the entire policy. If only some checks in a Sentinel policy rely on
res.config.attribute["references"]or graph traversal, convert the remaining checks as normal, apply the Simplify label to the overall policy, and document each skipped check in the report with: "This check was omitted — tfpolicy does not expose reference metadata (res.config.attribute["references"])." Only label the entire policy as Not convertible if its core enforcement logic is wholly dependent on reference metadata with no convertible remainder.
❌ Do not approximate reference-metadata checks with cross-resource JSON value matching. A common workaround is to retrieve all instances of a related resource via
core::getresources()and compare their serialized attribute values (e.g.attrs.policy == doc.json) as a proxy for "this resource references that data source." This is not a faithful conversion — it produces false negatives when the referenced resource is already deployed and absent from the current plan, and enforces a different semantic (value equality) than the original (structural reference). When the only check IS reference metadata, generate a stub policy instead:# <policy_name> — Non-Convertible (Reference Metadata) resource_policy "<resource_type>" "<policy_name>_stub" { enforce { condition = true info_message = "Automated enforcement not available: this policy requires reference metadata inspection which tfpolicy does not support. Manual compliance review required." } }
⚠️ Partial exception — direct content inspection via the parent bucket. The cross-resource approximation prohibition applies to comparing JSON across resources (e.g. fetching all
aws_iam_policy_documentoutputs and matching against a bucket policy). It does not prohibit inspecting a bucket's own policy content. When the Sentinel reference-metadata check is really enforcing content (e.g. "the bucket policy must not grant public read access"), convert it by anchoring onresource_policy "aws_s3_bucket", fetching the childaws_s3_bucket_policyviacore::getresources("aws_s3_bucket_policy", { bucket = attrs.id }), and inspecting itspolicyattribute viacore::jsondecode()inside the parent block. This follows the standard dependent-child pattern —aws_s3_bucket_policyalways requires a parent bucket. Label the overall policy Simplify (because the reference-path check is omitted) and add a# NOTE:that the content-based check achieves a similar security outcome.# ✅ Content-based alternative to reference-metadata check on bucket policies # NOTE: This policy contains a cross-resource reference that will not resolve during plan time, # but the policy will run successfully during apply time. resource_policy "aws_s3_bucket" "no_public_read_policy" { locals { bucket_policy = core::getresources("aws_s3_bucket_policy", { bucket = attrs.id }) policy_doc = core::try(core::jsondecode(core::try(local.bucket_policy[0].policy, "{}")), { Statement = [] }) statements_enriched = [for s in core::try(local.policy_doc.Statement, []) : { effect = core::lower(core::try(s.Effect, "Allow")), action = core::try(s.Action, []), principal_str = core::try(s.Principal, ""), principal_aws = core::try(core::try(s.Principal, {}).AWS, "") }] public_read_stmts = [for s in local.statements_enriched : s if s.effect == "allow" && (core::contains(s.action, "s3:GetObject") || core::contains(s.action, "s3:*") || core::contains(s.action, "*")) && (s.principal_str == "*" || s.principal_aws == "*")] } enforce { condition = core::length(local.public_read_stmts) == 0 error_message = "S3 bucket policy must not grant public read access (Principal: * with s3:GetObject or s3:*)." } }
Conversion quality labels
- Perfect — Same enforcement intent and behavior expressed directly in tfpolicy with no known semantic gap.
- Good — Preserves the important enforcement outcome using idiomatic tfpolicy structure (not a one-to-one translation).
- Simplify — Only part of the original Sentinel behavior can be reproduced; document the missing checks explicitly.
- Not convertible — tfpolicy lacks the runtime data or language features required for a safe translation.
Conversion Strategy
| Tier | Pattern | Examples | Approach |
|---|---|---|---|
| ✅ Easy | Single resource, direct attribute check | EBS encryption, RDS public access, ECS container insights, EKS audit logging, Lambda runtime, CloudTrail logging | Direct 1:1 conversion |
| ✅ Good | Cross-resource value-based lookup (resolved attribute IDs) | S3 + public-access-block, S3 + versioning, EventBridge + resource policy | Use inline core::getresources() with direct filter inside resource_policy; mark as apply-time policy if filter value is a newly-created resource ID |
| ⚠️ Simplify | Cross-resource logic with partial reference dependence | EC2 IMDSv2, security group coverage | Check explicit configuration only; document what is not checked; prefer known literal attributes over inferred relationships |
| ❌ Avoid | rc.change.before outside update/delete; reference-metadata navigation; data-source content inspection by address; complex graph traversal; mutable external state; strings.split() decomposition |
— | Recommend a redesign or treat as non-convertible |
Steps to Convert a Sentinel Policy
Step 1 — Parse the Sentinel Structure
Identify imports (tfplan, tfconfig, tfstate, custom), filter logic and resource selection, main and sub-rules, and enforcement level (advisory vs mandatory). Ask: What resources are being checked? Which attributes are validated? Does the policy depend on before/after diff? Are there cross-resource dependencies? Are data sources inspected? Does it rely on reference metadata or graph traversal?
Identify the enforcement target and the parent type: For any policy involving cross-resource dependencies, first perform the registry check (Steps A and B in "Cross-Resource Lookups" above) to identify which resource types are dependent children and which are parent types. Then use the Sentinel's violations expression to confirm the parent type — this is the type your resource_policy targets. Write resource_policy on the parent type. Every dependent child type is accessed exclusively via core::getresources() inside that parent block; all conditions on child resources are evaluated there, and all violations are reported on the parent. Do not write a standalone resource_policy on any resource type that the registry identifies as a dependent child — this rule holds regardless of whether the Sentinel's violations iterated the child or the parent, and regardless of whether the check is about a missing child or a misconfigured one.
Cross-resource policies — use the Terraform Registry to identify the parent, linking attribute, and filter value. For any child resource type involved in a cross-resource lookup, fetch its documentation at
https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown. Look for a(Required)argument referencing a parent resource in "Argument Reference" and confirm with the usage examples (.idvs.arn). The required argument name is the linking attribute; use the correspondingattrs.idorattrs.arnin thecore::getresources()filter. This applies to all resource families — not just S3.
Do not over-constrain the enforcement condition beyond the Sentinel intent. When the Sentinel checks "at least one item in a collection satisfies condition X", the correct tfpolicy translation is core::length([for item in local.items : item if <condition>]) > 0. Do NOT translate this to "every item must satisfy X" (i.e. core::length([for item in local.items : item if !<condition>]) == 0) unless the Sentinel explicitly enforces ALL items. Over-constraining the condition creates false violations for valid configurations that the Sentinel would pass.
Distinguish enforcement intent from Sentinel implementation detail. Sentinel code often accesses a collection element by index (e.g. origins[0]) as a traversal shortcut rather than an intentional "only check the first item" rule. Before encoding an index access as a scope restriction, ask:
- Is the indexed access repeated for all resources in a filter loop? (If so, it is a loop artifact, not a one-item intent.)
- Does the policy name or comment indicate intent that applies to "all" items?
- Would checking only the first item leave a real security gap?
- Is this collection a structurally singleton block in the provider schema (e.g.
max_items = 1) — in which case[0]is intentional?
When the answer to any of the first three questions is "yes" and the fourth is "no", convert the check to iterate all items in the collection, not just [0]. Document the interpretation in the requirements as: "Sentinel source accesses collection[0]; interpreted as checking all items to preserve full enforcement intent."
Step 2 — Assess Convertibility
Check for non-convertible patterns above. Document what cannot be converted and assign a quality label.
Step 3 — Map to tfpolicy Constructs
Use the mapping table above. In practice, focus on (in order): matching resource scope, translating attribute access and null handling, replacing collection helpers with list comprehensions, and splitting compound logic into locals + multiple enforce blocks.
Step 4 — Generate the policy
Follow the authoring guidance in the Knowledge Base sections above. Apply the cross-resource decision table and companion-anchor rules.
Error-message rules:
- ✅ Static strings, or safe
${attrs.fieldname}interpolation. - ❌ Never interpolate
${meta.address}— it is UNDEFINED inresource_policyand throwsError: Unsupported attributeat runtime for every evaluated resource.tfpolicy testwill not catch this; only a realterraform plan --policies=run will.
Step 5 — Document the Conversion
Include the quality label, test success rate (if tests written), any limitations or simplifications made, behavioral differences from Sentinel, and references to related documentation.
tfpolicy-author.md— guided first-policy walkthrough.tfpolicy-author.md— reusable patterns (attribute checks, allowlists, cross-resource enforcement, etc.).verified-syntax.md— verified syntax tables, runtime limitations, common-mistake corrections. Source of truth — defer to this file when this SKILL.md disagrees.
Usage Instructions — Write a New Policy from User Intent
Step 1 — Clarify requirements
- Which resources / modules / providers to target.
- The specific condition to enforce.
- Whether create, update, and/or destroy should be in scope (
operations). - Whether any value should be tunable per policy set (→
inputblock). - The desired error message and whether
attrs.*interpolation is helpful.
Step 2 — Design the structure
- Choose the policy type (resource / module / provider).
- Decide whether a wildcard label (
"*") is appropriate. - Plan the
filterfor performance and to exclude resources where the attribute is meaningfully absent. Two cases:- Attribute absent = resource out of scope (e.g. no
aclblock set at all → resource doesn't configure ACLs → skip it): usefilter = core::try(attrs.field, null) != null. - Attribute absent = AWS provider default applies (e.g.
encryptedabsent → AWS defaults tofalse→ resource is still in scope and may violate the policy): do not filter on null. Usecore::try(attrs.field, <aws_provider_default>)in theconditioninstead so absent resources are evaluated against the effective default.
- Attribute absent = resource out of scope (e.g. no
- Move complex predicates into
localsfor readability.
Step 3 — Generate the policy
- Wrap optional attributes in
core::try(). - Keep each boolean expression on a single line.
- Use multiple
enforceblocks when you want independent diagnostics. - Never interpolate
${meta.address}inerror_message.
Step 4 — Document the policy
- Header comment with description, resources checked, and any compliance reference.
- Note operation scope and any parameterization.
Worked Example
User request: "Ensure all S3 buckets have versioning enabled."
# Ensure S3 Bucket Versioning is Enabled
#
# Enforces that all AWS S3 buckets have versioning enabled to protect
# against accidental deletion and enable recovery.
#
# Resources checked:
# - aws_s3_bucket with inline versioning configuration
# - aws_s3_bucket_versioning (standalone resource pattern)
resource_policy "aws_s3_bucket" "versioning_enabled" {
filter = attrs.versioning != null
locals {
versioning_enabled = core::try(attrs.versioning[0].enabled, false)
}
enforce {
condition = local.versioning_enabled == true
error_message = "S3 buckets must set versioning.enabled = true to protect against accidental deletion."
}
}
resource_policy "aws_s3_bucket_versioning" "versioning_enabled" {
locals {
versioning_status = core::try(attrs.versioning_configuration[0].status, "Disabled")
}
enforce {
condition = local.versioning_status == "Enabled"
error_message = "S3 bucket versioning resources must have status 'Enabled'. Current status: '${local.versioning_status}'."
}
}
Best Practices
Policy Writing
- Use descriptive policy names.
- Add a comprehensive header comment with description, resources checked, and compliance references.
- Always use
core::try()for optional attributes. - Break down complex logic with
locals. - Provide actionable, remediation-focused error messages.
- Cover all variations of a resource family (e.g. AWS security groups:
aws_security_group,aws_security_group_rule,aws_vpc_security_group_ingress_rule,aws_default_security_group). - Use
filterto skip resources that don't apply (saves work and avoids false positives). - Cache
core::getresources()results in top-level locals when the filter is a known literal or an existing resource ID — this avoids O(N) overhead per resource. Exception: when the filter depends on the current resource's own attribute (e.g.{bucket = attrs.id},{event_bus_name = attrs.name}), the call cannot be pre-computed at top level becauseattrsis only available insideresource_policy— use the inline pattern instead (see item 15). ❌ Do NOT work around this by fetching all child resources at the top level with{}and building a lookup map — that is the same anti-pattern restructured. - Avoid
core::getdatasource()insideresource_policy— it calls provider APIs. - Build lookup maps once for O(1) matching when iterating many resources.
- Keep each boolean expression on a single line (HCL parser limitation in beta).
- Use clear variable names (
scanning_config, notsc). - Convert sets to lists before indexing:
[for item in set : item][0]. - Don't use
core::try()defaults to mask missing values that should fail the policy — usefilterinstead. - For cross-resource lookups where the filter value is the current resource's own attribute: use an inline
core::getresources()with the direct filter insideresource_policy. To find the correct linking attribute name and filter value, fetch the child resource's Terraform Registry documentation athttps://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdownand look for the(Required)or(Optional)argument that references the parent resource. Check whether the usage examples assign it.id,.arn, or.name— useattrs.id,attrs.arn, orattrs.nameaccordingly. ⚠️ Some linking attributes are(Optional)in the schema (e.g.event_bus_nameonaws_cloudwatch_event_bus_policydefaults to the default bus) but still represent a parent-child link — treat them the same way. Always add this comment in the policy: "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time." Do not use a top-level cache + for-loop for this pattern. - Cross-resource enforcement — registry check determines the structure unconditionally:
- First, verify every resource type via the Terraform Registry. For any resource type involved in a cross-resource check, fetch
https://raw.githubusercontent.com/hashicorp/terraform-provider-aws/main/website/docs/r/{resource_name_without_aws_prefix}.html.markdown. A resource is a dependent child if it has a(Required)argument whose description or usage examples reference another AWS resource by.id,.arn, or.name. The argument name is the linking attribute; the assignment in examples tells you whether to useattrs.id,attrs.arn, orattrs.name. The resource that the linking attribute points to is the parent type. - When the enforcement goal is to ensure every parent has a compliant child, the dependent child must NEVER have a standalone
resource_policyblock. Write theresource_policyblock on the parent type. Fetch the dependent child inside the parent block viacore::getresources("<child_type>", {<linking_attr> = attrs.id_or_arn_or_name}). Evaluate all attribute checks on those lookup results. Report all violations on the parent. When the goal is only to check every existing child's own attributes, a standaloneresource_policyon the child type is valid — see Self-check above. - Concrete examples:
aws_s3_bucket_public_access_block,aws_s3_bucket_policy,aws_s3_bucket_acl(all requirebucket) → never standalone for any enforcement goal; always fetched insideresource_policy "aws_s3_bucket".aws_lb_listener→ standaloneresource_policy "aws_lb_listener"is valid when checking every listener's own attributes (e.g., protocol, ssl_policy); useresource_policy "aws_lb"with inline lookup only when the goal is "every LB must have at least one compliant listener". - Always add this comment when using this pattern: "This policy contains a cross-resource reference that will not resolve during plan time, but the policy will run successfully during apply time."
- First, verify every resource type via the Terraform Registry. For any resource type involved in a cross-resource check, fetch
Communication
- Ask clarifying questions; don't assume requirements.
- Show sample passing and failing resources alongside the policy.
- Explain enforcement-level trade-offs.
- Offer simplifications when an exact rule isn't expressible.
See Also
tfpolicy-test— write.policytest.hclfiles to validate the policies authored here.../../examples/README.md— side-by-side Sentinel +.policy.hclexamples with quality labels and per-example READMEs.verified-syntax.md— shared source-of-truth syntax reference.
Purpose: Quick reference for AI agents to start writing Terraform Policy (tfpolicy) Status: All behaviors verified during private beta (2026-02-19) Compatible with: Any AI system capable of reading markdown and generating HCL code
Quick Start for AI Agents
When a user asks you to write a Terraform Policy:
- Identify the policy type: resource_policy, module_policy, or provider_policy
- Use the correct structure: filter (optional), locals (optional), enforce (required)
- Remember: ALL built-in functions need
core::prefix - For versions: Always use
core::semverconstraint(), never direct comparison - Validate: Check examples in this guide for patterns
Table of Contents
See Also:
- Common Patterns - Common policy patterns and examples
- tfpolicy-author - Complete authoring reference for this sub-skill
Critical Rules
✅ Rule 1: ALL Functions Need core:: Prefix
ALWAYS use core:: prefix for built-in Terraform functions
# ✅ CORRECT
filter = core::try(attrs.encrypted, false) == true
is_valid = core::length([for b in local.checks : b if b]) > 0
message = "Allowed: ${core::join(", ", local.versions)}"
# ❌ WRONG - Will fail with "Unknown function" error
filter = try(attrs.encrypted, false) == true # Missing core:: prefix
# ❌ WRONG - core::anytrue does NOT EXIST in tfpolicy runtime
# is_valid = anytrue(local.checks) # Missing core:: prefix AND function doesn't exist
# is_valid = core::anytrue(local.checks) # Function does not exist — use core::length() instead
✅ Rule 2: Use Semantic Versioning for ALL Version Comparisons
NEVER use direct comparison operators for versions
# ✅ CORRECT
condition = core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
# ❌ WRONG - Direct comparison doesn't work properly
condition = meta.version >= 4.0 && meta.version < 5.0
✅ Rule 3: ALL Policy Types Support locals and filter
Don't avoid using locals or filter - they work in all policy types
# ✅ All three policy types support this structure
resource_policy "aws_s3_bucket" "example" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}
module_policy "example" "check" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}
provider_policy "aws" "check" {
filter = <condition> # ✅ Supported
locals { ... } # ✅ Supported
enforce { ... } # ✅ Required
}
Note: Language servers during private beta may show false errors for locals in provider_policy. These are safe to ignore.
Policy Structure
Basic Template
<policy_type> "<target>" "<policy_name>" {
# Optional: Pre-filter resources/modules/providers
filter = <boolean_expression>
# Optional: Local variables for complex logic
locals {
variable_name = <expression>
}
# Required: One or more enforcement rules
enforce {
condition = <boolean_expression>
error_message = "<user-facing message>"
}
# Optional: Additional enforce blocks
enforce {
condition = <another_condition>
error_message = "<another message>"
}
}
Execution Flow
- filter - Applied first, determines which resources/modules/providers to evaluate
- locals - Computed once per filtered item
- enforce - Each block evaluated; all must pass for policy to pass
Performance Best Practices
Critical for large configurations — choose the pattern based on what the filter depends on:
-
Top-level
core::getresources()only when the filter is a known literal/constant- Use when the filter value is a hardcoded string, a fixed ID, or another stable literal — not derived from
attrs.* - Executes once for the entire policy evaluation; result is reused by every
resource_policyblock - ❌ Do not use a top-level empty-filter call (
{}) and then filter byattrs.*insideresource_policy— that is the prohibited anti-pattern (O(N²) with silent correctness bugs)
locals { # OK — filter is a known literal, cached once for all resources all_buckets = core::getresources("aws_s3_bucket", {}) } resource_policy "aws_s3_bucket" "example" { locals { bucket_count = core::length(local.all_buckets) # Reuse cached value } } - Use when the filter value is a hardcoded string, a fixed ID, or another stable literal — not derived from
-
Inline
core::getresources()insideresource_policywhen the filter depends on the resource's own attribute- Use when "every parent must have at least one compliant child" and the linking key is
attrs.id,attrs.arn, orattrs.name - The filter value is unknown at plan time (it's the current resource's own attribute), so a top-level cache is impossible
- Executes once per evaluated resource (apply-time); the lookup fully resolves once the resource is provisioned
- This is not a performance compromise — it is the correct and required pattern for parent+child presence enforcement
# NOTE: This policy contains a cross-resource reference that will not resolve during # plan time, but the policy will run successfully during apply time. resource_policy "aws_s3_bucket" "s3_block_public_access" { locals { public_access_block = core::getresources("aws_s3_bucket_public_access_block", { bucket = attrs.id # filter depends on current resource — must be inline }) } enforce { condition = core::length(local.public_access_block) > 0 error_message = "S3 bucket must have a public access block resource." } } - Use when "every parent must have at least one compliant child" and the linking key is
-
Use
filterto reduce evaluation scope- Skip resources that don't need checking
- Significantly improves performance
See Advanced Patterns Guide for detailed performance guidance
Policy Types
1. resource_policy
Purpose: Validate Terraform resource configurations
resource_policy "aws_s3_bucket" "encryption_check" {
enforce {
condition = attrs.server_side_encryption_configuration != null
error_message = "S3 buckets must have encryption enabled"
}
}
Available attributes:
attrs.<attribute_name>- Resource attributes from configurationmeta.provider_type- Provider type (e.g.,aws)- ⚠️
meta.addressis UNDEFINED forresource_policyin real plan evaluation — do not use it
⚠️ Understanding Provider Schema (Blocks vs Attributes):
Terraform providers expose their raw schema, where some attributes are blocks that require special handling:
# ❌ Wrong - blocks cannot be accessed directly
attrs.server_side_encryption_configuration.rules
# ✅ Correct - blocks are lists, use [0] index
attrs.server_side_encryption_configuration[0].rules
Common AWS provider blocks requiring [0] index:
default_tags[0].*(AWS provider configuration)assume_role[0].*(AWS provider configuration)versioning[0].enabled(S3 bucket)server_side_encryption_configuration[0].*(S3 bucket)metadata_options[0].*(EC2 instance)
Best practice: Always use core::length() checks before accessing blocks:
locals {
versioning_blocks = core::try(attrs.versioning, [])
versioning_enabled = core::length(local.versioning_blocks) > 0 ?
core::try(local.versioning_blocks[0].enabled, false) : false
}
See Verified Syntax Reference for complete details
Wildcards:
resource_policy "*" "all_resources" {
# Matches ALL resource types
}
2. module_policy
Purpose: Validate Terraform module sources and versions
# Check all modules use approved registry (prefix-based using core::regex)
module_policy "*" "module_source_check" {
filter = meta.source != null
locals {
# Prefix match: does source start with the approved namespace?
# core::contains() only does exact full-string matching — use core::regex() for prefix checks
is_approved = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
}
enforce {
condition = local.is_approved
error_message = "Modules must use an approved registry source. Current source: ${meta.source}"
}
}
# Check specific module version with semver
module_policy "app.terraform.io/myorg/vpc/aws" "vpc_version" {
locals {
has_version = meta.version != null
meets_minimum = core::semverconstraint(meta.version, ">= 1.0.0")
}
enforce {
condition = local.meets_minimum
error_message = "VPC module must be >= 1.0.0, got ${meta.version}"
}
}
Available attributes:
meta.source- Module source (e.g.,app.terraform.io/org/module/provider)meta.version- Module version (works withcore::semverconstraint())meta.address- Module address (e.g.,module.vpc)
⚠️ Current Limitations (Private Beta):
- ❌
attrs.*(module inputs) NOT accessible yet - work in progress - ❌
meta.tfe_workspaceNOT available - only in resource_policy
Targeting:
- Use full module source to target specific module:
module_policy "app.terraform.io/myorg/vpc/aws" - Use
"*"wildcard to match all modules:module_policy "*" - ❌ Substring matching does NOT work:
module_policy "vpc"won't match modules with "vpc" in source
3. provider_policy
Purpose: Validate provider versions and configurations
provider_policy "aws" "version_check" {
locals {
minimum_version = "4.0.0"
}
enforce {
condition = core::semverconstraint(meta.version, ">= ${local.minimum_version}")
error_message = "AWS provider must be >= ${local.minimum_version}, got ${meta.version}"
}
}
Available attributes:
meta.source- Full provider source (e.g.,registry.terraform.io/hashicorp/aws)meta.version- Provider version (e.g.,4.67.0)meta.alias- Provider alias (if configured)attrs.*- Provider configuration attributes (region, profile, etc.)
Note:
meta.nameandmeta.typeare NOT confirmed available inreference/verified-syntax.md. Do not rely on them — usemeta.sourceto identify a provider andmeta.versionfor version checks.
Accessing provider configuration with attrs:
provider "aws" {
region = "us-west-2"
profile = "production"
}
provider_policy "aws" "region_check" {
locals {
aws_region = core::try(attrs.region, "")
allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"]
}
enforce {
condition = core::contains(local.allowed_regions, local.aws_region)
error_message = "AWS provider must use approved region. Got: ${local.aws_region}"
}
}
⚠️ Provider configuration blocks require [0] index:
# AWS provider blocks (need [0] index)
attrs.default_tags[0].tags
attrs.assume_role[0].role_arn
attrs.endpoints[0].s3
Wildcards:
provider_policy "*" "all_providers" {
# Evaluates once per provider in configuration
}
Core Functions Reference
⚠️ String Function Limitations
Terraform Policy has VERY LIMITED string functions:
# ✅ Get string length
core::length(string)
# Example: core::length(attrs.description) > 0
# ✅ Join list into string
core::join(separator, list)
# Example: core::join(", ", local.allowed_versions)
✅ String functions available:
- ✅
core::startswith(string, prefix)- Returns bool; e.g.core::startswith(meta.version, ">")✅ - ✅
core::endswith(string, suffix)- Returns bool - ✅
core::contains_substring(string, substr)- Returns bool - ❌
core::contains(string, substring)- Does NOT work for strings (only lists!) - ✅
core::split(separator, string)- Splits string into list; e.g.core::split("-", "80-443")→["80", "443"]
✅ What IS Also Available — core::regex(pattern, string):
- Pattern and substring matching via
core::regex() - Important:
core::regex()throws on no match (does NOT return null) — always wrap withcore::try()
# Safe boolean pattern — use this idiom everywhere
locals {
# Substring check: does description contain "exception"?
has_exception = core::try(core::regex("NET-8 = exception", core::try(attrs.description, "")), null) != null
# Prefix check: does source start with approved namespace?
is_approved_source = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
# Exact membership: still use core::contains() for lists
approved_types = ["gp3", "io1"]
is_approved_type = core::contains(local.approved_types, core::try(attrs.volume_type, ""))
}
Note: For prefix/suffix checking, prefer core::startswith() / core::endswith() over core::regex(). Use core::split() with core::parseint() for numeric string decomposition (e.g. port-range strings like "80-443").
List Functions
# Check if list contains value
core::contains(list, value)
# Example: core::contains(["dev", "staging", "prod"], attrs.environment)
# Get collection or string length (works on strings, lists, sets, maps!)
core::length(list_or_string_or_map)
# Example: core::length(local.violations) == 0
# Example: core::length(attrs.description) > 0 # String length!
# Example: core::length(attrs.tags) > 0 # Map key count
# Get map keys as list
core::keys(map)
# Example: core::keys(attrs.tags)
# Example: core::contains(core::keys(attrs.tags), "Environment")
# Check if any element is true — ❌ core::anytrue() does NOT exist
# Use: core::length([for b in list_of_booleans : b if b]) > 0
# Check if all elements are true — ❌ core::alltrue() does NOT exist
# Use: core::length([for b in list_of_booleans : b if !b]) == 0
Safe Access
# Try expression with fallback
core::try(expression, default_value)
# Example: core::try(attrs.encrypted, false)
# Example: core::try(meta.version, "0.0.0")
⚠️ CRITICAL: Cannot Check Attribute Existence Without try()
Direct attribute access fails when attributes don't exist, even with null checks:
# ❌ WRONG - Crashes with "This object does not have an attribute named 'region'"
has_region = attrs.region != null
# ✅ CORRECT - Two-step safe access pattern
region_value = core::try(attrs.region, null)
has_region = local.region_value != null
Why: Terraform Policy cannot test attribute existence before accessing (no "attr" in attrs syntax). Always use core::try() first, then check the result.
Semantic Versioning
# Compare version against constraint
core::semverconstraint(version, constraint_string)
# Example: core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
Resource Queries
# Query related resources
core::getresources(resource_type, filter_map)
# filter_map is REQUIRED. Pass {} to match everything, or { attr = value }
# for equality filtering. Caveat: candidates with unknown target
# attributes at plan time (e.g. references to to-be-created resource IDs)
# are conservatively included regardless of the filter value.
# Example: core::getresources("aws_security_group_rule", {})
⚠️ Filter caveat: core::getresources(type, { attr = value }) performs equality matching, but candidates whose target attribute is unknown at plan time (e.g. bucket = aws_s3_bucket.x.id for a resource being created in the same plan) are conservatively included. On first-time-create plans with cross-references you'll get every candidate back. Most reliable on update plans against existing infrastructure. See reference/verified-syntax.md.
CRITICAL: core::getresources() Attribute Access
Resources returned by core::getresources() have attributes at the top level (NOT through .attrs):
locals {
all_roles = core::getresources("aws_iam_role", {})
}
# ✅ CORRECT - Access attributes directly
role_names = [for role in local.all_roles : role.name]
filtered = [for role in local.all_roles : role if role.path == "/service/"]
# ❌ WRONG - Do NOT use .attrs
role_names = [for role in local.all_roles : role.attrs.name] # ERROR!
Why: This is DIFFERENT from current resource context where you use attrs.name. Returned resources have a different structure.
Pattern for Cross-Resource Validation:
resource_policy "aws_iam_role" "check" {
locals {
role_attachments = core::getresources("aws_iam_role_policy_attachment", {
role = attrs.name # filter depends on current resource — must be inline
})
has_attachment = core::length(local.role_attachments) > 0
}
}
Semantic Versioning
Constraint Operators
| Operator | Meaning | Example | Matches |
|---|---|---|---|
= |
Exact version | "= 4.67.0" |
4.67.0 only |
!= |
Not equal | "!= 4.50.0" |
Any except 4.50.0 |
> |
Greater than | "> 4.0.0" |
4.0.1, 4.1.0, 5.0.0, etc. |
>= |
Greater or equal | ">= 4.0.0" |
4.0.0, 4.0.1, 5.0.0, etc. |
< |
Less than | "< 5.0.0" |
4.99.99, 3.0.0, etc. |
<= |
Less or equal | "<= 5.0.0" |
5.0.0, 4.99.99, etc. |
~> |
Pessimistic (patch) | "~> 4.67.0" |
>= 4.67.0, < 4.68.0 |
~> |
Pessimistic (minor) | "~> 4.0" |
>= 4.0.0, < 5.0.0 |
Multiple Constraints (AND logic)
# Both constraints must be satisfied
core::semverconstraint(meta.version, ">= 4.0.0, < 5.0.0")
core::semverconstraint(meta.version, ">= 4.0.0, != 4.50.0")
OR Logic
locals {
# Version 4.x OR 5.x allowed
version_ok = core::semverconstraint(meta.version, "~> 4.0") ||
core::semverconstraint(meta.version, "~> 5.0")
}
Version Allowlist Pattern
locals {
allowed_versions = ["3.75.0", "3.80.0", "3.85.0"]
# Check if current version matches any allowed version
version_checks = [
for v in local.allowed_versions :
core::semverconstraint(meta.version, "= ${v}")
]
is_allowed = core::length([for b in local.version_checks : b if b]) > 0
}
enforce {
condition = local.is_allowed
error_message = "Version ${meta.version} not approved. Allowed: ${core::join(", ", local.allowed_versions)}"
}
Purpose: Common patterns for writing Terraform policies Status: All patterns verified during private beta (Updated: 2026-02-24)
Pattern 1: Required Attribute
resource_policy "aws_s3_bucket" "require_encryption" {
enforce {
condition = attrs.server_side_encryption_configuration != null
error_message = "S3 buckets must have encryption configured"
}
}
Pattern 2: Attribute Must Match Value
resource_policy "aws_ebs_volume" "encryption" {
enforce {
condition = core::try(attrs.encrypted, false) == true
error_message = "EBS volumes must be encrypted"
}
}
Pattern 3: Allowlist Check
resource_policy "aws_instance" "instance_type" {
locals {
allowed_types = ["t3.micro", "t3.small", "t3.medium"]
is_allowed = core::contains(local.allowed_types, attrs.instance_type)
}
enforce {
condition = local.is_allowed
error_message = "Instance type ${attrs.instance_type} not allowed. Use: ${core::join(", ", local.allowed_types)}"
}
}
Pattern 4: Tag Validation
resource_policy "*" "required_tags" {
filter = attrs.tags != null
locals {
required_tags = ["Environment", "Owner", "CostCenter"]
# Count tags that are MISSING; if zero, all required tags are present
missing_tags = [
for tag in local.required_tags :
tag if !core::contains(core::keys(attrs.tags), tag)
]
has_all_tags = core::length(local.missing_tags) == 0
}
enforce {
condition = local.has_all_tags
error_message = "Resources are missing required tags: ${core::join(", ", local.required_tags)}"
}
}
Pattern 5: Module Source Restriction
module_policy "*" "approved_sources" {
filter = meta.source != null
locals {
# Exact allowlist: use core::contains() for explicit full-source matching.
# For prefix matching use core::startswith(), for pattern matching use core::regex().
approved_sources = [
"app.terraform.io/myorg/vpc/aws",
"app.terraform.io/myorg/database/aws",
"app.terraform.io/myorg/network/aws",
"registry.terraform.io/hashicorp/vpc",
"registry.terraform.io/hashicorp/s3-bucket"
]
is_approved = core::contains(local.approved_sources, meta.source)
}
enforce {
condition = local.is_approved
error_message = "Module source not approved: ${meta.source}. Must be one of the explicitly allowed modules."
}
}
Note: core::contains() only supports exact full-string matching. For prefix or namespace matching, use core::regex():
# Prefix matching with core::regex() — matches any source under the approved namespace
locals {
is_approved_namespace = core::try(core::regex("^app\\.terraform\\.io/myorg/", meta.source), null) != null
}
enforce {
condition = local.is_approved_namespace
error_message = "Module source must be from app.terraform.io/myorg/ namespace. Got: ${meta.source}"
}
Pattern 6: Provider Version Range
provider_policy "aws" "version_range" {
locals {
min_version = "4.0.0"
max_version = "5.0.0"
version_ok = core::semverconstraint(meta.version, ">= ${local.min_version}, < ${local.max_version}")
}
enforce {
condition = local.version_ok
error_message = "AWS provider version ${meta.version} outside allowed range: >= ${local.min_version}, < ${local.max_version}"
}
}
Pattern 7: Conditional Enforcement
resource_policy "aws_s3_bucket" "conditional_encryption" {
locals {
# Only enforce encryption for production buckets
is_production = core::contains(core::keys(attrs.tags), "Environment") &&
attrs.tags["Environment"] == "production"
has_encryption = attrs.server_side_encryption_configuration != null
}
enforce {
# Skip check for non-production or enforce for production
condition = !local.is_production || local.has_encryption
error_message = "Production S3 buckets must have encryption enabled"
}
}
Pattern 8: Multiple Checks with Detailed Errors
resource_policy "aws_security_group" "security_checks" {
locals {
# ✅ Use core::length() instead of core::anytrue() (which does NOT exist)
# Filter to SSH rules from internet; if list is non-empty, there's a violation
ssh_from_internet = [
for rule in core::try(attrs.ingress, []) :
rule if (rule.from_port == 22 && core::contains(core::try(rule.cidr_blocks, []), "0.0.0.0/0"))
]
has_ssh_ingress = core::length(local.ssh_from_internet) > 0
# Check for proper description
has_description = attrs.description != null && core::length(attrs.description) > 0
}
enforce {
condition = !local.has_ssh_ingress
error_message = "Security groups must not allow SSH from the internet (0.0.0.0/0)"
}
enforce {
condition = local.has_description
error_message = "Security groups must have a description"
}
}
Pattern 9: Provider Configuration Policies
Validate provider configuration attributes and blocks:
# Check provider region
provider_policy "aws" "approved_regions" {
locals {
aws_region = core::try(attrs.region, "")
allowed_regions = ["us-east-1", "us-west-2", "eu-west-1"]
}
enforce {
condition = core::contains(local.allowed_regions, local.aws_region)
error_message = "AWS provider must use approved region. Got: ${local.aws_region}"
}
}
# Check provider blocks (need [0] index)
provider_policy "aws" "enforce_default_tags" {
locals {
# Provider blocks are lists
default_tags = core::try(attrs.default_tags, [])
has_default_tags = core::length(local.default_tags) > 0
# Access block attributes with [0] index
tags = local.has_default_tags ?
core::try(local.default_tags[0].tags, {}) : {}
required_tags = ["Environment", "Owner", "CostCenter"]
tag_keys = core::keys(local.tags)
# ✅ Use core::length() instead of core::alltrue() (which does NOT exist)
# Count required tags that are MISSING; if zero, all required tags are present
missing_required_tags = [
for tag in local.required_tags :
tag if !core::contains(local.tag_keys, tag)
]
has_all_required = core::length(local.missing_required_tags) == 0
}
enforce {
condition = local.has_default_tags
error_message = "AWS provider must configure default_tags block"
}
enforce {
condition = local.has_all_required
error_message = "AWS provider default_tags must include: ${core::join(", ", local.required_tags)}"
}
}
# Prevent hardcoded credentials
provider_policy "aws" "no_hardcoded_credentials" {
locals {
has_access_key = attrs.access_key != null
has_secret_key = attrs.secret_key != null
}
enforce {
condition = !local.has_access_key && !local.has_secret_key
error_message = "AWS provider must NOT use hardcoded credentials (access_key/secret_key). Use environment variables or IAM roles instead."
}
}
Key points:
attrs.*provides access to provider configuration- Provider blocks (default_tags, assume_role) require
[0]index - Version checking uses
meta.versionwithcore::semverconstraint() - Security checks (no hardcoded credentials) are important
Pattern 10: Cross-Resource Enforcement
Enforce that one resource type has a corresponding companion resource:
aws_s3_bucket_server_side_encryption_configuration is a dependent child of aws_s3_bucket — its bucket argument always references attrs.id. The filter value is derived from attrs.*, so the top-level cache pattern is prohibited (Mistake 13 in verified-syntax.md). Always use the inline filter pattern for S3 companion resources.
# NOTE: This policy contains a cross-resource reference that will not resolve
# during plan time, but the policy will run successfully during apply time.
resource_policy "aws_s3_bucket" "require_encryption_config" {
locals {
sse_configs = core::getresources("aws_s3_bucket_server_side_encryption_configuration", {
bucket = attrs.id # filter derived from current resource's own attr — must be inline
})
has_sse_config = core::length(local.sse_configs) > 0
# rule is a Set — convert to list before indexing; guard first
sse_rules = local.has_sse_config ? core::try([for r in local.sse_configs[0].rule : r], []) : []
has_sse_rule = core::length(local.sse_rules) > 0
sse_apply_block = local.has_sse_rule ? core::try([for a in local.sse_rules[0].apply_server_side_encryption_by_default : a], []) : []
has_apply_block = core::length(local.sse_apply_block) > 0
sse_algorithm = local.has_apply_block ? core::try(local.sse_apply_block[0].sse_algorithm, "") : ""
}
enforcement_level = "advisory"
enforce {
condition = local.has_sse_config
error_message = "S3 bucket must have an aws_s3_bucket_server_side_encryption_configuration resource."
}
enforce {
condition = local.sse_algorithm == "aws:kms"
error_message = "S3 bucket encryption must use aws:kms (found: ${local.sse_algorithm != "" ? local.sse_algorithm : "(none configured)"})"
}
}
Key points:
- The filter value (
attrs.id) is the current resource's own attribute — a top-level cache is impossible; use the inline call - Both checks (presence and KMS algorithm) live in one
resource_policyblock with multipleenforceblocks — never split checks on the same resource type into two blocks (SKILL.md Output Structure Rule 1) - Always include the apply-time
# NOTE:comment
Pattern 11: Resource Count Limits
Limit the total number of resources of a specific type:
locals {
all_nat_gateways = core::getresources("aws_nat_gateway", {})
nat_gateway_count = core::length(local.all_nat_gateways)
max_allowed = 3
}
resource_policy "aws_nat_gateway" "limit_count" {
enforce {
condition = local.nat_gateway_count <= local.max_allowed
error_message = "Maximum ${local.max_allowed} NAT gateways allowed (found: ${local.nat_gateway_count})"
}
}
Key points:
- Policy runs for each resource but references global count
- All resources will fail if limit is exceeded
- Use top-level locals to count once
Pattern 12: Cross-Resource Attribute Validation
Validate that one resource's attribute matches another resource's attribute:
resource_policy "aws_subnet" "vpc_tag_match" {
filter = attrs.vpc_id != null
locals {
# NOTE: This policy contains a cross-resource reference that will not resolve
# during plan time, but the policy will run successfully during apply time.
matching_vpcs = core::getresources("aws_vpc", { id = attrs.vpc_id })
vpc = core::length(local.matching_vpcs) > 0 ? local.matching_vpcs[0] : null
vpc_env_tag = core::try(local.vpc.tags["Environment"], "")
subnet_env_tag = core::try(attrs.tags["Environment"], "")
tags_match = local.vpc_env_tag == local.subnet_env_tag
}
enforce {
condition = local.tags_match
error_message = "Subnet Environment tag (${local.subnet_env_tag}) must match VPC Environment tag (${local.vpc_env_tag})"
}
}
Key points:
aws_vpc.idis the linking attribute referenced byattrs.vpc_id— this is an attrs.*-derived key so the top-level cache + map-index pattern is prohibited (Mistake 13 in verified-syntax.md); always use inlinecore::getresources- Use
core::try()for safe attribute access - Check both existence and value matching
Pattern 13: Sentinel Conversion - DMS Endpoint SSL Mode
Source policy: HashiCorp PCI DSS library - dms-endpoints-should-use-ssl.sentinel
Conversion quality: Perfect
resource_policy "aws_dms_endpoint" "require_ssl_mode" {
locals {
ssl_mode = core::try(attrs.ssl_mode, "")
valid_ssl_modes = ["require", "verify-ca", "verify-full"]
}
enforce {
condition = core::contains(local.valid_ssl_modes, local.ssl_mode)
error_message = "DMS endpoints must set ssl_mode to one of: require, verify-ca, verify-full"
}
}
Why this converts cleanly:
- Single resource type
- Direct attribute check on planned values
- No cross-resource dependency or reference metadata
- Sentinel
collection.reject()becomes one focusedenforcecondition
Pattern 14: Sentinel Conversion - Elasticsearch HTTPS Required
Source policy: HashiCorp PCI DSS library - elasticsearch-https-required.sentinel
Conversion quality: Good
resource_policy "aws_elasticsearch_domain" "https_required" {
locals {
endpoint_options = core::try(attrs.domain_endpoint_options, [])
endpoint_options_present = core::length(local.endpoint_options) > 0
enforce_https = core::try(local.endpoint_options[0].enforce_https, false)
tls_security_policy = core::try(local.endpoint_options[0].tls_security_policy, "")
}
enforce {
condition = local.endpoint_options_present
error_message = "Elasticsearch domains must define domain_endpoint_options"
}
enforce {
condition = local.enforce_https == true
error_message = "Elasticsearch domains must set domain_endpoint_options.enforce_https = true"
}
enforce {
condition = local.tls_security_policy == "Policy-Min-TLS-1-2-PFS-2023-10"
error_message = "Elasticsearch domains must use tls_security_policy 'Policy-Min-TLS-1-2-PFS-2023-10'"
}
}
Why this is Good instead of Perfect:
- The Sentinel policy uses helper functions and nested map lookups; tfpolicy rewrites that logic into direct block access with
core::try() - The enforcement intent is preserved, but the structure is idiomatic tfpolicy rather than one-to-one
Pattern 15: Sentinel Conversion - EventBridge Bus Must Have Attached Policy
Source policy: HashiCorp PCI DSS library - eventbridge-custom-event-bus-should-have-attached-policy.sentinel
Conversion quality: Limited
locals {
all_event_bus_policies = core::getresources("aws_cloudwatch_event_bus_policy", {})
event_bus_policy_map = {
for policy in local.all_event_bus_policies :
policy.event_bus_name => true
}
}
resource_policy "aws_cloudwatch_event_bus" "require_attached_policy" {
locals {
bus_name = core::try(attrs.name, "")
has_attached_policy = core::try(local.event_bus_policy_map[local.bus_name], false)
}
enforce {
condition = local.has_attached_policy
error_message = "EventBridge buses must have a matching aws_cloudwatch_event_bus_policy resource"
}
}
Why this is only Limited:
- This relies on value matching through
core::getresources(), not Terraform graph/reference metadata - It works best when
event_bus_nameis explicit and already resolved - New resources with unresolved references can produce different behavior from Sentinel or fail to match on first creation
Previous: Quick Start Guide Back to: Main README