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, 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,
google) 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 or s2-security-groups |
use_audit_logsink |
on | monitoring, in the infrastructure folder |
use_central_alerts |
on | monitoring, 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 — 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 |
use_defender |
off | integrations — its plan fragments by hand |
use_sentinel |
off | integrations — the federation half |
use_sentinel_auditlogs |
follows use_sentinel |
integrations, asked only when Sentinel is on |
use_sentinel_network_logs |
follows use_sentinel |
integrations — flow logs, firewall, DNS, NAT: free until the feature is enabled |
use_verification_runner |
off | ci/verification-runner, the customer-hosted shape |
use |
follows use_verification_runner |
ci — 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 |
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 |
organizations/<id>, known at compile time |
| a name | google_folder { workload_folder { display_name = workload_folder_name } } and export "workload_folder" = "${{google |
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, 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:
- s1-security-groups.satz — S1 in ONE typed file (groups AND grants).
Resource-type sections may repeat across files with distinct ids, so the
pack's
google_cloud_identity_group { … }sits beside the estate's own. - s2-security-groups.satz — S2: S1 plus a distinct
gcp-network-adminsgroup. The network authority moves out of project-admins (which losecompute.networkAdminandcompute.xpnAdmin) into a team that owns VPCs, Shared VPC, firewall policies, Cloud DNS, hybrid connectivity and network diagnostics —compute.networkAdmin,compute.xpnAdmin,compute.securityAdmin,dns.admin,networkconnectivity,.hubAdmin networkmanagement.admin, plus viewer roles; no owner, no IAM admin. One file, since groups and their grants belong together.
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
— 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, 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 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 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, …KeyUpload, iam |
public-endpoint |
a workload reachable from the internet on its own address | compute, sql, sql |
public-storage |
an object store published on purpose, and the access model for it | storage, storage |
vm-image |
an image or machine family that cannot boot shielded or confidential | compute.requireShieldedVm, compute |
vm-access |
an instance reached by metadata SSH keys or the serial console | compute, compute, compute |
data-residency |
a resource created outside the permitted locations | gcp.resourceLocations |
encryption |
a resource that may use Google-managed keys | gcp.restrictNonCmekServices, gcp |
network-appliance |
an instance that forwards traffic for others | compute, compute |
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,
iam), VPC flow logs, DNS query logging and
domain-restricted sharing (iam). 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), which is the route there.
Who may exempt, and where: two grants, both required
| grant | on what | decides |
|---|---|---|
roles |
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 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 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
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>);
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 — the s1 model's group);
the IaC service account keeps billing.admin. Declares its own
google map, pinned to billing_account_infra.
Use (root level): use "presets
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
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.
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 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 for a
declared project), folders (folders/<id> or organizations/<id>; a declared folder's
"${{google), 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
(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, 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
Overridable defaults:
| Param | Default | Meaning |
|---|---|---|
billing_export_project_id |
{customer |
the export's own project |
billing |
"" |
the folder it is created in; empty is the organisation |
billing |
the project id | what the console shows |
billing_export_dataset_id |
{customer |
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 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 |
own org (//cloudresourcemanager) |
§1.1: principal sets whose members may be granted IAM roles |
allowed |
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 |
["@{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, allowed. 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, 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 names it. To allow another agent, add it to
the param — keeping the five SCC agents — and apply. The legacy
iam 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 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, 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 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 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, generated from a live organisation by
scripts 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 ↔
iam — 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 — 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. 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 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); 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 (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 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) 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 |
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 |
GCS archive bucket |
logsink_bucket_location |
default_region |
bucket region |
logsink_retention_days |
400 |
lifecycle delete age |
logsink_name |
"{customer |
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 |
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 |
Cloud Logging bucket id |
cis_central_bucket_location |
default_region |
bucket location |
cis |
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 |
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). Eight metrics therefore cover
the whole organization.
Why a second sink is needed — "bucket" means two different products. A GCS bucket
(storage) 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) 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:
- CIS 2.3/2.4 —
cloudstorageonly inspects sinks whose destination contains_bucket _log _retention _policy _lock storage.googleapis.com, then testsretention_policy.is_lockedon that GCS bucket. A Logging-bucket sink is never examined. (Logging buckets can be locked too — they have alockedfield; GCS is required by the check's wording, not by a missing capability.) - CIS 2.5–2.12 — log-based metrics require a Logging bucket.
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 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, 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
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 |
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 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 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: 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 |
— |
shared_subnets |
every subnet of the host project, by label (- written _: payments-app is the key payments_app) |
google: a project grants roles/compute.networkUser on its own subnet |
Overridable defaults:
| Param | Default | Meaning |
|---|---|---|
network_host_project_id |
"{customer |
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
— are refused at compile and by satz check-request, naming the entry and the value.
Per entry payments:
| resource | what |
|---|---|
google_project.payments |
{customer under project_onboarding_folder, billed to billing_account_infra, with the Cloud Resource Manager, IAM, IAM Credentials, Service Usage and Cloud Storage APIs |
google |
svc-iac-payments, the IaC service account the project's estate runs as; it holds roles and roles on the project, so the project's estate grants itself the rest (satz update-prerequisites there) |
google |
{customer 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 and roles 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
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 |
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 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 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 |
svc-deploy-platform, the account the team's pipelines impersonate |
google |
{customer 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 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 |
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;
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), a Pub/Sub topic
({customer), 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 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). 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:
- Stub: the pack declares the shared resource and exports it as an attach point
(
export … attach [ … ]); it never writes the membership list itself, and projects add their own members in their own state. The shared-VPC host (projects attach service Google projects), a Network Connectivity Center hub (projects attach spokes), a perimeter with per-member resources (projects attach Google projects; the pack writeslifecycle { ignore_changes = [status[0].resources] }on it). - Central-only: the pack keeps the resource whole and takes the projects' entries as a
list param, which a project's contribution fills (
contributes_<param>, ADR 0051) and arequestpoint beside the list declares — what a project may add, checked bysatz check-request; the change is a pull request on the estate, reviewed like a rule change in a global firewall or a route in a network hub. Global firewall policy rules (the provider could attach them one by one asgoogle, and security keeps them central), hub routes, DNS forwarding._compute _firewall _policy _rule
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 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 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"
- a
google_pubsub_topicin the project the estate names (scc_notification_project, asked; the infrastructure project by default); rolesfor/securitycenter .notificationServiceAgent service-org-<organisation>@gcp-sa-scc-notificationon that topic — the identity the config reports in its own.iam .gserviceaccount .com serviceAccountfield, which is not thesecurity-center-apiagent SCC activation creates. Without the grant the config is created, reports no error, and publishes nothing. SCC adds the binding itself when the config is created; the estate declares it anyway, and the two converge.iamdoes not apply to Google-managed service agents, so a domain-restricted organisation permits it;.managed .allowedPolicyMembers googleat_scc _v2 _organization _notification _config location = "global", with the filter the customer decides (scc_notification_filter, asked; active HIGH and CRITICAL findings by default). The resource is v2: the v1 notification API answers "This API is no longer available" on a live organisation.
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/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 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 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 with
roles 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 |
"Security Audit (read-only)" |
|
security |
"grp-security-auditors" |
impersonation group |
Notes: enable iamcredentials 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 blocks the providers: the estate must allow the
sts.windows.net/<microsoft tenant> issuer or document the exception. The pack adds
nothing to allowed, because the service accounts it grants roles
to are created in the management project. The agentless-scanning plan grants roles to
service,
which lives in a Microsoft-owned project; an estate that onboards that plan outside the
library adds the account to allowed 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 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 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 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),
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 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 blocks the provider unless
the sts.windows.net/<microsoft tenant> issuer is allowed, and where
iam 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 (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.
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 (service,
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
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.