satz library

Every pack is a .satz file: pack <name> version "<v>", a params { … } block of overridable defaults, and the resources it contributes. Estates use them.

Presets are read-only building blocks: use them from a customer's estate and set every org-specific value there — never by editing a preset.

use "presets/<pack>.satz" at top level for packs that declare their own resource-type maps — the CIS baseline and its extensions, and most of the library — or inside a resource map for the packs that are a bare list of labels (google_essential_contacts_contact { use … }, or use … as <type> written flat). Each pack's header states its own line and presets/docs/ prints it; the shapes are not interchangeable, and using one the wrong way round is refused rather than emitted. Pack params are overridable defaults; define the same name in the estate params block to override (the using document always wins). When a needed customization is not expressible as a param, fork: copy to <pack>.local.satz, repoint the use — merge-presets maintains the .diff.satz adoption ledger. If a fork's whole diff could be a param, add the param to the pack instead.

Optional packs are gated on a single param: use "presets/x.satz" when logsink_project_id — a falsy value skips the pack entirely (no resources, no params, no claims). The param must be DECLARED somewhere (params { … } of the estate or a pack): a when on a param nobody declares is an error, not false.

Multi-resource-type packs (marked below) rely on hoisted scopes: org/customer/ billing-scoped types (google_cloud_identity_group, google_organization_iam_member, google_billing_account_iam_member) may sit anywhere in the tree and are emitted once at their intrinsic scope. Projects land wherever the pack is used — root or inside a google_folder { … } block. Two packs contributing to the same resource-type map merge label by label (the ⊕ fold); the same label with a different body is a hard error naming both files.

Adopting what already exists (groups, org policies, folders, the state bucket …) is satz adopt <estate> — it resolves live ids and --execute writes them back as "import-id". Pack headers that mention adoption mean that command.


Writing one: satz review-pack <file> --format text --out - judges a pack against everything on this page — it parses, it is formatted, its header opens with a sentence the index can print, its id is its file name, its version has a changelog row in CHANGELOG.md, it carries no value shaped like private data that is not a documented example value, it declares no membership, it runs no legacy constraint beside its managed replacement, every resource type it emits has a row in satz's prerequisite table, it compiles inside an estate, and every string param it leaves "" has a question — and says what adopting it would cost that estate in roles and APIs. It warns on a type satz adopt has no rule for. It is the same bar this repository's gates hold, reachable without a checkout.

A claim states its measure without satz. Beside interpretation and its duties, a claim carries gcloud — the commands that meet the control the way the claim's resources do, one command per entry — gcloud_check — the commands that show whether it is met — and risk, one sentence on what goes wrong without the measure. A multi-line command, a heredoc that writes a policy file, is one triple-quoted entry. Placeholders are uppercase words the operator replaces: ORGANIZATION_ID, PROJECT_ID, DOMAIN, EMAIL, REGION, NETWORK, INSTANCE, SERVICE_ACCOUNT, DATASET, KMS_PROJECT_ID, KEY_RING, KEY, BUCKET_NAME, SINK_NAME, LOG_PROJECT_ID, ALERT_BUCKET_ID and CHANNEL_ID. A claim with no gcloud route leaves gcloud out — the CIS baseline's §1.1.4 claim, whose route is its other claims' routes together. require and report-compliance carry every claim that names a control in that control's measures, from the estate's packs and from the rest of the library; a changed text emits nothing and never forks an estate's pack. Every claim in this library carries risk and gcloud_check, and gcloud except §1.1.4; cargo test fails on one that does not.

This page and the pack pages: this page holds the conventions, one introduction per group, and the files beside the packs that are not packs. docs/README.md is one page per pack, derived from the pack file by satz doc-packs — what it does, the copy-pasteable use block with its params and the params it needs from outside, the resources, the claims with their control titles from the catalog, the duties, which contributed resources no claim witnesses, and the pack's own version history — with a notes region for what is written by hand. History: CHANGELOG.md — what each release refuses that the one before it compiled, and one row per pack version.

Groups: presets/library-groups.txt sorts every pack into one topic, in reading order — the estate map and core, security groups, billing, the CIS baseline, monitoring, networking, projects and interfaces, security operations, continuous verification. The sections below, the index of pack pages and the site's side menu follow it; a pack file stays where it is, since an estate's use line names its path. satz doc-packs refuses a pack in no group or in two, and a group this page has no ## heading for, in that order.

Tiers: the map decides them, in its defaults. estate-core and estate-map are in every estate; every other pack has one gate, and the gate's default in estate-map.satz is the pack's tier — true is on unless the customer declines, false is opt-in, a reference to another gate follows that choice. A vendor integration is an opt-in pack under integrations/, and satz pack-graph refuses one whose gate defaults to true. Nothing declares a tier.

Names: a pack's id is its file name, - written _: cis/cmek.satz is pack cmek. The folder says where a pack lives, the groups file its topic, and the id keys the changelog and names the page, so it is unique. satz doc-packs and satz review-pack refuse any other id; the ids that predate the rule are named in LEGACY_PACK_IDS (src/doc_packs.rs) until their rename, and a row whose pack conforms is refused.

Estate map and core

estate-map.satz

Which packs make up the estate, asked as questions — the map an interview follows after the day-0 params. One boolean per optional pack plus the S1/S2 model as a question oneof, each with what the pack is for and what turning it off later destroys. Below the choices, one offers entry per pack in the library says the gate its line carries, the phase that has to be finished before it can go in, the block it belongs in and — by the entries' order — the order the packs can be adopted; satz pack-graph turns them, with the edges it derives from the packs, into presets/pack-graph.json (ADR 0031). The estate carries one use … when line per choice, each written commented under its phase. satz init and satz interview --create write them from the pack graph, satz merge-presets writes a missing one, satz interview and satz add-pack switch a line on when its choice is answered yes, satz packs lists every choice with its line, and a test fails on a choice with no line (ADR 0007).

use "presets/estate-core.satz"
use "presets/estate-map.satz"
use "presets/security-group-models/s1-security-groups.satz" when security_model_s1
use "presets/security-group-models/s2-security-groups.satz" when security_model_s2
use "presets/billing-account-permissions.satz" when use_billing_permissions
…
choice default pack
use_cis_baseline on cis/CIS-GCP-Foundation-4.0 — the thirty organisation policies the estate exists for; its opt-in extensions and their dry-run twins are the baseline's own questions
security_model (oneof) S1 security-group-models/s1-security-groups or s2-security-groups
use_audit_logsink on monitoring/organization-audit-logsink, in the infrastructure folder
use_central_alerts on monitoring/organization-cis-log-alerts-central, beside it — needs the archive
use_billing_permissions on billing-account-permissions
use_essential_contacts on essential-contacts-organization
use_budget off organization-budget
use_billing_export off billing-export — the project and dataset Cloud Billing exports usage and cost into
use_shared_network off shared-network — a shared VPC in a host project: the network, and the subnets and firewall rules the projects request
use_project_cis_log_alerts off monitoring/project-cis-log-alerts — the CIS log alerts inside one project of its own, beside the central ones
use_scc_enablement off scc/scc-service-enablement — recommended; the four below are asked only when it is on
use_scc_notifications off scc/scc-notifications — the Pub/Sub chain findings travel on
use_scc_findings_mail off scc/scc-findings-mail — asked only when the topic is on: the subscription, mailbox and alert that tell somebody
use_scc_findings_siem off scc/scc-findings-siem — asked with it: the connector's own subscription and the grant it reads with. Both may be on
use_scc_export off scc/scc-export — the BigQuery dataset findings are kept in
use_security_audit_sa off security-audit/sa-security-audit
use_defender off integrations/microsoft-defender-for-cloud — its plan fragments by hand
use_sentinel off integrations/microsoft-sentinel — the federation half
use_sentinel_auditlogs follows use_sentinel integrations/microsoft-sentinel-auditlogs, asked only when Sentinel is on
use_sentinel_network_logs follows use_sentinel integrations/microsoft-sentinel-network-logs — flow logs, firewall, DNS, NAT: free until the feature is enabled
use_verification_runner off ci/verification-runner, the customer-hosted shape
use_verification_runner_grant follows use_verification_runner ci/verification-runner-grant — the binding that lets a runner act as the estate; answered alone when the runner lives in another estate
use_exemption_tag off exemptions/exemption-tag — the tag an exemption is bound to; it exempts nothing on its own
interface_notice (oneof, not required) none how the projects hear that an exported value changed, one option per delivery form: interface_notice_pubsub is interface-notice, one Pub/Sub message per apply that changes a value the estate exports

The CIS baseline is the map's first choice: the skeleton writes its line commented under its phase, like every pack's, and answering use_cis_baseline yes puts it in. It is asked rather than assumed because its thirty policies reach the organisation in one apply. Defender's plan fragments are offered with by_hand: they are gated on Defender's own params, and their lines are written by hand beside the Defender line, as their headers show.

estate-core.satz

The questions every estate has to answer on day 0, with the params they answer — the ones satz init writes, each with a question: what to ask, why, and what changing the answer later costs. Which packs make up the estate is the next pack, estate-map.satz; this one is the day-0 params and nothing else.

use "presets/estate-core.satz"

The pack declares no resource; it holds the day-0 questions and the core exports. satz interview <estate> --create and the MCP tool satz_interview write an estate that uses it, with every question open. An estate written by init does not need it for its params: init binds every param from its flags, and a bound param is an answered question.

The core exports. An estate that uses the pack publishes these to the projects beside it — outputs of hcl/outputs.tf and values of every interface under interfaces/ (workflows), all known at compile time:

export value
organization_id customer_organization_id
customer_domain customer_domain
customer_shortname customer_shortname
default_region default_region
infra_project_id infra_project_name
iac_service_account {svc_iac_account}@{infra_project_name}.iam.gserviceaccount.com

satz init writes two exports into the estate itself: infra_folder, the infrastructure folder's folders/<number>, which the interface looks up, and workload_folder.

The workload folder is where the customer's and the projects' folders live, the parent every project's folder takes. workload_folder_name names it, like infra_folder_name names the infrastructure folder, and it may be empty: "", the default, is the organisation itself, and nothing is created for it. Its question says so (empty = "…", language §6.14), so "" is an answer the interview offers and the gate counts. The export is written into the estate, not by this pack, because its two forms are different statements:

workload_folder_name what the estate carries workload_folder
"" export "workload_folder" = "organizations/{customer_organization_id}" organizations/<id>, known at compile time
a name google_folder { workload_folder { display_name = workload_folder_name } } and export "workload_folder" = "${{google_folder.workload_folder.name}}" folders/<number>, looked up by the interface

satz init writes the section from --workload-folder-name (the folder) or without it (the organisation); satz interview and satz_interview write it when the question is answered. The folder block stands at the top level, so its parent is the organisation; to put the workload folder inside another folder — a top-level folder named after the organisation — move the workload_folder { … } block into that folder's block, and the interface's lookup follows the parents. An answer or a re-run of init whose form differs from the section the estate already carries is refused, not rewritten: the projects' folders sit under it. The compile refuses a name and a section that disagree, at the line to edit: a name with no export "workload_folder" or with the organisation's, and an empty name with a folder's. An empty name with no section compiles and publishes no workload_folder.

error    workload-folder  satz/acme.satz:33  workload_folder_name
    `workload_folder_name = "Workloads"`, and the estate publishes no `workload_folder`: the folder is neither declared nor exported. Add `google_folder { workload_folder { display_name = workload_folder_name } }` and `export "workload_folder" = "${{google_folder.workload_folder.name}}"`, or bind `workload_folder_name = ""` for the organisation

Google refuses a second folder of one name under one parent, so a folder the customer already has is imported — satz adopt <estate> --execute --import resolves the folder by its display name under its parent — and until it is, the apply stops on it.

Two kinds of param, and the interview treats them differently:

kind params in the report
no possible default customer_id, customer_organization_id, customer_domain, customer_shortname, customer_longname, first_admin, billing_account_infra blocking: true — a value has to be typed
derived or conventional infra_folder_name, infra_project_name, infra_bucket_name, svc_iac_account, svc_iac_users_group, deployment_engine, deployment_mode, default_region, default_zone, compliance_frameworks default offered — accepting it is an answer, recorded by writing it

workload_folder_name is of neither kind: its default "" is a real answer — the organisation — because its question declares what an empty answer means.

compliance_frameworks is the one param here that is neither derived nor a convention: it is what the customer ANSWERS TO — a contract, an auditor, a regulator — as a list of catalog ids (cis-gcp-4.0, cis-gcp-5.0, iso27001-2022), default ["cis-gcp-5.0"]. It is not what the estate CLAIMS, which comes from its packs, and a value naming no catalog is a compile error with the list. satz report-compliance <estate> reports one section per framework named here, satz prowler scans for them beside the frameworks the packs claim, and a pack reads it like any other param.

A derived default is offered only once what it derives from is answered: infra_project_name is "{customer_shortname}-infra-001", which with the short name still open would be -infra-001, so it blocks until the short name is typed, then offers acme-infra-001. See satz interview.

Security groups and exemptions

security-group-models/

This is where STANDING authority is modelled — who administers projects, networks, guardrails, billing, org-wide and continuously. Its counterpart is exemptions/, which says who may make a narrow, named EXCEPTION to a control this authority set, without holding this authority. The two stay apart: gcp-security-admins holds roles/orgpolicy.policyAdmin and can rewrite any policy, and that is a much bigger thing to hand someone than "may let one service account hold a key".

The security group models: admin groups plus their org-level role grants. Each model is one typed file, used at the top level of the estate:

Which model: S1 when one platform team does both project administration and networking (the network roles ride along in project-admins because the people are the same). S2 as soon as network and project administration are different people — a connectivity team, Shared VPC with many service projects, hierarchical firewall policies, hybrid connectivity, or a separation-of-duties requirement; the network team gets org-wide reach over connectivity and nothing else. Moving S1 → S2 later is a pack swap plus adopting the new group, but every project admin loses two roles at that apply. The role-by-role table and who-sits-where guide are on the S2 pack page.

The groups: gcp-organization-admins (break-glass owners of the org tree, policies and org IAM), gcp-project-admins (day-2 workload projects), gcp-security-admins (guardrails: org policies, SCC, folder IAM, log routing), gcp-security-viewers (org-wide read-only for audit and compliance evidence), gcp-billing-admins (billing, budgets, procurement) — S2 adds gcp-network-admins (everything that connects, no ownership, no IAM) — each with the IaC service account as owner; lifecycle ignores initial_group_config (imported groups always diff on it). No pack ships human memberships — presets define groups, humans grant membership (in the console or with gcloud, outside satz). Estates that must manage a membership declare it on their own estate-level groups. Every group name is a param.

Use:

// one line at the top level of the estate — s1-security-groups or s2-security-groups
use "presets/security-group-models/s1-security-groups.satz"

To adopt groups (and their declared members) that already exist in the tenant, run satz adopt <estate> --only google_cloud_identity_group,google_cloud_identity_group_membership — each group is looked up by email, each declared member by email in that group, and --execute writes the verified ids back as "import-id". Members not declared in the estate stay unmanaged.

Overridable defaults: the five gcp_*_name group names (gcp_organization_admins_name, gcp_project_admins_name, gcp_security_admins_name, gcp_security_viewers_name, gcp_billing_admins_name).

Questions. Every group name is asked, because a group's address is its identity: renaming one later is a new group, members moved by hand and roles re-granted. Each has its conventional default, so satz interview <estate> --accept-defaults settles a model in one pass. Which model — S1 or S2 — is the security_model choice in estate-map.satz, not a question in either model pack: a question that gates a pack cannot live in the pack it gates.

exemptions/

Two features live here, and they are two halves of one question: who may do what. The security group models say who holds STANDING authority — who administers projects, networks, guardrails, billing. This pack says who may make a narrow, named EXCEPTION to a control that authority set, without holding that authority.

One pack, and it exempts nothing. exemption-tag.satz creates the organisation tag an org policy can condition on: the key <shortname>-exemption and one value per exemption CLASS, bound to nothing.

Why a tag rather than lowering the policy

An org policy is all-or-nothing per node. Letting ONE service account create ONE key means lowering the policy for its whole project or folder and raising it again — a window during which nothing under that node is enforced, and which nobody remembers to close. A Resource Manager tag is IAM-governed and a policy rule can condition on it, so the policy stays enforced everywhere and named resources are let out one at a time. Google ships iam.disableServiceAccountKeyCreation this way on new organisations.

The classes, and why there are several

IAM is set on a tag VALUE. One blanket not_enforced would mean that anyone allowed to exempt anything may exempt everything — the team that needs a public bucket could switch off customer-managed encryption just as easily. So each value is a class covering one kind of risk, and roles/resourcemanager.tagUser on one value delegates exactly that kind:

class what it lets out constraints it is written for
service-account-keys a principal that must hold a static credential iam.managed.disableServiceAccountKeyCreation, …KeyUpload, iam.managed.disableServiceAccountApiKeyCreation
public-endpoint a workload reachable from the internet on its own address compute.managed.vmExternalIpAccess, sql.managed.restrictPublicIp, sql.managed.restrictAuthorizedNetworks
public-storage an object store published on purpose, and the access model for it storage.publicAccessPrevention, storage.uniformBucketLevelAccess
vm-image an image or machine family that cannot boot shielded or confidential compute.requireShieldedVm, compute.managed.restrictNonConfidentialComputing
vm-access an instance reached by metadata SSH keys or the serial console compute.managed.requireOsLogin, compute.managed.blockProjectSshKeys, compute.managed.disableSerialPortAccess
data-residency a resource created outside the permitted locations gcp.resourceLocations
encryption a resource that may use Google-managed keys gcp.restrictNonCmekServices, gcp.restrictCmekCryptoKeyProjects
network-appliance an instance that forwards traffic for others compute.managed.vmCanIpForward, compute.managed.restrictProtocolForwardingCreationForTypes

The classes are narrow, and vm-image is separate from vm-access for that reason: what a machine can run and who can get into it are different risks and should be different grants. A wide class is a grant that hands over more than the person asking for it described.

What has no class. Audit logging (gcp.detailedAuditLoggingMode, iam.disableAuditLoggingExemption), VPC flow logs, DNS query logging and domain-restricted sharing (iam.managed.allowedPolicyMembers). Exempting the record of what happened, or letting an outside identity in, is not a delegation — it is a decision for whoever owns the baseline, made by editing the policy where the change is visible. The service-agent caveat for domain-restricted sharing is already a baseline parameter (allowed_policy_member_subjects), which is the route there.

Who may exempt, and where: two grants, both required

grant on what decides
roles/resourcemanager.tagUser the tag VALUE WHICH class this principal may grant at all
the createTagBinding permission the TARGET (organisation, folder or project) WHERE they may apply it

That pair is the project / folder / organisation restriction — no separate mechanism. A team holding tagUser on public-endpoint and createTagBinding on their own folder can exempt public endpoints in that folder, and nothing else, anywhere else.

The first half is declarable in the estate, so who may exempt what is in the repository rather than in somebody's console history:

google_tags_tag_value_iam_member {
  "platform_may_exempt_keys" {
    tag_value = "${{google_tags_tag_value.exempt_service_account_keys.name}}"
    role      = "roles/resourcemanager.tagUser"
    member    = "group:gcp-platform-admins@{customer_domain}"
  }
}

The second half stays the estate's too, but is not in this pack: which folder a team owns is the customer's hierarchy, not the library's.

No new security group is needed for this, and adding one would defeat it. gcp-security-admins already holds roles/orgpolicy.policyAdmin org-wide, so that group can lift any policy today by editing it; giving it the tag as well buys only an audit trail. The point of the tag is that "may grant a narrow exemption" need not imply "may rewrite any policy", and an org-wide exemption-approver group would re-centralise exactly what the tag decentralises. Grant the node half to whoever owns the project or folder.

What a binding reaches

Tag bindings are inherited. Bound to a service account, an exemption reaches that account. Bound to a PROJECT it reaches everything in that project — including every resource created in it afterwards, for as long as the binding exists. Bound to a folder, everything below. A project-level binding is the widest and quietest exemption available; prefer binding the individual resource.

Using one

The constraint has to condition on the class. One CIS constraint does already, with no forking involved: iam.managed.disableServiceAccountKeyCreation takes its rules from the baseline param cis_sa_key_creation_rules, which defaults to the plain enforcing rule. To let one service account out, rebind it:

cis_sa_key_creation_rules = [
  {
    enforce = "FALSE"
    condition = {
      title      = "exempted service accounts"
      expression = "resource.matchTagId('${{google_tags_tag_key.exemption.name}}', '${{google_tags_tag_value.exempt_service_account_keys.name}}')"
    }
  },
  { enforce = "TRUE" },
]

then bind that value to the one account. tests/iac/exemption-tag/main.satz is the whole thing end to end: the rebound param, the class grant, and the binding.

It is the only constraint with a rules param. The rest are written in place, because a param per constraint would put forty list-of-object blocks into every estate's terraform.tfvars for a case nobody has; another constraint earns one the way this did, from a real organisation that needed it. Until then an estate can suppress a pack's policy and declare its own — a fork, and the thing the param exists to avoid.

Where an exemption is visible

satz require prints it under its control rather than letting a conditional policy read as plain "enforced" — the control is met and something is let out:

  ✓ 1.4   Only GCP-managed service account keys  — google_org_policy_policy.iam_managed_disableServiceAccountKeyCreation
      ↳ exempted: google_org_policy_policy.iam_managed_disableServiceAccountKeyCreation: enforce OFF where exempted service accounts

The estate carries the binding with its owner and reason, which is what makes a permanent exemption reviewable. And Cloud Asset Inventory answers the org-wide question, per class:

gcloud asset search-all-resources --scope=organizations/ORG \
  --query='tagValues:<shortname>-exemption/service-account-keys'

A binding somebody adds out of band — the temporary kind — is reported by satz report-compliance. It lists every cloudresourcemanager.googleapis.com/TagBinding of the organisation through Cloud Asset Inventory, keeps the bindings of this key, subtracts the google_tags_tag_binding resources the estate declares, and prints the rest in a section of its own, with each one's value and target. A binding whose value a claimed control's policy conditions on is also printed under that control (**undeclared exemption**: <org>/<shortname>-exemption/service-account-keys bound to …); the control's status stays what its witnesses make it.

Before granting any exemption

Does the consumer need one? A workload inside Google Cloud, a Cloud Run service, and external CI on GitHub or GitLab can all use Workload Identity Federation or impersonation and leave the control intact. The tag route is the exception with a named owner, never the default.

Billing and budget

billing-account-permissions.satz

Billing-account IAM, split by audience: everyone in the domain gets billing.user + billing.viewer; the full administration (billing.admin + billing.costsManager) goes to ONE group named by the billing_admins_group param (default gcp-billing-admins@{customer_domain} — the s1 model's group); the IaC service account keeps billing.admin. Declares its own google_billing_account_iam_member map, pinned to billing_account_infra.

Use (root level): use "presets/billing-account-permissions.satz"

Question. billing_admins_group is asked: the group can move projects between billing accounts and see every cost.

organization-budget.satz

Budgets on the infrastructure billing account: the organisation's, and one per entry of budgets for a set of projects or folders.

Use (root level): use "presets/organization-budget.satz"

Questions. budget_amount (the organisation budget per month), budget_currency (the billing account's own), budget_notification_email (an address beside the billing account's administrators; empty is nobody else) and budget_default_recipients_off. budget_threshold_rules (50, 80 and 100 % of current spend) and budget_channel_project (the infrastructure project) are params nobody is asked.

Recipients. Google mails every alert to the billing account's administrators and users with no resource. An entry with an email also gets an e-mail notification channel in budget_channel_project, named in its budget — no Pub/Sub. With budget_default_recipients_off = true a budget that has a channel mails its channel alone; a budget without one keeps the administrators.

More budgets. Each entry of budgets is { name amount } plus, optionally, projects (projects/<number>; "projects/${{google_project.<label>.number}}" for a declared project), folders (folders/<id> or organizations/<id>; a declared folder's "${{google_folder.<label>.name}}"), email and import_id. The first entry is the organisation's budget, global-budget; binding budgets replaces the list, so an estate that adds budgets writes it too:

budgets = [
  { name = "global-budget" amount = budget_amount },
  { name = "payments" amount = 300 projects = ["projects/${{google_project.payments.number}}"] email = "payments-team@example.com" },
]

budgets is a request point: a project asks for its own budget with contributes_budgets, checked by satz check-request.

Adopting an existing budget. Its entry's import_id = "billingAccounts/<account>/budgets/<id>" (gcloud billing budgets list --billing-account=<account>); satz adopt does not resolve budgets.

billing-export.satz

Cloud Billing usage and cost data, exported to BigQuery — what the money went on, per project, per SKU, per day, queryable months later. Where organization-budget alarms on a threshold, this keeps the record. The pack declares a project of its own, BigQuery's API on it, the dataset, and Google's export account's roles/bigquery.dataEditor on that dataset; it CONTRIBUTES that account to allowed_policy_member_subjects, so domain-restricted sharing lets the grant through while the pack is on.

The dataset lives in a project of its own rather than in the infrastructure project: whoever reviews spend reads the billing dataset, and that must not also mean reading the state bucket.

Use (root level): use "presets/billing-export.satz" when use_billing_export

Overridable defaults:

Param Default Meaning
billing_export_project_id {customer_shortname}-billing-001 the export's own project
billing_export_project_folder "" the folder it is created in; empty is the organisation
billing_export_project_display_name the project id what the console shows
billing_export_dataset_id {customer_shortname}_billing_export the dataset; a dataset id takes underscores, never hyphens
billing_export_location default_region where the dataset lives; it cannot move
billing_export_description a sentence the dataset's description

Notice. Cloud Billing has no API, no gcloud command and no Terraform resource for switching the export on. Apply the pack, then in the Cloud console open Billing, pick the billing account, go to Billing export, BigQuery export, and point Standard usage cost at the project and dataset the params name. The dataset stays empty until that is done, and the notice stays open until the estate binds billing_export_enabled = true. It is a warning, not an error: the apply that creates the dataset has to come first.

Questions. Four: the project, the folder it lands in, the dataset and its location. The project, the dataset and the location cannot be changed afterwards.

No claim. No catalog control covers billing export.

CIS baseline and extensions

CIS-GCP-Foundation-4.0.satz

The CIS GCP Foundation 4.0 organization-policy set as google_org_policy_policy resources, managed constraints included, with the superseded legacy twins declared off (below). §1.1 Domain Restricted Sharing is the managed iam.managed.allowedPolicyMembers alone, parameterized with compliant defaults: every estate is locked to its own organization, and a cross-org need is a one-line param override in the estate.

Params:

Param Default Meaning
allowed_policy_member_principal_sets own org (//cloudresourcemanager.googleapis.com/organizations/<org-id>) §1.1: principal sets whose members may be granted IAM roles
allowed_policy_member_subjects Security Command Center's five organization-level service agents §1.1: individual principals allowed past the lock — Google service accounts that org-level products grant roles to (SCC's service-org-<id>@gcp-sa-*-hpsa… / @security-center-api…, Firebase Hosting firebase-hosting@system…). An override replaces the list: keep the five when adding
essential_contacts_allowed_domains ["@{customer_domain}"] domains the Essential Contacts constraint allows, each entry @domain
allowed_resource_locations ["in:eu-locations", "in:us-locations"] gcp.resourceLocations (§2): where resources may be created. Narrow it here instead of forking

Questions. Thirteen are asked: the ten opt-in controls (each can break a running workload) and the three lists that say where and who — allowed_resource_locations, allowed_policy_member_principal_sets, allowed_policy_member_subjects. An estate using the pack has to bind all thirteen before bootstrap or transpile --apply run; satz interview <estate> --accept-defaults binds every default in one pass, since none of the thirteen needs a typed value. The technical defaults (protocol-forwarding schemes, the contacts domain) are not asked. See satz interview.

Notice. Once the baseline is switched on, satz adopt <estate> --execute --import is to run before the apply: Google sets some of these policies on every new organisation, and an apply that creates a policy that exists stops on 409 POLICY_ALREADY_EXISTS. satz prints that notice when the pack goes on and warns at its use line until the estate binds cis_baseline_adopted = true, which the adopt run does itself; transpile --apply and bootstrap refuse while it is open. Every org-policy extension carries the same notice on its own param — each is adopted when it is switched on.

Cross-org grants need the other organization in allowed_policy_member_principal_sets, beside the estate's own. Example (a lab org administered by the parent org's staff):

allowed_policy_member_principal_sets = [
  "//cloudresourcemanager.googleapis.com/organizations/{customer_organization_id}",
  "//cloudresourcemanager.googleapis.com/organizations/123456789012",
]

Service agents. Google grants roles to its service agents when a product is activated or an API is enabled for the first time. An agent that lives outside the estate's organization is not in its principal set, so the §1.1 lock refuses the grant unless allowed_policy_member_subjects names it. To allow another agent, add it to the param — keeping the five SCC agents — and apply. The legacy iam.allowedPolicyMemberDomains cannot name a principal as an exception; Google's remedy there is to disable the constraint, grant, and re-enable it, so the pack declares it off.

A pack brings its own. allowed_policy_member_subjects is the ESTATE's list, for its own exceptions. A pack that needs an external principal allowed declares it itself, as a contribution:

params {
  contributes_allowed_policy_member_subjects = [
    "serviceAccount:billing-export-bigquery@system.gserviceaccount.com",
  ]
}

A contributes_<param> declaration is no param of its own: it is never a variable, the estate never binds it, and its entries are added to whatever <param> holds — the estate's binding, else the declaring pack's default — while the contributing pack is on. Switch that pack off and its entries go with it. Two packs contributing the same entry add it once. Where the contributing line stands decides nothing: the entries are merged before anything is compiled. Where no pack in the estate declares the param at all — the CIS baseline is off, so nothing is restricting anyway — the entries are dropped, and satz packs names the requirement. Two packs contribute today: billing-export and integrations/microsoft-defender-for-cloud, each named in its own section of this page, and satz packs <estate> prints which pack put which entry in this estate's list.

SCC activation under the §1.1 lock

Activating Security Command Center Premium — the tier this library assumes — grants organization-level roles to five Google service agents, which allowed_policy_member_subjects names by default:

service-org-<org-id>@security-center-api.iam.gserviceaccount.com
service-org-<org-id>@gcp-sa-csc-hpsa.iam.gserviceaccount.com
service-org-<org-id>@gcp-sa-dspm-hpsa.iam.gserviceaccount.com
service-org-<org-id>@gcp-sa-ee-hpsa.iam.gserviceaccount.com
service-org-<org-id>@gcp-sa-ktd-hpsa.iam.gserviceaccount.com

They live in Google tenant projects, not in the customer's organization. Each address derives from the org id inside a Google-owned domain, so no one else can create an identity that matches.

An organization where the legacy iam.allowedPolicyMemberDomains is still set refuses these grants whatever the param says, because that constraint has no exception for a principal. Apply the pack before activating: it resets the legacy policy. Where that policy already exists, satz adopt --only google_org_policy_policy --execute --import imports it first (Superseded legacy constraints).

Superseded legacy constraints

Where Google replaces a legacy org-policy constraint with a managed one, a pack runs the replacement alone and declares the legacy twin OFF in the same file:

"compute-requireOsLogin-superseded" {
  name   = "compute.requireOsLogin"
  parent = "organizations/{customer_organization_id}"
  spec {
    reset = true
  }
}

The -superseded address suffix is required: switching a policy from rules to reset cannot be an in-place update — the provider PATCHes the rules it still holds together with reset, and the API refuses the pair — so the reset policy has its own address and the plan is a destroy + create by construction. The policy NAME is unchanged; it is one policy being reset, not two. ADR 0002.

Both forms in force is a defect. Org-policy constraints AND together, so an exemption has to lift two policies, and for several legacy constraints Google's only documented exemption path is to disable the constraint org-wide, grant, and re-enable it, which leaves the control off in between. The CIS pack's duty_legacy_superseded records this for every pair it enables.

Leaving the legacy policy out does not remove it. A legacy policy already set on an organisation is invisible to an apply that does not declare it: it keeps enforcing until someone deletes it by hand. reset = true restores the constraint's default — ALLOW for every constraint paired this way, verified against a live organisation — so the apply resets it, and the next apply reverts a later re-enabling. On an organisation where the legacy policy already exists, run satz adopt --only google_org_policy_policy --execute --import first so it is imported rather than created twice.

Do not suppress one of these blocks: suppressing it removes the declaration that the legacy constraint is off, so a legacy policy already set on the organisation keeps enforcing beside its managed twin.

The pairing is data, and the rule is a gate. Which managed constraint replaces which lives in presets/managed-constraint-equivalents.txt, generated from a live organisation by scripts/update_constraint_equivalents.py and never hand-edited above its CURATED marker. cargo test compiles every corpus case against that table and fails when a pack enforces a legacy constraint that has a replacement, or enables a replacement without declaring its twin off.

Only pairs need this. Of the 60 managed constraints a live organisation offers, 45 have no legacy form at all — nothing to switch off. Google declares the pairing in equivalentConstraint, asymmetrically (15 managed name a legacy twin; only 6 legacy name a managed one), and not at all for iam.allowedPolicyMemberDomains ↔ iam.managed.allowedPolicyMembers — that pair is in the file's CURATED section.

The one claim that is not about an org policy

The baseline claims CIS 5.0 §2.14, Cloud Asset Inventory enabled, against google_project_service.infra_cloudasset_googleapis_com — a resource the SCAFFOLD declares, not the pack. Every estate satz init writes enables cloudasset.googleapis.com in its infrastructure project, so every estate already satisfied that control and said nothing about it; it was the last technical control of CIS 5.0 with no claim anywhere in the library.

The pack does not declare its own google_project_service for the API. Two resources enabling one API on one project is a duplicate Terraform resource, not a merge.

So the estate's project labels are a contract. google_project.infra is what bootstrap imports by name, and the emitter derives that service address from it (<project label>_<service, dots to underscores>). An estate whose infrastructure project carries a different label, or whose service list has lost cloudasset.googleapis.com, reports this control as a broken claim — which is the right signal twice over: the witness named is genuinely not there, and report-compliance reads the organisation through that same API, so it could not verify anything else either.

cis/

Two of these are ON by default: cis_dns_logging and cis_block_internet_ssh_rdp. Every other fragment here is off until a customer asks for it, because each one restricts what an organisation may create. These two are what an organisation is expected to have already.

DNS logging does not restrict anything: it makes a RECORD — of name resolution, which is how a compromised host asking for its command-and-control domain becomes visible, and which nothing reconstructs afterwards.

The admin-port policy closes TCP 22 and 3389 to the INTERNET, which is what CIS 3.6 and 3.7 ask for. It does not close them internally: its pass list carries the private blocks beside Google's IAP ranges, because the deny matches 0.0.0.0/0 — every address, private ones included — and a hierarchical policy is read before the VPC rules that would otherwise allow internal traffic. What breaks is a bastion reachable on a PUBLIC address; that access belongs on IAP TCP forwarding, which reaches an instance without an open admin port.

Switching either off is a decision, so the estate that makes it writes a deviates claim, whose reason is mandatory and which an auditor reads in the compliance report. The library does not argue; it records who decided and why.

VPC flow logs are not in this directory, and do not need to be: the baseline pack enforces compute.requireVpcFlowLogs and claims CIS 4.0 §3.8 / 5.0 §3.10 with it. That constraint refuses a subnet without flow logs: the apply fails with Error 412: Constraint constraints/compute.requireVpcFlowLogs violated. Every subnet an estate or a pack declares under the baseline therefore carries a log_config block; shared-network writes one on each subnet it declares, with shared_subnet_flow_sampling as its sampling rate.

CIS coverage beyond the baseline, one fragment per control, all opt-in. The base pack declares the flags (cis_require_shielded_vm and friends, all false); an estate turns one on and uses its fragment:

cis_require_shielded_vm = true
use "presets/cis/shielded-vm.satz" when cis_require_shielded_vm
use "presets/cis/dns-logging.satz" when cis_dns_logging

Each is opt-in because it can break a running workload: Confidential Computing is limited to particular machine families, Shielded VM needs image support, CMEK needs the keys and grants to exist first, Cloud SQL hardening cuts public-IP connectivity, the bucket-retention constraint applies to every bucket in the organisation, not only the log sink's, Access Approval makes every support case that needs the customer's content wait for an approval, the SSH/RDP policy ends every session that reaches an instance straight from the internet, and the Cloud SQL IAM/deletion-protection pair refuses every create or update without both settings. The comment at the top of each fragment says what specifically breaks.

Measure before you enforce: the dry-run twins

"Will this break us?" has an answer that is not a guess. An org policy can carry dry_run_spec instead of spec: Google evaluates every rule, writes a violation to the audit log for each action it WOULD have blocked, and blocks nothing. Six of these fragments ship a dry-run twin that does exactly that:

cis_cloud_sql_hardening_dry_run = true
use "presets/cis/cloud-sql-dry-run.satz" when cis_cloud_sql_hardening_dry_run

Apply it, let the organisation run, then read the violations:

protoPayload.metadata."@type"="type.googleapis.com/google.cloud.audit.OrgPolicyViolationInfo"

Zero violations over a representative period means enforcing costs nothing. Promote by switching the dry-run param off and cis_cloud_sql_hardening on.

A twin replaces its enforcing fragment, it does not sit beside it. Both params true is refused, naming both and what each choice means: the twin declares the same policy addresses, so an estate asking for both asks for one policy to block and measure at once.

A twin carries no claim. A dry run discharges no control while it measures, so require reports the control unmet — the truth. A claim over a dry-run policy would be contradicted by its own witness.

The twins are GENERATED from the enforcing fragments by scripts/build_dry_run_fragments.py and must not be edited: a dry run that measures a different policy than the one that will be enforced answers a question nobody asked. Six fragments have a twin; five do not, and the convention tolerates that rather than pretending otherwise — the legacy constraints (Shielded VM, both CMEK list constraints) have no dry-run form, Access Approval is not an org policy, and the two on-by-default extensions have nothing to size.

fragment controls why it is not in the baseline
block-project-ssh-keys 4.3 the managed constraint is still PREVIEW, with no legacy equivalent
shielded-vm 4.8 image support; the only one with no managed form and no dry-run
confidential-computing 4.11 machine-family limited; CIS rates it Level 2
cloud-sql 6.5, 6.6 (5.0: 6.7) existing public-IP instances lose connectivity
cmek 7.2, 7.3, 8.1 keys, key rings and service-agent grants must exist first
api-key-services 4.0 1.14 / 5.0 1.15 narrows what an API key may call
bucket-retention 4.0 2.3 / 5.0 2.4 constrains every bucket's retention duration
access-approval 4.0 2.15 / 5.0 2.16 support cases wait for an approval; Access Transparency must be on first
internet-ssh-rdp 3.6, 3.7 ends SSH and RDP sessions that reach an instance straight from the internet
cloud-sql-iam-and-deletion-protection 5.0 6.6, 6.9 every Cloud SQL create and update must carry both settings

Three fragments are not org-policy constraints. access-approval is an organisation setting (google_organization_access_approval_settings); Access Transparency, which it needs, has no provider resource and is switched on in the console first. internet-ssh-rdp is a hierarchical firewall policy attached to the organisation, and one of the two fragments here that are ON by default. Four rules: SSH and RDP from admin_port_source_ranges (1000) and admin_port_source_ranges_ipv6 (1001) pass to the VPC firewall rules, which still decide; every other address is denied (1002 for IPv4, 1003 for IPv6) before any VPC rule is read. The families are separate rules because a rule's sources may not mix IPv4 with IPv6.

The default pass lists are Google's IAP TCP-forwarding ranges — 35.235.240.0/20, and 2600:2d00:1:7::/64 for IPv6 VMs — plus the private blocks. Those are there because the deny matches 0.0.0.0/0, which is any IPv4 address, private ones included, and a hierarchical policy is read before the VPC rules that would otherwise allow internal traffic: without them, SSH between two instances in one subnet is denied. An estate that wants internal SSH denied as well removes them.

Each rule carries the control's full protocol set — SSH on TCP 22 and SCTP 22, RDP on TCP 3389 and UDP 3389 — because Google's own detectors check all four and a TCP-only deny leaves UDP 3389 open.

Only the two deny rules log, and that is Google's rule rather than a choice: logging cannot be enabled on a goto_next rule. So the firewall record is of what was REFUSED; an accepted IAP session leaves no firewall log. That is the stream integrations/microsoft-sentinel-network-logs.satz carries, and it is empty while this fragment is off.

Prowler's checks read the VPC rules, so a VPC rule allowing 0.0.0.0/0 still fails there and the row reads CONTESTED until the rule is deleted. The same is true of Security Command Center's OPEN_SSH_PORT, whose supported asset is the VPC firewall rule: a hierarchical deny shadows such a rule without clearing the finding, so delete default-allow-ssh and default-allow-rdp rather than relying on this policy to hide them. cloud-sql-iam-and-deletion-protection declares two custom constraints (google_org_policy_custom_constraint) and a policy enforcing each; the emitter makes each policy wait for its constraint. A custom constraint is checked when an instance is created or updated, never against one that already exists.

Constraint names and shapes come from a live organisation's OrgPolicy ListConstraints, not from documentation. The three shapes differ, and a policy in the wrong shape either does nothing or refuses everything: a plain managed boolean takes enforce; a managed boolean with a parameter takes enforce plus parameters; a list constraint takes allow/deny values.

Questions. The five fragments with a list to fill ask for it: the API services a key may target (the empty default blocks: an empty list is a valid answer, but the customer gives it), the allowed retention durations, the CMEK services and key projects, the addresses that receive access approval requests (empty blocks until named), and the ranges that may still reach SSH and RDP. Whether a fragment is on at all is the CIS pack's question, not the fragment's — a question that gates a pack cannot live in the gated pack.

Monitoring and logging

monitoring/organization-audit-logsink.satz

Organization-wide audit trail: enables Data Access audit logs for all services org-wide, creates its own destination project + GCS archive bucket, and routes all Cloud Audit Logs from every current and future project into that bucket via an aggregated org-level sink (project owners cannot bypass it). Self-contained — multi-resource-type.

Use — logsink_project_folder says where the destination project is created, so the line stands at the top level:

params {
  logsink_project_folder = "google_folder.shared_services.name"
}

google_folder {
  shared_services {
    display_name = "Shared Services"
  }
}

use "presets/monitoring/organization-audit-logsink.satz" when logsink_project_id

Overridable defaults (names are derived from customer_shortname, so they are globally unique without overrides):

Param Default Meaning
logsink_project_id "{customer_shortname}-log-infra-001" project_id of the destination project
logsink_project_folder "" the folder the destination project is created in — a folder the estate declares by reference (google_folder.<label>.name), one that already exists by its id. Empty says nothing and the project is created under the organisation
logsink_bucket_name "{customer_shortname}-organization-audit-logs" GCS archive bucket
logsink_bucket_location default_region bucket region
logsink_retention_days 400 lifecycle delete age
logsink_name "{customer_shortname}-organization-audit-gcs" display name of the sink
logsink_filter the four Cloud Audit log streams sink filter — extend to archive more (e.g. VPC flow logs), never narrow below the audit streams

Questions. The project, the bucket, its location and the retention are asked; each is a recreate or an irreversible deletion if changed later. The sink name and the filter are technical defaults. satz interview <estate> --accept-defaults binds all four once customer_shortname and default_region are answered — the names derive from them, and a derived default is offered only when its inputs are in.

Notes: Retention lock (retention_policy.is_locked) is not set: a lock cannot be undone, so the claim carries a duty to set it once the pipeline is validated. DATA_READ org-wide can be voluminous — measure a week of volume before narrowing it.

monitoring/ — CIS 2.5–2.12 (log metrics + alerts)

The eight alert controls are numbered §2.5–2.12 in CIS 5.0 and §2.4–2.11 in CIS 4.0 (5.0 inserted a new §2.2; 4.0's §2.12 is DNS logging). Resource labels and this document use the 5.0 numbers; each pack claims both versions with the right id, and doc-packs --check refuses a claim on an id its catalog lacks.

Two variants of the same eight controls; the central one is the default. Both may coexist: a control passes when either path is satisfied.

central per project
File organization-cis-log-alerts-central.satz project-cis-log-alerts.satz
Covers every project in the org, current and future one named project
Resources 1 logging bucket, 1 sink, 8 metrics, 8 policies, 1 channel 8 metrics, 8 policies, 1 channel — per project
New project covered on creation, no config change needs its own use; without it the project fails 2.5–2.12
Recipients one org-wide channel; per-control routing possible own channel per project
Cost audit logs stored twice (GCS archive + logging bucket) none beyond the metrics

monitoring/organization-cis-log-alerts-central.satz

One Cloud Logging bucket, a second organization sink into it, and eight bucket-scoped metrics with alert policies — covering the whole organization. Multi-resource-type.

Use (root level):

use "presets/monitoring/organization-cis-log-alerts-central.satz" when cis_central_bucket_project

Overridable defaults:

Param Default Meaning
cis_central_bucket_project logsink_project_id project hosting bucket, metrics, policies, channel: the audit-logsink pack's own project, by reference — an estate using both packs sets nothing
cis_central_bucket_id "{customer_shortname}-organization-log-alerts" Cloud Logging bucket id
cis_central_bucket_location default_region bucket location
cis_central_bucket_retention_days 30 short, because the archive lives in GCS
cis_central_sink_name "cis-central-metrics-sink" second org sink
cis_central_email "gcp-security@{customer_domain}" recipient — a full address, any domain. The mailbox must exist and receive external mail (Monitoring sends from alerting-noreply@google.com); a group whose members have no mailboxes drops every alert
cis_central_channel_name "CIS Security Alerts (org)" channel display name
cis_central_alert_window "300s" alert alignment period

Questions. cis_central_email — the mailbox must exist and accept external mail, or every alert is dropped — and cis_central_bucket_project, which defaults to the logsink pack's project by reference. The rest are technical defaults and are not asked.

How the credit works. Prowler's CIS metric checks are written per-project, but it credits a child project when an org sink with include_children routes its logs to a Cloud Logging bucket carrying a matching bucket-scoped metric with an alert (logging_service.get_projects_covered_by_aggregated_metric). Eight metrics therefore cover the whole organization.

Why a second sink is needed — "bucket" means two different products. A GCS bucket (storage.googleapis.com/<name>) is Cloud Storage: the sink drops hourly JSON files into a folder path, and to Cloud Logging those are files, not logs — no metric can count them. A Logging bucket (logging.googleapis.com/projects/…/buckets/…) is a container inside Cloud Logging: entries stay indexed, searchable in Log Explorer, and usable by bucket-scoped metrics. Every project already has _Default and _Required.

Two controls demand different destinations, hence both sinks:

GCS is the tamper-evident archive, the logging bucket the queryable surface for metrics and alerting. Audit logs are therefore stored twice — keep the logging bucket's retention short.

Prowler version. The credit needs a Prowler that has get_projects_covered_by_aggregated_metric and org-level sink collection (_get_org_sinks). A Prowler without them does not see organization sinks in these checks: the central setup is not credited, and CIS 2.3/2.4 return no result although a GCS sink and bucket exist. Check both:

prowler --version
python3 -c "
from prowler.providers.gcp.services.logging import logging_service as m
print('org-sinks:', hasattr(m.Logging, '_get_org_sinks'))
print('central-credit:', hasattr(m, 'get_projects_covered_by_aggregated_metric'))"

Both must print True; otherwise upgrade Prowler or use the per-project variant.

Keep the sink filter wide. Prowler credits the sink only when the filter provably carries the Admin Activity stream: empty, all, or OR-combined Cloud Audit selectors. A filter with an AND, NOT or != loses the credit for every project.

Notes: enable logging.googleapis.com and monitoring.googleapis.com in the logging project. Verify the credit after apply by running Prowler: 2.5–2.12 must pass for every scanned project, not only the logging project — if only that one passes, the sink filter or destination is wrong. A freshly created logging bucket can answer 404 to the metrics for about a minute while it propagates; a second apply creates the metrics that failed.

Smoke test — prove the pipeline end to end

One alert tests the whole chain (org sink → central logging bucket → bucket-scoped metric → policy → email channel). §2.7, custom role changes, needs no VPC and no API enablement, and leaves nothing behind:

# any project of the org works — the sink is org-wide with include_children
gcloud iam roles create smoke_test_2_7 --project=<any-project> \
  --permissions=resourcemanager.projects.get --title="smoke test"
# expect the email at the channel recipient within ~5–10 minutes, then clean up
# (the delete fires §2.7 again — a free second sample):
gcloud iam roles delete smoke_test_2_7 --project=<any-project>

Org-level --organization=<org-id> also works if the caller holds iam.organizationRoleAdmin; the IaC service account holds it, so impersonating it works too.

If no email arrives in ~15 minutes, trace the stages:

# 1. did the audit event happen?
gcloud logging read 'protoPayload.methodName="google.iam.admin.v1.CreateRole"' \
  --project=<any-project> --freshness=15m --format='value(timestamp)'
# 2. did the org sink deliver it into the central alerts bucket?
gcloud logging read 'protoPayload.methodName="google.iam.admin.v1.CreateRole"' \
  --bucket=<cis_central_bucket_id> --location=<cis_central_bucket_location> \
  --view=_AllLogs --project=<cis_central_bucket_project> --freshness=15m \
  --format='value(timestamp)'
# 3. stages 1+2 fine but no mail → check the channel address is real and
#    mail-enabled, and look for an incident on the policy in Cloud Monitoring.

The §2.8 (firewall) variant needs a project with the compute API enabled and an existing VPC: gcloud compute firewall-rules create smoke-test-2-8 --project=<p> --network=<vpc> --action=deny --rules=tcp:9999 --source-ranges=192.0.2.0/24, then delete it. Do not enable the compute API for this test: under the CIS §1.1 lock, first-time API enablement can fail on the service agent's role grant (service agents), and §2.7 tests the same pipeline.

monitoring/project-cis-log-alerts.satz

CIS GCP Foundations 2.5 – 2.12 for one project: eight log-based metric filters, one alert policy each, and the email notification channel they fire into. Multi-resource-type.

Use (root level — the target project comes from the resources' project attribute, not from where the use sits):

use "presets/monitoring/project-cis-log-alerts.satz" when cis_alert_project

Overridable defaults:

Param Default Meaning
cis_alert_project "{infra_project_name}" project hosting metrics, policies and channel
cis_alert_email_local "gcp-security" local part of the recipient group address
cis_alert_channel_name "CIS Security Alerts" channel display name
cis_alert_window "300s" alert alignment period

Questions. cis_alert_project (one project per use) and cis_alert_email_local (the mailbox rule above). The channel name and window are not asked.

One project per use — no parameterisation of labels. The resource labels are fixed, so using the pack twice folds the same addresses with different bodies — a hard error. For a second project either fork the pack (.local.satz) and prefix every label and metric name, or use the central variant above.

Alerts cannot go to Essential Contacts. That is Google's channel for notifying the customer (security bulletins, billing, suspension), not a Cloud Monitoring channel — alert policies cannot target it. Use the same group mailbox in both systems instead: one inbox, both sources.

Notification channels are project resources. google_monitoring_notification_channel lives in a project and an alert policy can only reference channels from its own project — there is no org-level channel and no cross-project reference. N projects therefore mean N channels pointing at the same mailbox, N×8 metrics and N×8 policies, and every new project needs the pack again; without it the project fails 2.5–2.12.

Notes: enable monitoring.googleapis.com (and logging.googleapis.com) in the target project's project_service list. The recipient group must exist in Cloud Identity before apply — Google accepts unverified email channels, but sends nothing to them. Filter strings are compared by substring against Prowler's expectation: a reformatted filter keeps the alert working and fails the compliance check — the preset header has the expected strings and the end-to-end test. Prowler does not check whether a policy has a recipient; notification_channels = [] passes CIS and notifies nobody.

essential-contacts-organization.satz

One organization-level Essential Contact subscribed to ALL notification categories. A bare list of labelled contacts: use it inside the resource map. Its params and its question reach the estate from there; the map receives the contacts alone.

Use:

google_essential_contacts_contact { use "presets/essential-contacts-organization.satz" }

Overridable defaults:

Param Default Meaning
essential_contacts_email "essential-contacts-all@{customer_domain}" the contact address

Splitting by category: the pack carries COMMENTED contacts for each category — BILLING, SUSPENSION, SECURITY, TECHNICAL, LEGAL, PRODUCT_UPDATES, and a multi-category oncall example — each with its own essential_contacts_<category>_email param. Uncomment what you split out (in a .local fork, or the estate declares them directly), give each a distinct address, and narrow or delete the all contact: one address may appear once per parent, and an address on ALL already receives everything.

Question. essential_contacts_email is asked: the mailbox must exist and accept external mail; otherwise Google's suspension, security and legal notices reach nobody.

Networking

shared-network.satz

A shared VPC in a host project of its own: the host project, the shared-VPC host switch, one custom-mode network, and a network firewall policy attached to it. The subnets and the firewall rules are what the projects ask for: two lists with request points, filled through contributions, one resource per entry (each).

Use — the host project lands in the folder network_host_project_folder names, so the line stands at the top level:

params {
  use_shared_network          = true
  network_host_project_folder = "google_folder.infra_folder.name"
}

use "presets/shared-network.satz" when use_shared_network

Requests. A project writes its entries as a small pack, checks it with satz check-request <file> <estate> and hands it to the estate's repository by pull request (satz/requests/<project>.satz and its use line):

pack requests_payments version "1.0"

params {
  contributes_shared_vpc_subnets = [
    { name = "payments-app" cidr = "10.20.0.0/24" region = "europe-west3" },
  ]
  contributes_shared_firewall_rules = [
    {
      name        = "payments-https"
      priority    = 1000
      direction   = "INGRESS"
      action      = "allow"
      description = "HTTPS to the payments subnet from the internal range"
      match       = { src_ip_ranges = ["10.0.0.0/8"] layer4_configs = [{ ip_protocol = "tcp" ports = ["443"] }] }
    },
  ]
}
request point key fields
shared_vpc_subnets name name, cidr (primary range), region
shared_firewall_rules name name, priority, direction (INGRESS/EGRESS), action (allow/deny/goto_next), description, match — the policy rule's match block as an object

What the projects read — the common interface network:

export value attach point for
network_host_project the host project's id google_compute_shared_vpc_service_project: a project attaches its own Google project as a service project, in its own state, as an identity with Shared VPC Admin
shared_network projects/<host>/global/networks/<name> —
shared_subnets every subnet of the host project, by label (- written _: payments-app is the key payments_app) google_compute_subnetwork_iam_member: a project grants roles/compute.networkUser on its own subnet

Overridable defaults:

Param Default Meaning
network_host_project_id "{customer_shortname}-net-host-001" the host project's id, asked
network_host_project_folder "" the folder the host project is created in, asked; empty is the organisation
shared_network_name "shared" the network's name; the policy is <name>-policy
shared_subnet_flow_sampling 0.5 the share of flows every subnet logs — the CIS baseline's compute.requireVpcFlowLogs refuses a subnet without flow logs (CIS 3.8); an idle subnet logs nothing
shared_vpc_subnets [] the subnets, filled by requests
shared_firewall_rules [] the policy's rules, filled by requests

Nothing in the pack bills while idle.

Projects and interfaces

project-onboarding.satz

The projects on the estate: one entry of projects per project, and per entry its Google project, its IaC service account and state bucket, the grants, and the project's own interface — written by each projects by name { interface "{each.name}" { … } } (language §6.17).

Use — the projects land in the folder project_onboarding_folder names, so the line stands at the top level:

params {
  use_project_onboarding    = true
  project_onboarding_folder = "google_folder.workload_folder.name"
  projects = [
    { name = "payments" owner_group = "payments-owners@example.com" },
  ]
}

use "presets/project-onboarding.satz" when use_project_onboarding

Requests. A project asks to be onboarded with a small pack of its own, checked with satz check-request <file> <estate> and vendored into the estate by pull request:

pack requests_payments version "1.0"

params {
  contributes_projects = [
    { name = "payments" owner_group = "payments-owners@example.com" },
  ]
}
request point key fields
projects name name (the project's name: its interface's, and a folder name — lowercase letters, digits and -, starting with a letter), owner_group (the address of the group that reads the project and may become its IaC service account, <name>@<domain>)

The request point's patterns hold both fields: a name outside [a-z][a-z0-9-]* and an owner_group that is not <name>@<domain> — payments-owners, or group:payments-owners@example.com — are refused at compile and by satz check-request, naming the entry and the value.

Per entry payments:

resource what
google_project.payments {customer_shortname}-payments-001 under project_onboarding_folder, billed to billing_account_infra, with the Cloud Resource Manager, IAM, IAM Credentials, Service Usage and Cloud Storage APIs
google_service_account.payments_iac svc-iac-payments, the IaC service account the project's estate runs as; it holds roles/resourcemanager.projectIamAdmin and roles/serviceusage.serviceUsageAdmin on the project, so the project's estate grants itself the rest (satz update-prerequisites there)
google_storage_bucket.payments_state {customer_shortname}-payments-001-state in default_region, versioned, public access prevented; the account holds roles/storage.objectAdmin on it
the owner group's grants roles/viewer on the project; roles/iam.serviceAccountTokenCreator and roles/iam.serviceAccountUser on the account alone

A name with - is the label with _: data-lake is google_project.data_lake.

What the project reads — its own interface payments, in interfaces/payments/:

export value attach point for
project_id the Google project's id google_project_iam_member: the project grants on its own Google project in its own state
project_number its number, looked up —
iac_account the IaC service account's email —
state_bucket the state bucket's name —

satz init --project payments --interface interfaces/payments/payments/satz/interface.satz writes the project's estate from these and the core export default_region. The estate adds to a project's interface with a block of the same name in its own file — interface "payments" { use interface "network" } — since an interface declared in two files is one.

Overridable defaults:

Param Default Meaning
projects [] the projects, bound in the estate or filled by requests
project_onboarding_folder "" the folder the projects are created in, asked; empty is the organisation, the workload folder is "google_folder.workload_folder.name"

Nothing in the pack bills while idle.

workload-deployer.satz

One deployer identity per customer team: a Google project of the team's own holding the service account its pipelines run as and the bucket its states live in, and the roles that account needs to roll out folders and Google projects under the workload folder — nothing more. A team then never runs as the organisation's IaC account.

Use — the teams' projects land in the folder deployer_project_folder names, so the line stands at the top level:

params {
  use_workload_deployer   = true
  deployer_project_folder = "google_folder.infra_folder.name"
  workload_deployers = [
    { name = "platform" group = "gcp-project-admins@example.com" },
  ]
}

use "presets/workload-deployer.satz" when use_workload_deployer

Requests. A team asks for its identity with a small pack of its own, checked with satz check-request <file> <estate> and vendored into the estate by pull request:

request point key fields
workload_deployers name name (the team's name — lowercase letters, digits and -, starting with a letter), group (the address of the group that may become the account, <name>@<domain>), folder (the parent the team creates under; google_folder.workload_folder.name when left out)
workload_deployer_folders name name, deployer (the team's name), folder — one entry per further parent folder a team creates under

Per entry platform:

resource what
google_project.platform {customer_shortname}-deploy-platform under deployer_project_folder, billed to billing_account_infra, with the Cloud Billing, Cloud Resource Manager, IAM, IAM Credentials, Service Usage and Cloud Storage APIs
google_service_account.deploy_platform svc-deploy-platform, the account the team's pipelines impersonate
google_storage_bucket.deploy_platform_state {customer_shortname}-deploy-platform-state in default_region, versioned, public access prevented; the account holds roles/storage.objectAdmin on it
the account's grants deployer_folder_roles on the entry's folder (and on each further folder of workload_deployer_folders), deployer_billing_roles on the billing account, deployer_organization_roles on the organisation
the group's grants roles/viewer on the project; deployer_group_roles on the account alone

The one organisation-level right, roles/browser, is what the interface's lookups need: the workload folder is looked up by its name among the organisation's folders, and that listing needs resourcemanager.folders.list on the organisation. Without it the team's plan stops at the lookup.

Overridable defaults:

Param Default Meaning
workload_deployers [] the teams, bound in the estate or filled by requests
workload_deployer_folders [] further parent folders per team
deployer_project_folder "" the folder the teams' projects are created in, asked; empty is the organisation, the infrastructure folder is "google_folder.infra_folder.name"
deployer_folder_roles folderAdmin, projectCreator, projectIamAdmin, serviceUsageAdmin what the account holds on its parent folder(s)
deployer_organization_roles ["roles/browser"] what it holds on the organisation
deployer_billing_roles ["roles/billing.user"] what it holds on the billing account
deployer_group_roles tokenCreator, serviceAccountUser what the team's group holds on the account

The account's address is the pattern svc-deploy-<name>@{customer_shortname}-deploy-<name>.iam.gserviceaccount.com; the pack exports no interface. Nothing in the pack bills while idle.

interface-notice.satz

Tells the projects that read the estate's interface when an exported value changes. Gated on interface_notice_pubsub, the Pub/Sub option of the map's choice question oneof interface_notice, off by default.

use "presets/interface-notice.satz" when interface_notice_pubsub

The choice has one option per delivery form, and answering none leaves every form off; Pub/Sub is the one satz has. A further form — a webhook, a push of the interface to a customer repository — is a further option and its own pack beside this one.

In the infrastructure project (interface_notice_project) it declares a bucket that is not the state bucket ({customer_shortname}-infra-001-interface), a Pub/Sub topic ({customer_shortname}-satz-interface), roles/pubsub.publisher on the topic for Cloud Storage's service agent, a storage notification for OBJECT_FINALIZE on the bucket, and the object interface.json, whose content is the root module's local.satz_interface — every exported value, the core ones under core and each interface's under interfaces."<name>" — as JSON. Terraform rewrites the object only when its content changes: an apply that changes an exported value publishes one message, an apply that changes nothing publishes none.

It exports interface_topic, the topic's id, and interface_object, the object's gs:// URL, as core exports, so every interface and its README carry them. A project subscribes in its own state with a google_pubsub_subscription on the topic — a push to its CI's webhook, or a pull from a runner — and reads the new values from the object the message names.

The service agent's address carries the project number, so the pack reads it with the provider's google_storage_project_service_account data source in a trusted hcl block, which the compile notes on every transpile.

A pack that publishes an interface

A pack publishes what the projects beside the estate read with export, core or inside an interface block (language §6.17). A project is an estate that depends on parts of the estate's interface, with its own repository or folder, config, state and pipeline.

A pack's interface is common. An interface a pack declares — interface "network" { export … } — is in the library every project's folder carries (interfaces/common/ and interfaces/<project>/), without the word common. One an each writes is the exception: each projects by name { interface "{each.name}" { … } } writes one interface per entry, and each is that project's own (project-onboarding.satz). A project's interface takes it with use interface "network": the author keeps the set stable, and every project receives it. A pack's interface uses only common interfaces. What satz generates from it — interfaces/…/network/hcl/ and satz/interface.satz — is written per estate beside that estate: it holds one estate's values and lookup keys, and nothing generated goes into the preset library (satz doc-packs and satz pack-graph refuse an interface file there).

An export is a contract with the projects that read it. A pack version that removes or renames an export, or changes the shape of its value — a string that becomes a list, a folders/<number> that becomes a bare number — fails a project that reads it, in its plan or in its compile. It is a breaking change: the version's row under ## Changelog names the export, an entry under ## Breaking changes says what a project edits, and the release that ships it is a minor one. A new export breaks no one.

A label is part of that contract when the pack's resources reach an all export. export "…" = all <type> keys its map by resource label, and projects index by it (module.satz.folders["infra"]). A pack version that renames a label of such a type changes a key: its ## Changelog row names the old and the new label, and the release is a minor one. Labels change for a reason, never for style.

Each central resource a pack declares is a stub or central-only, and the choice is security's as much as the provider's. It decides how a project changes it:

An interface's README shows every export as read, and an attach point with the types it takes; a project estate's compile and satz check-consumer hold a project to the same table.

attach-points.yaml

Not a pack: the attachment resource types an export … attach [ … ] may name, and what each one conflicts with in the estate — per type the argument that names the shared object, and the estate's authoritative form of the membership: an attribute the estate must not set (a perimeter's status.resources), the entry its lifecycle { ignore_changes } must hold, or a resource type the estate must not declare on the same node (google_<node>_iam_policy and _iam_binding beside *_iam_member). The table is compiled into satz; satz check-consumer and a project estate's compile read the same rows to find a project's attachments (language §6.17).

interface-lookups.yaml

Not a pack: how the interfaces under interfaces/ read back what an estate emits, per resource type — the data source, the keys it is looked up by, the read permission the lookup needs, the attributes it yields, and the attributes that derive from what satz writes (a service account's email, a bucket's url). The table is compiled into satz; an export that needs a lookup of a type it has no row for is refused at compile, naming the type (language §6.17).

Security operations and integrations

Turning the SCC services on — presets/scc/scc-enable-all.sh

Service (module) enablement has no provider resource, so no preset can express it; neither can tier activation. Everything downstream of this step has provider resources: custom modules, sources and source IAM, v2 notification configs, BigQuery exports, mute configs, Security Posture.

presets/scc/scc-enable-all.sh is that step: every service ENABLED at the org, every folder and project below it INHERITED. Dry run by default.

presets/scc/scc-enable-all.sh --organization 123456789012              # dry run
presets/scc/scc-enable-all.sh --organization 123456789012 --apply      # write

scc/scc-service-enablement.satz binds it, so an estate does not retype the org id — one use is the whole thing:

use "presets/scc/scc-service-enablement.satz"
satz run-actions estate.satz              # print the resolved command line, run nothing
satz run-actions estate.satz --check      # the dry run above
satz run-actions estate.satz --execute    # adds --apply

The pack has no resources. The script sits beside the pack rather than in scripts/ because get-presets downloads presets/** and nothing else, and the action must find its script in the estate's copy of the presets.

scc/scc-notifications.satz — findings out of the console

The first of the downstream resources: where findings GO. A notification is a chain of three, and two of them sit outside the notification config:

use "presets/scc/scc-notifications.satz"

Nothing here subscribes to the topic: an organisation with a SIEM points it at the topic, and one without adds scc/scc-findings-mail.satz below. No catalog control covers SCC, so the pack claims nothing. It needs the detectors switched on to have anything to publish — use it with scc/scc-service-enablement.satz.

scc/scc-findings-mail.satz — the mailbox that is told

For an organisation with nothing subscribed to the topic. A Pub/Sub topic with no subscription drops every message it receives, so findings would be published into nothing.

use "presets/scc/scc-findings-mail.satz"

Three resources on top of the notification pack, whose topic and project it takes: a subscription that keeps seven days, an e-mail notification channel, and an alert policy that fires when messages reach the topic. The address is asked (scc_findings_email), and its default is the central alert pack's cis_central_email BY REFERENCE — one security mailbox for the CIS alerts and the findings. Without that pack and without an answer the compile stops with unknown param 'cis_central_email', which is the honest failure: the recipient is undecided.

What the mail says is that findings were published, with a link — not the finding itself. Cloud Monitoring alerts on a metric, and the metric is how many messages reached the topic; the finding's text is in the topic, the console and the export. Putting the finding in the body needs a subscriber that formats it, which is code, and code is not a resource.

The mailbox must exist and accept mail from alerting-noreply@google.com. A group whose members have no mailboxes drops every alert and nothing in the estate can see it happen — a group, never a person.

scc/scc-findings-siem.satz — the SIEM pulls them

The other destination, for a customer whose security team works in Microsoft Sentinel, Defender for Cloud, Splunk or QRadar rather than in a mailbox.

use "presets/scc/scc-findings-siem.satz"

Two resources on the notification pack's topic: a PULL subscription of its own — every connector in this class pulls, and a push endpoint needs a URL the estate cannot know — and roles/pubsub.subscriber for the identity the connector reads as. The grant is the half that is forgotten: without it a connector authenticates, finds the subscription and reads nothing.

The identity is asked (scc_siem_subscriber, a full IAM member) and has no default. For Microsoft Sentinel it is the service account integrations/microsoft-sentinel.satz creates; for Defender for Cloud the one its onboarding script names; for another SIEM whatever its connector authenticates as. A default would tie Security Command Center to one vendor's pack, and a wrong one is a subscription nobody can read.

This and the mailbox are not exclusive — each makes its own subscription, so the SIEM ingests every finding while the mailbox is told they are arriving. Two readers on ONE subscription would split the findings between them, which is why they do not share.

scc/scc-export.satz — findings kept and queryable

The other half: a notification tells somebody now, an export answers what the organisation looked like months ago.

use "presets/scc/scc-export.satz"

Four resources, and their order matters: bigquery.googleapis.com in the dataset's project, the dataset (which takes its project THROUGH that service resource, so the API is enabled before the dataset is made), roles/bigquery.dataEditor for the exporting agent — the same gcp-sa-scc-notification identity that publishes notifications, and the principal the export reports — and the v2 export itself. Even with the ordering, a first apply can fail with "The project … has not enabled BigQuery": the API is on and BigQuery's control plane is a minute behind. Run it again.

The export pins its own name, which the server assigns: without it every plan proposes to null the field and the API refuses ("Field name is immutable"). Its other fields do not update in place either — a new description, dataset or filter means replacing the export (satz apply -replace=…), which keeps the dataset.

delete_contents_on_destroy stays false, so removing the pack from an estate never deletes the finding history. The dataset's location is asked and cannot be changed afterwards; where the CIS pack enforces gcp.resourceLocations, a location outside that list is refused at apply.

Not in the library, and why: mute configs (which findings to silence is a customer's noise decision, and a wrong mute hides a real finding — write them in the estate), custom modules for Security Health Analytics and Event Threat Detection (the rules are content a customer's security team owns; on a 2026 organisation SHA's modules cannot be set at all, see below), sources and source IAM (only for a customer pushing third-party findings into SCC), and Security Posture, which is a framework of its own rather than part of this preset.

A pack may declare an action too, and satz doc-packs puts it on the pack's page. An action is a step satz runs, never a witness: no claim covers what a script did, and nothing about it reaches report-compliance. Because get-presets downloads packs from this public repository, every compile warns when one declares an action, --no-pack-actions ignores pack-declared ones, and a downloaded script arrives without its executable bit, which satz does not set.

A new action is written in Python unless a shell script is simpler for the job. The extension decides how satz launches the file: a .py action is spawned as uv run --script <file>, runs on every platform satz ships for and needs no executable bit, while a .sh action is refused on Windows before the spawn.

The script enables every service except Web Security Scanner (it actively crawls the customer's web apps) and Artifact Analysis (billed per image scan), which --with-optional adds, and the AWS/Azure connectors, which --with-multicloud adds. A detector for a workload that does not exist yet costs nothing, so the rest are enabled before the workload exists.

The §1.1 lock applies here as well: SCC's service agents get their roles at the organization, and the lock refuses any agent allowed_policy_member_subjects does not name. In the console a refused agent shows as SCC not staying activated and asking to be activated on every visit.

Five agents, however many services are enabled. With every service that can be enabled turned on — all fourteen GCP-side ones, including the four reachable only through the API — a live organization's IAM policy carried exactly the five the baseline lists (securitycenter, cloudsecuritycompliance, dspm, externalexposure, containerthreatdetection service agents). They come with SCC activation, not per service. A notification config adds service-org-<ORG_ID>@gcp-sa-scc-notification.iam.gserviceaccount.com with roles/securitycenter.notificationServiceAgent on the Pub/Sub topic, not at the organization; creating one succeeded with the §1.1 lock enforced and that agent not in the list. Not measured: Security Health Analytics (it failed a precondition on that organization) and the AWS/Azure connectors.

Flags, failure modes and the rest: docs/housekeeping.md, under "The scripts, one by one".

Use, then adopt what the organisation already has (satz adopt --activate activates managed constraints via the Org Policy API and imports existing policies into state — see "Adopting what already exists" in the main README):

use "presets/cis/CIS-GCP-Foundation-4.0.satz" when use_cis_baseline
satz adopt C0example.satz --only google_org_policy_policy --activate --execute --import

security-audit/sa-security-audit.satz

Read-only security-audit service account + impersonation group + org-level IAM in one pack (Security Toolset §6.6). Access is impersonation-only: no SA keys, no remediation rights (roles/viewer, iam.securityReviewer, securitycenter.adminViewer, cloudasset.viewer; auditors group gets serviceAccountTokenCreator). Multi-resource-type.

Use (root level):

use "presets/security-audit/sa-security-audit.satz"

Overridable defaults:

Param Default Meaning
security_audit_sa_project "" effectively required — project hosting the SA
security_audit_sa_name "sa-security-audit" SA account_id
security_audit_sa_display_name "Security Audit (read-only)"
security_audit_auditors_group "grp-security-auditors" impersonation group

Notes: enable iamcredentials.googleapis.com in the SA's project manually after apply — impersonation fails without it.

Questions. The hosting project (no default — it blocks), the account id and the auditors group are asked; each is a recreate. The display name is not asked.

integrations/microsoft-defender-for-cloud*.satz

Microsoft Defender for Cloud's GCP onboarding: workload identity federation, so no service-account keys leave the estate. Four files — the foundation plus one fragment per licensed plan and per access mode — because a fragment cannot add to another fragment's project (two definitions of one project is a fold conflict, and lists replace rather than concatenate). The foundation owns the management project and its complete API set; each plan declares its own resources at top level, naming the project through a param.

Use (root level):

use "presets/integrations/microsoft-defender-for-cloud.satz"
use "presets/integrations/microsoft-defender-for-cloud-cspm.satz" when mdc_plan_cspm
use "presets/integrations/microsoft-defender-for-cloud-cspm-role-default.satz" when mdc_cspm_default_access
use "presets/integrations/microsoft-defender-for-cloud-cspm-role-least-privilege.satz" when mdc_cspm_least_privilege

Params: mdc_workload_pool_id (the customer's Entra tenant id without dashes — that is what Microsoft's wizard uses as the pool id), mdc_mgmt_project_id, mdc_mgmt_project_folder (the folder that project is created in — a folder the estate declares by reference, google_folder.<label>.name, or one that already exists by its id; empty says nothing and the project is created under the organisation), mdc_plan_cspm, and the access-mode pair mdc_cspm_default_access / mdc_cspm_least_privilege. Everything Microsoft-side — their tenant as the OIDC issuer, the per-plan api:// audiences, the provider ids, the custom role ids, the API list — is an inlined constant, identical for every customer and not a param.

No claim. Defender for Cloud is an external CSPM that reads the estate. It implements no CIS control and contributes to none, so the pack asserts nothing.

One prerequisite before the first apply. A deny-all on iam.workloadIdentityPoolProviders blocks the providers: the estate must allow the sts.windows.net/<microsoft tenant> issuer or document the exception. The pack adds nothing to allowed_policy_member_subjects, because the service accounts it grants roles to are created in the management project. The agentless-scanning plan grants roles to serviceAccount:mdc-agentless-scanning@guardians-prod-diskscanning.iam.gserviceaccount.com, which lives in a Microsoft-owned project; an estate that onboards that plan outside the library adds the account to allowed_policy_member_subjects itself, or the managed §1.1 constraint refuses the grant.

Coverage. The pack ships two plans: auto-provisioner (always created, in the foundation) and CSPM. The other plans Microsoft's generator writes — ciem-discovery, containers, containers-streams, data-security-posture-storage, defender-for-databases-arc-ap, defender-for-servers and agentless scanning — are not in the library. Each has its own api:// audience, service account and role set, and these are Microsoft's constants, not the customer's: public copies of the generated script for different tenants carry the same values for every plan they both contain. The plan set and a plan's accounts differ between versions of the generator, so a plan is transcribed from a current generated script.

Questions. mdc_workload_pool_id and mdc_mgmt_project_id block until typed — only Microsoft's generated script knows them; mdc_plan_cspm asks whether the plan is licensed; and the access mode is a question oneof with ask_when = mdc_plan_cspm, so it is asked only once CSPM is on.

integrations/microsoft-sentinel*.satz

Microsoft Sentinel's GCP connector: Sentinel PULLS logs from a Pub/Sub subscription, authenticating through workload identity federation, so no service-account key leaves the organisation. Two files, because federation is set up once and log sources are added one at a time.

Use (root level):

use "presets/integrations/microsoft-sentinel.satz"
use "presets/integrations/microsoft-sentinel-auditlogs.satz" when use_sentinel_auditlogs

microsoft-sentinel.satz is the federation half: the two APIs, a pool named after the customer's Entra tenant, the sentinel-identity-provider that trusts Microsoft's commercial tenant as issuer with api://<Sentinel application id> as the audience, the sentinel-service-account, and roles/iam.workloadIdentityUser for the pool's whole principal set on that account. It grants nothing else — each log source grants what it needs, where it needs it.

microsoft-sentinel-auditlogs.satz is the first log source: an organisation sink with include_children exporting the four audit streams, the topic it writes to, the subscription Sentinel pulls from, roles/pubsub.publisher for the sink's own writer identity (without which the sink exists and delivers nothing), and roles/pubsub.subscriber for the Sentinel account on that one subscription. Microsoft's published configuration grants a project-level custom role carrying pubsub.subscriptions.consume and .get instead, which reaches every subscription in the project; this is the same access confined to the one that exists for it.

Params: sentinel_workload_pool_id (the Entra tenant id without dashes) and sentinel_project_number block until typed — the project number cannot be derived from the id and the principal set is built from it. sentinel_project_id defaults to the audit archive's project by reference. sentinel_auditlogs_filter is asked: Data Access logs are most of the volume and Sentinel charges by the gigabyte ingested.

Transcribed, not imported. The shape is Microsoft's own Terraform in Azure/Azure-Sentinel (DataConnectors/GCP/Terraform/sentinel_resources_creation/), which pins the Google provider at 3.73.0 and uses authoritative google_project_iam_binding — run beside an estate it removes grants the estate made. Everything here is non-authoritative _iam_member against the pinned provider.

microsoft-sentinel-network-logs.satz is the four network streams — VPC flow logs, firewall rules logging, DNS queries and Cloud NAT — each with its own sink, topic, subscription and pair of grants. On by default with Sentinel, because every one of them is empty until somebody enables that feature on a subnet, a rule, a DNS policy or a NAT gateway, and Google charges nothing to route log entries: a sink for a feature nobody enabled costs nothing, while switching them off costs the day somebody enables flow logs and finds Sentinel was not watching. The volume once a feature is on lands on Sentinel's ingestion bill; an estate that wants one stream out suppresses that sink, topic and subscription.

Each filter selects exactly one stream — log_id where Google publishes the log name (flow logs, firewall, NAT) and the documented dns_query resource type for DNS, whose log name Google does not publish. Microsoft's own per-source configurations mix each stream with the audit records of the same service, which already leave through the audit fragment: carrying them again exports and bills the same entries twice.

The nine sources this library does not ship. Of Microsoft's fourteen configurations, nine — Apigee, Cloud SQL, Compute, IAM, Resource Manager, and the audit halves of CDN, NAT, DNS and Cloud IDS — are only protoPayload.serviceName=… filters over the same audit stream the audit fragment exports organisation-wide. As separate sinks they duplicate entries and pay for them twice. Service-scoped audit routing, if a customer wants it, is ONE sink with a union filter. Three more are unbounded and stay a decision rather than a default: Microsoft's audit setup carries NO filter at all (the entire log estate), its GKE filter matches .*stdout$/.*stderr$ with no resource-type guard (every application log in scope, not only GKE's), and its Cloud Run filter matches cloud_run_revision (the same for Cloud Run).

Why the rest cannot be transcribed as published. Every upstream setup grants publisher with authoritative google_project_iam_binding: the second source applied REMOVES the first sink's writer identity from the role, and delivery stops without an error. Its firewall setup names its topic sentinel-topic — the same name its audit setup creates — and its IAM setup subscribes to that topic without creating it. Its NAT filter's logName= is unquoted, so that half of the filter matches nothing.

Two organisation policies can refuse the first apply, neither of them set by this library: a deny-all on iam.workloadIdentityPoolProviders blocks the provider unless the sts.windows.net/<microsoft tenant> issuer is allowed, and where iam.managed.allowedPolicyMembers is enforced the principal set must be allowed before the binding is applied.

Running Defender too? They share nothing — separate pools, accounts and topics. If both are pointed at one project, give them different pool ids: a pool id is unique per project.

No claim. Exporting logs to a SIEM does not satisfy a retention control — the audit archive pack claims those — and Sentinel is in no catalog.

Onboarding is two-sided. This is the Google half; the Sentinel connector in Azure is configured with the pool, provider and service account it creates, and nothing flows until both sides are done.

Continuous verification

ci/ — continuous verification

Two packs that run satz's checks in Cloud Build, on every push and nightly: ci/verification-runner.satz (a Cloud Build trigger satz-check on every push running transpile --check, a nightly satz-compliance via Cloud Scheduler running report-compliance --fail-on, the runner service account and its two project roles) and ci/verification-runner-grant.satz (one binding on the ESTATE's IaC service account so the runner may become it). Split along ownership: customer-hosted uses both in one estate and wires nothing; MSP-hosted puts the runner in the MSP's estate and the grant in the customer's. The build steps are inline in the trigger, not a file in the watched repository — whoever controls the build file controls what runs as the runner. Params and the use blocks: docs/verification-runner.md, docs/verification-runner-grant.md; the workflow and the hosted shape: docs/workflows.md; the reasoning: ADR 0004.

Questions. The runner asks where it lives (ci_runner_project), whose project it reads as (ci_target_infra_project), which repository and which file it watches (ci_repo_name, ci_estate_file); the grant asks which runner account may become the estate's (ci_runner_service_account). All five default to the customer-hosted shape, so an estate hosting its own runner accepts them; an MSP answers the project and the account. Schedule, time zone, catalog and release are technical defaults, unasked.

Writing a pack

Questions

A pack declares its params, its claims — and what a human must be asked before those params can be filled:

question customer_shortname {
  prompt   = "Short name identifying this customer"
  why      = "Project ids and bucket names derive from it, and those ids are globally unique."
  reversal = recreate      // edit | state_surgery | recreate — cost to the ESTATE
  blast    = none          // none | low | high — cost to the RUNNING organisation
}

The two costs are independent: enforcing OS Login is one boolean to reverse and cuts every existing SSH path. why is required wherever satz will refuse or warn, so it can quote the pack's own sentence. An exclusive choice is question oneof, whose options name existing boolean params — so an answer set stays a plain param map and satz can refuse two true branches by name.

A question must live in the file that declares its param: questions are absorbed after the use … when guard, so a question gating a pack cannot live in the gated pack.

A question is answered when the estate's own params {} binds its param — to the pack's default or to anything else; accepting a default is an answer, recorded by writing the default in. A question whose param the estate does not bind is unanswered, and every question must be answered before the estate touches an organisation: bootstrap and transpile --apply refuse while one is open, transpile --plan warns. A question with no usable default is blocking — a value has to be typed. ask_when names a boolean param; when it is false the question is not-applicable and counts toward nothing.

satz questions <estate> lists them with their state (--unanswered for the open ones, --format markdown for the decisions sheet a customer signs off); satz interview asks them and writes the answers; satz doc-packs gives each pack a Questions section; and the prompt becomes the generated variables.tf description. check-presets reports a pack whose questions changed as questions, not as drift: its HCL is identical, so the estate is not forked, and the change — a recreate → edit downgrade included — is still listed.

Notices

A step outside the estate — a console switch, a request to a vendor, an adoption run — is a notice: the estate is reminded until its operator acknowledges it. A pack can name ONE command to run once it is switched on, beside its questions:

params {
  cis_baseline_adopted = false
}

notice cis_baseline_adopted {
  text     = "Google sets some of these policies on every new organisation …"
  run      = "satz adopt <estate> --execute --import"
  severity = error
}

satz shows it when the pack goes on — the interview's yes, satz add-pack, merge-presets bringing the pack in — and the compile warns at the estate's use line until the estate binds the param true. severity = error makes every command that writes to the organisation refuse while it is open (§6.15 of the language reference lists the three severities). The param is the notice's alone: declared false in the same pack, asked by no question, read by nothing, and never emitted — binding it moves no line of the HCL. satz packs lists every pack's notices with their state, and satz doc-packs gives a pack a Notices section.

The CIS org-policy packs carry one each, naming satz adopt; a run over every resource type that resolves everything binds their params itself.

A big resource is a pack

A resource with a long literal (a custom role with 1,400 permissions, an allowlist shared by several resources) goes into its own pack — the whole resource, not just the value:

// presets/roles/application-owner-connected.satz
pack roles.application_owner_connected version "1.0"
params { application_owner_connected_role_id = "ApplicationOwnerConnected" }
google_organization_iam_custom_role {
  ApplicationOwnerConnected {
    role_id = application_owner_connected_role_id
    title = "ApplicationOwnerConnected"
    permissions = [ "accessapproval.requests.get", … ]
  }
}

and the estate says use "presets/roles/application-owner-connected.satz". The resource gains a name, a version and a ledger entry, merge-presets can track it, and any estate can share it. A params-only pack (permissions = <param>) is the shape when the LIST itself is the shared thing. Satz has no value-position include: use is a language construct (params, provenance, claims), not a preprocessor splice.

Data beside the packs

catalogs/

Compliance catalogs (cis-gcp-4.0.yaml, cis-gcp-5.0.yaml): control ids with this project's own paraphrases, read by require and report-compliance. YAML data, not packs.

iso27001-2022.yaml is a cross-walk, not a second benchmark. ISO 27001 is a management-system standard: Annex A names no cloud resource, so a control that the estate can evidence points at the CIS controls that stand as its evidence (evidence: { "cis-gcp/4.0": ["3.1", …] }) and require folds their verdicts. Packs claim CIS only, so there is one set of witnesses. Two fields exist for it: evidence, and duties named on the CONTROL (the human half a config cannot discharge, which caps the verdict at partial). automatability: inherited marks the provider's own controls under shared responsibility — all of Annex A 7.x — reported so the Statement of Applicability is complete, never counted as a gap.

The ISO view follows the CIS coverage beneath it: an estate claiming few CIS controls shows few ISO controls satisfied.

import-config.yaml

Not a pack: the configuration satz import reads — an optional root (organization, folder by id or display-name path, project), only and exclude lists, and per resource type the import filter (import, asset_type, attribute include/exclude) plus the adoption rules satz adopt reads — import_id templates for user-chosen ids, match_on keys for GCP-assigned ones, activate: managed for org policies. A type without a rule is reported by adopt as "no rule"; adding one is a one-line change here. Referenced automatically from presets_dir, or explicitly via --import-config.

Its rows are every resource type of the google and google-beta providers at provider_version, the pinned version; import: true marks the default set, and satz import --all takes every row the source can deliver.

A row's skip: is the live shape's list of what the platform owns and an estate never declares: glob patterns (*) over the resource's own name — a sink's _Default, a service account's email — or, on an IAM row, over the member (serviceAccount:service-*@gcp-sa-*.iam.gserviceaccount.com, deleted:*). A match is skipped and listed under its pattern, never dropped in silence; a copy of the table without the pattern imports it. A key the row does not know is refused at load, so a misspelt field never passes as an empty one.

cai-asset-types.txt beside it is Google's published list of Cloud Asset Inventory resource types (dated in its header); scripts/update_import_config.py --cai-types fills asset_type from it — a derived name is kept only when it is in the list.

type-map.yaml beside it is generated by satz map-types (never edited by hand): per resource type the API→Terraform field map the live import applies, aligned from the API's Discovery Document and the provider schema. Its rows join the map: rows of import-config.yaml, which win where both name a field, so a correction made in the table survives a refresh. Overrides go into import-config.yaml (api_schema: to pin an ambiguous schema name). The file is what carries a renamed BLOCK (lifecycle.rule[] → lifecycle_rule); a nested value whose name is an attribute of the resource the import flattens by itself, with or without the file.