Everything AlpinaShop has built over seven modules works because three people remember. Marta remembers not to create VMs with public IPs. Dani remembers to label resources. Lucía remembers not to export data to an external bucket. The SLOs live on a dashboard somebody maintains, the runbooks get updated because somebody reviews them, and the error budget policy is respected because the team agreed it one afternoon.

None of that is wrong. All of it is fragile.

Because today, technically, nothing stops somebody creating a machine with a public IP in Iowa tomorrow, disabling an audit log, sharing a bucket with allUsers, or downloading a service account JSON key. The good practices in this course are agreements, not controls. And an agreement breaks the day a new person joins, the day somebody is in a hurry, or the day the company goes from three technical people to eight.

Governance is exactly that: turning agreements into controls that enforce themselves, and having a reliable record of everything that happens. It is the difference between "we trust nobody will do it" and "it cannot be done".

And there is a misunderstanding worth clearing up from the first line: governance is not something you put in when you are big. You put it in earlier, because retrofitting policies onto an organization with fifty projects and a thousand resources is a months-long job, while applying them to five projects is an afternoon. AlpinaShop is late, but not too late. That is the only reason this lesson is still feasible in a day's work.

By the end you will know how to structure an organization and understand the consequences of each criterion, apply organization policies that are inherited and really do block, inventory everything that exists and monitor changes in real time, enable and read the audit logs that matter, set up immutable retention in a project not even the administrators can write to, and deploy all of that in one go as a landing zone with Terraform.

And you will close module 7 and, with it, AlpinaShop's complete journey.

Contents

  1. What cloud governance is and why you need it sooner than you think
  2. The organization structure: criteria and consequences
  3. Organization policies: what they are and how they are inherited
  4. The policies AlpinaShop applies, one by one
  5. Dry-run mode and the danger of blocking the team
  6. Custom constraints and their relationship with Policy Controller
  7. Quotas and limits at the organization level
  8. Cloud Asset Inventory: knowing what exists
  9. Feeds: monitoring changes in real time
  10. Cloud Audit Logs properly
  11. The audit queries to have ready
  12. Immutable retention in a separate project
  13. Landing zones and the order that matters
  14. Change management: reviews, lifecycle and exceptions
  15. AlpinaShop's maturity, and what is left

  1. What cloud governance is and why you need it sooner than you think

Governance is the set of structures, policies and processes that ensure the use of the cloud is consistent with what the organization wants. It covers four questions:

Question Mechanism
What can be created, where and how? Organization policies
What exists right now? Cloud Asset Inventory
Who did what and when? Cloud Audit Logs
Who pays for what? Project structure and labels

And the reason you need it before "being big", which is purely arithmetic:

Moment Projects Resources Cost of applying policies
Now (AlpinaShop) 5 ~120 One day
In two years 15 ~600 A week, with negotiations
In five years 60 ~5,000 Months, and it probably will not get done

The cost does not grow with the number of resources: it grows with the number of exceptions you have to negotiate. Applying the policy "no creating VMs with public IPs" today affects zero existing resources. Applying it in five years' time means finding the forty VMs that breach it, working out why, talking to six teams and accepting twenty permanent exceptions that hollow the policy out.

The rule: policies go in when they bother nobody. After that they never go in at all.

  1. The organization structure: criteria and consequences

The hierarchy from 01-04, revisited now with the full context:

flowchart TB
    ORG["Organization<br/>alpinashop.example"]
    ORG --> F1["Folder produccion"]
    ORG --> F2["Folder desarrollo"]
    ORG --> F3["Folder compartido"]
    ORG --> F4["Folder seguridad"]

    F1 --> P1["alpinashop-prod"]
    F2 --> P2["alpinashop-dev"]
    F3 --> P3["alpinashop-datos"]
    F3 --> P4["alpinashop-cicd"]
    F3 --> P5["alpinashop-red"]
    F4 --> P6["alpinashop-auditoria"]

    style F4 fill:#e6f4ea,stroke:#34a853
    style P6 fill:#e6f4ea,stroke:#34a853

Two changes from 01-04: the alpinashop-red project from 07-03 lives in compartido, and a new folder appears, seguridad, with alpinashop-auditoria — the immutable log project from section 12. It is separated on purpose: it is the only one not even Marta can write to.

The three criteria for structuring

You rarely use only one; the usual thing is to combine two levels.

Criterion Structure Advantage Drawback
By environment produccion / desarrollo / pruebas Very different policies per environment; clean separation of permissions A team with several applications has them scattered
By team or unit tienda / analitica / corporativo Direct billing per team; autonomy Production and development mixed: the same policies for both
By application One folder per product Maximum isolation An explosion of folders; duplication

The consequences of the choice, which is what almost nobody thinks about before deciding:

Aspect Effect of the structure
Permissions They are inherited downwards. A role on the produccion folder applies to all its projects
Organization policies Inherited the same way. That is why separating environments allows harder policies in production
Billing It is aggregated by folder and project. The structure is the cost breakdown
Quotas They belong to the project, not to the folder. One project per application isolates quota exhaustion
Blast radius The project is the deletion boundary. Deleting a project takes everything inside it

AlpinaShop's choice is by environment at the first level, and it is the right one for its size for a specific reason: the difference in strictness between production and development is far greater than the difference between teams. Nobody is owner of production, but in development you can be editor without drama. With a team-based structure, that distinction would be impossible to express.

When AlpinaShop has four products, the natural evolution is a second level: produccion/tienda, produccion/alpinapro, desarrollo/tiendaThe folder structure can be changed; moving projects between folders is one command. What cannot be changed is the projectId.

  1. Organization policies: what they are and how they are inherited

The Organization Policy Service is what turns agreements into controls.

IAM Organization policies
Answers Who can do what? What can be done, regardless of who?
Subject Identities Resources and configurations
Example "Marta can create VMs" "Nobody can create a VM with a public IP"
Bypassed with A higher role Nothing. Not even an organization owner

The last row is the essence: an organization policy is not a permission. Even if you have roles/owner on the project, if the policy forbids public IPs, you cannot create one. Only somebody with roles/orgpolicy.policyAdmin at the level where it is defined can lift it.

Types of constraint

Type What it does Example
Boolean Enables or disables a behaviour compute.requireOsLogin, storage.uniformBucketLevelAccess
List Allows or denies specific values gcp.resourceLocations with the list of regions
Custom Your own CEL over the resource's attributes "VMs must be of type e2 or n2"

Inheritance, which is where the subtlety lives

Policies are inherited from organization → folder → project, and at each level you can:

Action Effect
Inherit (default) The parent's applies
Merge (inheritFromParent: true) The parent's plus your own
Replace (inheritFromParent: false) Only your own; the parent is ignored
Restore the default value Removes the policy at that level
flowchart TB
    O["Organization<br/>regions: europe-west1, europe-southwest1"] --> F1["Folder produccion<br/>inherits"]
    O --> F2["Folder desarrollo<br/>inherits + adds europe-west4"]
    F1 --> P1["alpinashop-prod<br/>only europe-west1, europe-southwest1"]
    F2 --> P2["alpinashop-dev<br/>3 regions"]

    style F1 fill:#e6f4ea,stroke:#34a853
    style F2 fill:#fef7e0,stroke:#fbbc04

And the rule that surprises everybody: for list constraints, a deny at any level always wins. A child cannot allow what the parent denies. It is what makes the model safe: you cannot escape a policy by going downwards.

Practical consequence: policies are defined at the highest level where they make sense, and relaxed downwards only with additional allow entries on constraints the parent did not explicitly deny. If you find yourself needing to "un-deny" something, the policy was placed wrongly.

  1. The policies AlpinaShop applies, one by one

These are the nine, with their reason, their level and their risk.

4.1 Forbid public IPs on VMs

gcloud resource-manager org-policies enable-enforce \
  constraints/compute.vmExternalIpAccess \
  --organization=ORG_ID

With the exception declared, if some legitimate VM ever needed it:

# politica-ip-externa.yaml
constraint: constraints/compute.vmExternalIpAccess
listPolicy:
  deniedValues:
    - "under:organizations/ORG_ID"
  # Explicit, documented exception, if one were needed:
  # allowedValues:
  #   - "projects/alpinashop-dev/zones/europe-west1-b/instances/bastion-pruebas"

Reason: at AlpinaShop there is no VM left in the serving path (07-02), and the development ones go out through Cloud NAT. A public IP is attack surface directly from the internet. Level: organization. Risk of blocking: low.

4.2 Restrict the regions to the EU

constraint: constraints/gcp.resourceLocations
listPolicy:
  allowedValues:
    - in:europe-west1-locations
    - in:europe-southwest1-locations
    - in:eu-locations            # EU multi-region: BigQuery, buckets

Reason: data residency in the EU (07-01, 07-04) and control of intercontinental traffic costs (07-05). Level: organization. Risk: medium-high, and it is the policy that causes the most grief. Many services have global or multi-region components, and in:eu-locations is essential for multi-region BigQuery and some buckets to keep working. This one gets tested in dry-run, no arguments.

4.3 Disable service account key creation

gcloud resource-manager org-policies enable-enforce \
  constraints/iam.disableServiceAccountKeyCreation \
  --organization=ORG_ID

Reason: it is debt #1 from 07-04. With identity federation and impersonation already in use, there is no legitimate case that needs a downloadable key. Level: organization. Risk: medium. It can break old integrations — which is why the inventory in section 8 comes first and the policy afterwards.

And its companion, which stops an existing key being valid forever:

constraint: constraints/iam.serviceAccountKeyExpiryHours
listPolicy:
  allowedValues:
    - "24h"     # if one is exceptionally allowed, it expires in 24 hours

4.4 Require OS Login

gcloud resource-manager org-policies enable-enforce \
  constraints/compute.requireOsLogin \
  --organization=ORG_ID

Reason: OS Login ties SSH access to the Google identity instead of to loose SSH keys in the metadata. That means offboarding a person from the directory removes their access to every machine, which is exactly what does not happen with keys in metadata. Level: organization. Risk: low (there are hardly any VMs left).

4.5 Forbid public buckets

gcloud resource-manager org-policies enable-enforce \
  constraints/storage.publicAccessPrevention \
  --organization=ORG_ID

Reason: a public bucket is the commonest data leak in the cloud, across every provider. Level: organization. Risk: low… with a trap to be resolved beforehand: the alpinashop-catalogo bucket serves images to the shop. If those images are served through the load balancer with bb-catalogo-imagenes (03-02), the bucket does not need to be public and the policy breaks nothing. If somebody were serving them by a direct Cloud Storage URL, it would. Checking is part of the preparatory work.

4.6 Uniform bucket-level access

gcloud resource-manager org-policies enable-enforce \
  constraints/storage.uniformBucketLevelAccess \
  --organization=ORG_ID

Reason: it disables per-object ACLs, which are a permission system running in parallel to IAM, invisible in audits and responsible for countless leaks. With uniform access, IAM is the only truth.

4.7 Require CMEK and limit the key projects

# Require a customer-managed key in the services that support it
constraint: constraints/gcp.restrictNonCmekServices
listPolicy:
  deniedValues:
    - bigquery.googleapis.com
    - sqladmin.googleapis.com
    - storage.googleapis.com
# And the key comes ONLY from our keyring
constraint: constraints/gcp.restrictCmekCryptoKeyProjects
listPolicy:
  allowedValues:
    - projects/alpinashop-prod      # where alpinashop-keyring lives

Reason: it closes off the control from 03-06. The second policy is the one almost nobody puts in and it prevents somebody encrypting data with a key from a project you do not control. Level: the produccion folder. Risk: high. Requiring CMEK breaks resource creation if the key does not have the right permissions granted to the corresponding service agent. dry-run mandatory.

4.8 Prevent automatic grants to the default accounts

gcloud resource-manager org-policies enable-enforce \
  constraints/iam.automaticIamGrantsForDefaultServiceAccounts \
  --organization=ORG_ID

Reason: it is debt #2 from 07-04. It stops new projects being born with the default Compute account holding the Editor role. It does not fix existing projects, which have to be corrected by hand.

4.9 Restrict sharing by domain

constraint: constraints/iam.allowedPolicyMemberDomains
listPolicy:
  allowedValues:
    - "C03xxxxxx"        # Cloud Identity customer ID of alpinashop.example

Reason: it prevents granting permissions to Google accounts outside the organization. It is the policy that avoids the mistake of giving [email protected] access "temporarily". Risk: high. It breaks any legitimate collaboration with outsiders, and it breaks some Google service agents. It requires careful exceptions and a long dry-run.

Summary

# Constraint Level Risk Debt it closes
1 compute.vmExternalIpAccess Organization Low
2 gcp.resourceLocations Organization Medium-high EU residency
3 iam.disableServiceAccountKeyCreation Organization Medium 07-04 #1
4 compute.requireOsLogin Organization Low
5 storage.publicAccessPrevention Organization Low
6 storage.uniformBucketLevelAccess Organization Low
7 gcp.restrictNonCmekServices + key projects produccion folder High Closes 03-06
8 iam.automaticIamGrants... Organization Low 07-04 #2
9 iam.allowedPolicyMemberDomains Organization High

  1. Dry-run mode and the danger of blocking the team

Organization policies support dry-run mode, just like Cloud Armor (03-05), Policy Controller (07-01) and VPC Service Controls (07-03). It is the same pattern for the fourth time in the course, and that is no coincidence: every control that denies must be measured before it is enforced.

# Apply in dry-run mode ONLY
gcloud org-policies set-policy politica-regiones.yaml --update-mask=dryRunSpec
# politica-regiones.yaml
name: organizations/ORG_ID/policies/gcp.resourceLocations
dryRunSpec:
  rules:
    - values:
        allowedValues:
          - in:europe-west1-locations
          - in:europe-southwest1-locations
          - in:eu-locations

And the query for what it would have blocked:

SELECT
  timestamp,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.methodName                        AS operation,
  protopayload_auditlog.resourceName                      AS resource_name,
  protopayload_auditlog.metadata.dryRun                   AS in_dry_run,
  protopayload_auditlog.metadata.constraint               AS constraint_name
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_policy`
WHERE DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 14 DAY)
  AND protopayload_auditlog.metadata.dryRun = TRUE
ORDER BY timestamp DESC

The safe procedure, and the order matters

Step What Duration
1 Inventory what already breaches it (section 8) 1 h
2 Fix or document every breach Variable
3 Apply in dryRunSpec 10 min
4 Wait 14 days, covering weekly and monthly processes 14 days
5 Review the violations and decide on exceptions 1 h
6 Enforce for real, starting with alpinashop-dev 10 min
7 A week in development with no incidents 7 days
8 Enforce in production 10 min

Step 1 is the one everybody skips and the one that avoids disaster. Applying dry-run to policies that are already being breached generates hundreds of violations nobody is going to analyse, and the practical result is that they all get ignored.

The five ways to lock yourself out

Real ones, all seen in production:

Mistake Consequence Prevention
allowedPolicyMemberDomains with no exceptions for Google's service agents Internal services stop working in inexplicable ways A long dry-run; a list of agents
resourceLocations without in:eu-locations Multi-region BigQuery and some buckets can no longer be created Always include the multi-regions you use
disableServiceAccountKeyCreation with old integrations still alive An external system stops authenticating with no warning Inventory the keys first
CMEK required without granting the service agent permissions on the key New resources cannot be created Grant cloudkms.cryptoKeyEncrypterDecrypter first
A policy enforced on a Friday A whole weekend unable to deploy Enforce on a Tuesday or Wednesday morning

And the safeguard to prepare before all of this: that at least two people have roles/orgpolicy.policyAdmin on the organization, with verified access. If the only person who can lift a policy locks themselves out, the way out is via Google support and a bad day.

  1. Custom constraints and their relationship with Policy Controller

When no predefined constraint fits, you write a custom constraint with CEL (Common Expression Language):

# restriccion-tipos-maquina.yaml
name: organizations/ORG_ID/customConstraints/custom.tiposMaquinaPermitidos
resourceTypes:
  - compute.googleapis.com/Instance
methodTypes:
  - CREATE
  - UPDATE
condition: "resource.machineType.contains('e2-') || resource.machineType.contains('n2-')"
actionType: ALLOW
displayName: "Only the e2 and n2 machine families"
description: "Avoids expensive or specialised families without justification (07-05)."
gcloud org-policies set-custom-constraint restriccion-tipos-maquina.yaml
gcloud resource-manager org-policies enable-enforce \
  custom.tiposMaquinaPermitidos --organization=ORG_ID

Another example, this time for the mandatory labels from 07-05:

name: organizations/ORG_ID/customConstraints/custom.etiquetasObligatorias
resourceTypes:
  - compute.googleapis.com/Instance
  - storage.googleapis.com/Bucket
methodTypes: [CREATE]
condition: "has(resource.labels['centro-coste']) && has(resource.labels['entorno'])"
actionType: ALLOW
displayName: "centro-coste and entorno labels mandatory"

Organization policies versus Policy Controller

Two systems that do similar things in different scopes. The confusion is common:

Organization policies Policy Controller (07-01)
Scope Google Cloud resources Kubernetes resources
Where it acts The Google Cloud API The cluster's admission webhook
Language CEL Rego (OPA/Gatekeeper)
Reach The whole organization The fleet's clusters
Example "No VM with a public IP" "No privileged pod"
Cost Included GKE base tier / GKE Enterprise

They do not compete: they complement each other. An organization policy cannot stop a pod running as root, and Policy Controller cannot stop a public bucket being created. An organization with Kubernetes needs both.

For AlpinaShop, which took the production workloads off GKE in 07-02, the organization policies are the ones that matter. Policy Controller remains as a control for the test cluster, which is consistent with what was decided in DA-004.

  1. Quotas and limits at the organization level

Quotas are managed per project, but they can be governed from above through policies and automation.

Lever What it does
Per-project quota adjustment Lowering the vCPU quota in development (07-05)
compute.quotaOverrides Restricting expensive machine families
BigQuery cost quotas Bytes per user per day
API rate quotas Containing runaway loops
# Lower the CPU quota in development: a HARD brake on spend
gcloud alpha services quota update \
  --service=compute.googleapis.com \
  --consumer=projects/alpinashop-dev \
  --metric=compute.googleapis.com/cpus \
  --unit=1/{project}/{region} \
  --dimensions=region=europe-west1 \
  --value=8

The essential distinction, already seen in 07-05 and put in its proper place here: budgets warn, quotas prevent. A governed organization needs both, and quotas are the only mechanism that does not depend on somebody reacting.

  1. Cloud Asset Inventory: knowing what exists

You cannot govern what you do not know exists. Cloud Asset Inventory is the catalogue of all the organization's resources, IAM policies and configurations, with their history.

gcloud services enable cloudasset.googleapis.com --project=alpinashop-auditoria

Searching for resources

# Everything that exists in the organization
gcloud asset search-all-resources --scope=organizations/ORG_ID \
  --format="table(name, assetType, location, project)"

# VMs with a public IP: the inventory PRIOR to policy 4.1
gcloud asset search-all-resources --scope=organizations/ORG_ID \
  --asset-types=compute.googleapis.com/Instance \
  --query="natIP:*" \
  --format="table(name, project, location)"

# Resources outside Europe: the inventory prior to policy 4.2
gcloud asset search-all-resources --scope=organizations/ORG_ID \
  --query="NOT location:europe AND NOT location:eu AND NOT location:global" \
  --format="table(name, assetType, location, project)"

# Resources WITHOUT a cost centre label (07-05)
gcloud asset search-all-resources --scope=organizations/ORG_ID \
  --query="NOT labels.centro-coste:*" \
  --format="table(name, assetType, project)"

Searching IAM policies

# Who has owner anywhere in the organization
gcloud asset search-all-iam-policies --scope=organizations/ORG_ID \
  --query="policy:roles/owner" \
  --format="table(resource, policy.bindings.members)"

# Anything granted to an external account
gcloud asset search-all-iam-policies --scope=organizations/ORG_ID \
  --query="policy:gmail.com" \
  --format="table(resource, policy.bindings.role, policy.bindings.members)"

These two queries, run before applying policies 4.1, 4.2 and 4.9, are exactly step 1 of the procedure in section 5. Without them, you are applying blind.

Exporting to BigQuery

gcloud asset export --organization=ORG_ID \
  --content-type=resource \
  --bigquery-table=projects/alpinashop-auditoria/datasets/inventario/tables/recursos \
  --output-bigquery-force

gcloud asset export --organization=ORG_ID \
  --content-type=iam-policy \
  --bigquery-table=projects/alpinashop-auditoria/datasets/inventario/tables/politicas_iam \
  --output-bigquery-force

With a scheduled daily export (Cloud Scheduler + Workflows, 04-06), you get something very valuable: the history of the inventory, which lets you answer "what was there on 3 June?" — a question that comes up in every investigation and every audit.

-- Resources created in the last 7 days, by type and project
SELECT
  asset_type,
  SPLIT(name, '/')[SAFE_OFFSET(4)] AS project_id,
  COUNT(*) AS created
FROM `alpinashop-auditoria.inventario.recursos`
WHERE DATE(update_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
GROUP BY asset_type, project_id
ORDER BY created DESC

  1. Feeds: monitoring changes in real time

The inventory tells you what is there. Feeds tell you when something changes, as it happens.

# 1. Topic the notifications will arrive on
gcloud pubsub topics create cambios-criticos --project=alpinashop-auditoria

# 2. Feed on IAM policy changes across the WHOLE organization
gcloud asset feeds create feed-iam \
  --organization=ORG_ID \
  --content-type=iam-policy \
  --asset-types="cloudresourcemanager.googleapis.com/Project,cloudresourcemanager.googleapis.com/Folder" \
  --pubsub-topic=projects/alpinashop-auditoria/topics/cambios-criticos

# 3. Feed on the creation or change of sensitive resources
gcloud asset feeds create feed-recursos-sensibles \
  --organization=ORG_ID \
  --content-type=resource \
  --asset-types="compute.googleapis.com/Firewall,storage.googleapis.com/Bucket,iam.googleapis.com/ServiceAccountKey" \
  --pubsub-topic=projects/alpinashop-auditoria/topics/cambios-criticos

And the function that decides what deserves an immediate warning (Cloud Functions 2nd gen, 06-03):

# main.py — evaluar-cambio-critico
import base64, json, logging

CRITICOS = [
    ("roles/owner",             "OWNER has been granted"),
    ("roles/editor",            "EDITOR has been granted"),
    ("allUsers",                "PUBLIC ACCESS granted"),
    ("allAuthenticatedUsers",   "SEMI-PUBLIC ACCESS granted"),
    ("roles/iam.securityAdmin", "SECURITY ADMIN has been granted"),
]

def evaluar_cambio(evento, contexto):
    datos = json.loads(base64.b64decode(evento["data"]).decode("utf-8"))
    activo = datos.get("asset", {})
    nombre = activo.get("name", "")
    tipo   = activo.get("assetType", "")

    # 1. Creation of a service account key: ALWAYS exceptional (07-04)
    if tipo == "iam.googleapis.com/ServiceAccountKey":
        alertar("CRITICAL", "Service account key created", nombre, datos)
        return

    # 2. Dangerous grants
    texto = json.dumps(activo.get("iamPolicy", {}))
    for patron, mensaje in CRITICOS:
        if patron in texto:
            alertar("CRITICAL", mensaje, nombre, datos)
            return

    # 3. Firewall rule open to the whole internet on an administration port
    if tipo == "compute.googleapis.com/Firewall":
        r = activo.get("resource", {}).get("data", {})
        rangos = r.get("sourceRanges", [])
        if "0.0.0.0/0" in rangos and r.get("direction") == "INGRESS":
            for permitido in r.get("allowed", []):
                puertos = permitido.get("ports", [])
                if any(p in ("22", "3389", "3306", "5432") for p in puertos):
                    alertar("CRITICAL", "Firewall open to the internet on a sensitive port",
                            nombre, datos)
                    return

def alertar(gravedad, mensaje, recurso, datos):
    logging.log_struct({
        "severity": gravedad,
        "message": mensaje,
        "recurso": recurso,
        "momento": datos.get("window", {}).get("startTime"),
        "alerta_gobierno": True,
    })

With a Cloud Monitoring alert on jsonPayload.alerta_gobierno=true and severity CRITICAL, those events reach the security channel within seconds.

This closes debt #4 from 07-04 — alerts on IAM changes and key creation — and it is the difference between finding out about a dangerous change as it happens or discovering it at the quarterly review, six weeks later.

  1. Cloud Audit Logs properly

They have been mentioned in 03-04, 06-06 and 07-04. Here, in full.

Type What it records By default? Cost Can it be disabled
Admin activity Configuration and permission changes Yes Free No
Data access Data reads and writes No (except BigQuery) Paid, and it can be a lot Yes
System events Google's actions on your resources Yes Free No
Policy denials Requests rejected by policies Yes Free No

The two facts to retain:

  1. Admin activity is free and cannot be disabled. Not even an attacker with organization-level permissions can erase the evidence of their configuration changes. It is the foundation of every investigation.
  2. Data access is disabled by default. And without it, as the 07-04 exercise demonstrated, you cannot know who read the customers' data — which turns a leaked credential into a breach of indeterminable scope.

Enabling data access where it matters

You do it with judgement, because the volume can be enormous:

# politica-auditoria.yaml — applied to the produccion FOLDER
auditConfigs:
  # BigQuery: reads and writes of customer data
  - service: bigquery.googleapis.com
    auditLogConfigs:
      - logType: DATA_READ
      - logType: DATA_WRITE

  # Cloud Storage: writes and administration only.
  # DATA_READ on a bucket serving images would generate millions of entries.
  - service: storage.googleapis.com
    auditLogConfigs:
      - logType: DATA_WRITE
      - logType: ADMIN_READ

  # Secret Manager: EVERYTHING. Every access to a secret is relevant
  - service: secretmanager.googleapis.com
    auditLogConfigs:
      - logType: DATA_READ
      - logType: DATA_WRITE

  # Cloud KMS: every use of a key
  - service: cloudkms.googleapis.com
    auditLogConfigs:
      - logType: DATA_READ
      - logType: DATA_WRITE

  # IAM: identity changes
  - service: iam.googleapis.com
    auditLogConfigs:
      - logType: DATA_WRITE
gcloud resource-manager folders set-iam-policy FOLDER_PRODUCCION_ID politica-auditoria.yaml

The three decisions that make this affordable, and that turn an unacceptable cost into around €35 a month (07-05):

  • Cloud Storage without DATA_READ: the catalogue bucket serves millions of images; recording every read would cost more than the whole platform. Writes and administrative reads are recorded, which is where the risk is.
  • Secret Manager with everything: there are few accesses and every one matters.
  • Only in the produccion folder: there is no real data in development.

And an essential exclusion so the volume does not explode:

gcloud logging sinks update _Default \
  --add-exclusion=name=ruido-lecturas-repetitivas,\
filter='protoPayload.methodName="google.storage.objects.get"
        AND protoPayload.authenticationInfo.principalEmail=~"^service-.*gserviceaccount.com$"'

This excludes the reads made by Google's own service agents, which are pure noise and extremely high volume.

How to read an entry

{
  "protoPayload": {
    "@type": "type.googleapis.com/google.cloud.audit.AuditLog",
    "authenticationInfo": {
      "principalEmail": "[email protected]",
      "serviceAccountDelegationInfo": [
        {"firstPartyPrincipal": {"principalEmail": "sa-deploy-prod@..."}}
      ]
    },
    "requestMetadata": {
      "callerIp": "88.20.145.33",
      "callerSuppliedUserAgent": "google-cloud-sdk gcloud/latest"
    },
    "serviceName": "cloudresourcemanager.googleapis.com",
    "methodName": "SetIamPolicy",
    "authorizationInfo": [
      {"resource": "projects/alpinashop-prod",
       "permission": "resourcemanager.projects.setIamPolicy",
       "granted": true}
    ],
    "resourceName": "projects/alpinashop-prod",
    "serviceData": {"policyDelta": {"bindingDeltas": [
      {"action": "ADD", "role": "roles/owner", "member": "user:[email protected]"}
    ]}}
  },
  "severity": "NOTICE",
  "timestamp": "2026-08-04T18:23:11.004Z"
}

The fields you have to know how to read, in order of importance:

Field What it tells you
authenticationInfo.principalEmail Who
serviceAccountDelegationInfo Who really, if there was impersonation (03-04)
methodName What
resourceName On what
requestMetadata.callerIp From where
authorizationInfo.granted Whether it was allowed or denied
serviceData.policyDelta The exact change, on IAM changes
timestamp When

serviceAccountDelegationInfo is the field almost nobody looks at and the one that matters most in an organization that uses impersonation: without it, a change made by Marta impersonating sa-deploy-prod appears attributed to the service account, and the trail back to the person is lost.

  1. The audit queries to have ready

Written and saved as views before you need them. An incident is no time to learn SQL.

1. Who changed an IAM policy?

SELECT
  timestamp,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.resourceName                      AS resource_name,
  delta.action                                            AS action,
  delta.role                                              AS role,
  delta.member                                            AS member,
  protopayload_auditlog.requestMetadata.callerIp          AS ip
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`,
  UNNEST(JSON_QUERY_ARRAY(protopayload_auditlog.servicedata_v1_iam.policyDelta.bindingDeltas)) AS d,
  UNNEST([STRUCT(
    JSON_VALUE(d, '$.action') AS action,
    JSON_VALUE(d, '$.role')   AS role,
    JSON_VALUE(d, '$.member') AS member)]) AS delta
WHERE protopayload_auditlog.methodName LIKE '%SetIamPolicy%'
  AND DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
ORDER BY timestamp DESC

2. Who accessed customer data?

SELECT
  DATE(timestamp) AS day,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.resourceName                      AS table_name,
  COUNT(*) AS accesses
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_data_access`
WHERE protopayload_auditlog.serviceName = 'bigquery.googleapis.com'
  AND protopayload_auditlog.resourceName LIKE '%pedidos%'
  AND DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY day, who, table_name
ORDER BY day DESC, accesses DESC

3. Who switched off or modified a log?

SELECT
  timestamp,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.methodName                        AS operation,
  protopayload_auditlog.resourceName                      AS resource_name
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`
WHERE (protopayload_auditlog.methodName LIKE '%UpdateSink%'
    OR protopayload_auditlog.methodName LIKE '%DeleteSink%'
    OR protopayload_auditlog.methodName LIKE '%UpdateBucket%'
    OR protopayload_auditlog.methodName LIKE '%SetIamPolicy%'
       AND protopayload_auditlog.serviceName = 'logging.googleapis.com'
    OR protopayload_auditlog.methodName LIKE '%UpdateExclusion%')
  AND DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
ORDER BY timestamp DESC

This third one is the most important of the three, and the one fewest people have. Tampering with the record is the first thing a competent attacker does and the first thing somebody who wants to hide a mistake does. It must have a permanent alert.

4. Out-of-hours activity.

SELECT
  timestamp,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.methodName                        AS operation,
  protopayload_auditlog.requestMetadata.callerIp          AS ip
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`
WHERE DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
  AND NOT REGEXP_CONTAINS(
        protopayload_auditlog.authenticationInfo.principalEmail, r'^(service-|.*gserviceaccount)')
  AND (EXTRACT(HOUR FROM timestamp AT TIME ZONE 'Europe/Madrid') NOT BETWEEN 7 AND 21
       OR EXTRACT(DAYOFWEEK FROM timestamp AT TIME ZONE 'Europe/Madrid') IN (1, 7))
ORDER BY timestamp DESC

The filter that excludes service accounts is essential: software works at night, and without that filter the query returns thousands of irrelevant lines.

5. Policy denials: what somebody tried and could not do.

SELECT
  DATE(timestamp) AS day,
  protopayload_auditlog.authenticationInfo.principalEmail AS who,
  protopayload_auditlog.methodName                        AS operation,
  protopayload_auditlog.status.message                    AS reason,
  COUNT(*) AS attempts
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_policy`
WHERE DATE(timestamp) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
GROUP BY day, who, operation, reason
ORDER BY attempts DESC

This query has a dual use worth understanding: it detects malicious attempts, and it also detects badly calibrated policies getting in the team's way. If Dani turns up twenty times trying to do something legitimate, the problem is the policy, not Dani.

  1. Immutable retention in a separate project

Here is the piece that makes the auditing credible.

A log the administrator can delete is no use as evidence.

If Marta has permission to modify the audit logs, then faced with any investigation — internal, from a customer or from the AEPD — the answer to "could somebody have altered this?" is "yes". And that empties the whole record of value.

The solution is a separate project with permissions not even the administrators have.

flowchart LR
    subgraph ORG["Organization"]
      P1["alpinashop-prod"]
      P2["alpinashop-dev"]
      P3["alpinashop-datos"]
      P4["alpinashop-red"]
    end
    SINK["Aggregated sink<br/>at ORGANIZATION level"]
    P1 & P2 & P3 & P4 --> SINK
    SINK --> B["Log bucket<br/>alpinashop-auditoria<br/>retention LOCKED 400 days"]
    SINK --> BQ["BigQuery<br/>audit queries"]
    SINK --> GCS["GCS bucket<br/>retention lock 7 years"]

    style B fill:#e6f4ea,stroke:#34a853
    style GCS fill:#e6f4ea,stroke:#34a853

Step 1: the project and its permissions

gcloud projects create alpinashop-auditoria --folder=FOLDER_SEGURIDAD_ID
gcloud services enable logging.googleapis.com bigquery.googleapis.com storage.googleapis.com \
  --project=alpinashop-auditoria

And the split of permissions, which is the key to everything:

Identity Role Can
gcp-seguridad@ roles/logging.viewer, roles/bigquery.dataViewer Read, not write or delete
gcp-infra@ (Marta) roles/logging.viewer Read only
An external auditor Scoped, temporary roles/logging.viewer Read during the audit
Nobody roles/logging.admin over the sinks

Marta, who administers the whole platform, can only read here. It is the separation of duties from 07-04 taken to its conclusion: whoever administers does not keep the evidence of what they administer.

Step 2: the aggregated sink at organization level

gcloud logging sinks create sumidero-auditoria-org \
  logging.googleapis.com/projects/alpinashop-auditoria/locations/eu/buckets/auditoria \
  --organization=ORG_ID \
  --include-children \
  --log-filter='logName:"cloudaudit.googleapis.com"'

--include-children is what makes it aggregated: it collects the logs of all the organization's projects and folders, including the ones created in the future. Without that flag, every new project would be left out and nobody would notice.

Then you have to grant the sink's writer permission on the destination:

ESCRITOR=$(gcloud logging sinks describe sumidero-auditoria-org \
  --organization=ORG_ID --format='value(writerIdentity)')

gcloud projects add-iam-policy-binding alpinashop-auditoria \
  --member="$ESCRITOR" --role=roles/logging.bucketWriter

This step gets forgotten constantly and the symptom is baffling: the sink exists, it shows up in the console, and nothing arrives. With no errors.

Step 3: locked retention

# Log bucket with 400-day retention and a LOCK
gcloud logging buckets create auditoria \
  --location=eu --retention-days=400 \
  --project=alpinashop-auditoria

# THE IRREVERSIBLE STEP
gcloud logging buckets update auditoria \
  --location=eu --locked \
  --project=alpinashop-auditoria

⚠️ --locked is irreversible. From that moment on nobody, not Google, not the organization owner, not support, can reduce the retention or delete the bucket before it expires. That is exactly the point: the guarantee is that not even you can.

And for long-term preservation, with Cloud Storage retention lock:

gcloud storage buckets create gs://alpinashop-auditoria-archivo \
  --location=EU --uniform-bucket-level-access \
  --project=alpinashop-auditoria

gcloud storage buckets update gs://alpinashop-auditoria-archivo \
  --retention-period=7y

# FINAL LOCK: no object can be deleted for 7 years
gcloud storage buckets update gs://alpinashop-auditoria-archivo --lock-retention-period

This is what is known as WORM storage (Write Once, Read Many), and it is what many compliance frameworks require. It is also, by the way, the best defence against ransomware there is: an attacker with full permissions cannot encrypt or delete what is under a retention lock.

With a counterpart to be accepted consciously: if you lock 7 years, you pay for 7 years of storage, no exceptions. Before locking, you calculate the volume and check that the number makes sense.

  1. Landing zones and the order that matters

A landing zone is the organization prepared before the first workload arrives: hierarchy, policies, network, identity, auditing and billing, all deployed as code.

AlpinaShop has done it the other way round, like almost everybody: workloads first, governance afterwards. It has worked because it is small. The correct way, and the one to know for your next project, is this.

The deployment order, and why it matters

flowchart TB
    A["1. Organization and Cloud Identity<br/>groups, MFA, domain"] --> B["2. Folder hierarchy"]
    B --> C["3. Billing<br/>account, export, budgets"]
    C --> D["4. Organization policies<br/>in DRY-RUN"]
    D --> E["5. Audit project<br/>aggregated sink + retention"]
    E --> F["6. Network project<br/>Shared VPC"]
    F --> G["7. IAM: groups and roles"]
    G --> H["8. Workload projects"]
    H --> I["9. Policies in ENFORCED mode"]
    I --> J["10. Workloads"]

    style E fill:#e6f4ea,stroke:#34a853
    style D fill:#fef7e0,stroke:#fbbc04
    style I fill:#fef7e0,stroke:#fbbc04

The four reasons for this particular order and not another:

  1. Auditing (5) comes before the network and the workloads. If the sink is created afterwards, the logs of everything done in the meantime are not captured. Building the platform is precisely the period with the most permission changes, and it is the one you most want on the record.
  2. Policies in dry-run (4) come before the workload projects (8). That way they are measured against resources as they are created, and violations get fixed as you go instead of piling up.
  3. The network (6) before the service projects (8). A service project needs the host to exist.
  4. Enforced policies (9) before the workloads (10). It is the only moment when enforcing a policy breaks nothing, because there is nothing to break.

In Terraform

# modules/landing-zone/main.tf — the module structure

module "carpetas" {
  source = "./modulos/carpetas"
  organizacion = var.org_id
  carpetas     = ["produccion", "desarrollo", "compartido", "seguridad"]
}

module "auditoria" {
  source     = "./modulos/auditoria"
  depends_on = [module.carpetas]

  org_id             = var.org_id
  carpeta_seguridad  = module.carpetas.ids["seguridad"]
  retencion_dias     = 400
  bloquear_retencion = true          # IRREVERSIBLE: done by hand the first time
}

module "politicas" {
  source     = "./modulos/politicas-organizacion"
  depends_on = [module.carpetas]

  org_id  = var.org_id
  dry_run = var.politicas_en_pruebas   # true at first, false once measured

  regiones_permitidas = ["in:europe-west1-locations",
                         "in:europe-southwest1-locations",
                         "in:eu-locations"]
}

module "red" {
  source     = "./modulos/vpc-compartida"
  depends_on = [module.politicas]

  carpeta_compartido = module.carpetas.ids["compartido"]
  proyecto_host      = "alpinashop-red"
  subredes           = var.subredes
}

module "proyectos" {
  source     = "./modulos/proyectos"
  depends_on = [module.red]

  for_each = var.proyectos
  # ...
}

Explicit depends_on on every module, even though there are no references between them. It is the legitimate case for depends_on that 06-07 flagged as the exception: the order here does not come from data dependencies, but from a logical sequence Terraform cannot infer.

Google publishes the foundation blueprints (Cloud Foundation Fabric, Terraform Example Foundation), which implement all of this thoughtfully. They deserve an honest warning: they are designed for large organizations and bring far more structure than a small business needs. Use them as a reference and as a source of well-thought-out decisions, not as a template to copy.

What it would cost to do it today at AlpinaShop

Phase Work Duration
Inventory the breaches The queries from section 8 2 h
Create alpinashop-auditoria and the sink Terraform 4 h
Policies in dry-run Terraform 3 h
Wait and analyse 14 days
Fix breaches and exceptions Variable 1-2 days
Enforce in development 1 h
Wait 7 days
Enforce in production 1 h
Feeds and alerts Terraform + a function 4 h
Total effective work ~4 days
Total elapsed time ~4 weeks

Four days of work spread over four weeks. That is the price of governing the platform, and it explains why the arithmetic in section 1 matters: in two years' time it would be four weeks of work and six months of elapsed time.

  1. Change management: reviews, lifecycle and exceptions

Technical policies are not enough. You need processes, and for a team of three they have to be light or they will not happen.

Periodic access review

Frequency What is reviewed Who Duration
Monthly New service accounts; keys created; basic roles Marta 30 min
Quarterly All permissions against the actual functions; Recommender recommendations Marta + Dani 2 h
Half-yearly External accounts; policy exceptions; third-party access Marta + management 1 h
When somebody changes role or leaves All their access Immediately
#!/usr/bin/env bash
# revision-trimestral.sh — generates the report for the meeting
echo "=== 1. Basic roles in the organization ==="
gcloud asset search-all-iam-policies --scope=organizations/$ORG_ID \
  --query="policy:(roles/owner OR roles/editor)" \
  --format="table(resource, policy.bindings.role, policy.bindings.members)"

echo "=== 2. External accounts ==="
gcloud asset search-all-iam-policies --scope=organizations/$ORG_ID \
  --query="policy:(gmail.com OR hotmail.com OR outlook.com)" \
  --format="table(resource, policy.bindings.members)"

echo "=== 3. Permissions granted and unused for 90 days ==="
for P in $PROYECTOS; do
  gcloud recommender recommendations list --project="$P" --location=global \
    --recommender=google.iam.policy.Recommender \
    --format="table(content.overview.member, content.overview.removedRole)"
done

echo "=== 4. Service account keys ==="
gcloud asset search-all-resources --scope=organizations/$ORG_ID \
  --asset-types=iam.googleapis.com/ServiceAccountKey \
  --format="table(name, createTime)"

echo "=== 5. Policy exceptions in force ==="
gcloud org-policies list --organization=$ORG_ID --format="table(name, spec.rules)"

Project lifecycle

Phase What is required
Request Purpose, owner, environment, cost centre, review date
Creation Always via Terraform, never from the console. Mandatory labels, a budget, the correct folder
Operation Quarterly review of cost and permissions
Expiry Every test project has an expiry date
Retirement Export the data → unlink billing → wait 30 days → delete

The mandatory expiry date on test projects is the measure that avoids the most phantom spend (07-05), because it attacks the cause rather than the symptom. It is implemented with a caduca=2026-12-31 label and a weekly job that flags the expired ones.

And the 30 days of waiting before deleting are not bureaucracy: deleting a project is irreversible once the grace period is over, and somebody always turns up who needed something from there.

The exceptions process

Every policy will end up having exceptions. Without a process, they get granted verbally and become permanent.

Element Rule
Request In writing, with a technical reason and the alternatives ruled out
Approval Marta + one more person. Never just one
Scope The smallest possible: one resource, not a project
Expiry Mandatory. 90 days maximum, renewable with justification
Record In alpinashop-infra/EXCEPCIONES.md, with Terraform
Review Half-yearly: is it still needed?
# EXCEPCIONES.md

## EXC-001 — Public IP on `bastion-migracion`
- **Policy:** `constraints/compute.vmExternalIpAccess`
- **Scope:** `projects/alpinashop-dev/zones/europe-west1-b/instances/bastion-migracion`
- **Reason:** the ERP vendor requires an inbound connection from its IP for the
  data migration; it does not support outbound connections or PSC.
- **Alternatives ruled out:** IAP (the vendor cannot use the tunnel),
  VPN (outside the project's timescale).
- **Mitigation:** firewall restricted to the vendor's IP; the VM is switched off
  outside working sessions; connection logging enabled.
- **Requested by:** Dani · **Approved by:** Marta + management
- **Granted:** 2026-08-05 · **EXPIRES: 2026-11-03**
- **Review:** when the migration finishes, the VM and the exception are removed

The expiry date is what separates an exception from a repeal. Without it, the policy is hollowed out forever and nobody remembers why.

  1. AlpinaShop's maturity, and what is left

Where it stands

Domain Status Comment
Organizational structure 🟢 Good Hierarchy by environment, projects with a clear purpose, a separate security folder
Identity 🟢 Good Groups, no basic roles, no keys, federation, nobody owner of production
Network 🟢 Good Shared VPC, permissions per subnet, hybrid resolved, data perimeter
Compute 🟢 Good No servers to maintain, scaling to zero, canary and rollback in seconds
Data 🟡 Acceptable Classified, encrypted, with retention; the right to erasure still needs testing
CI/CD 🟢 Good Trunk-based, keyless, manual approval, verified canary
Observability 🟢 Good Metrics, logs, traces, SLOs, error budget, burn rate alerts
Reliability 🟡 Acceptable Regional HA, DR with RTO/RPO, restore tested; multi-region still missing
Cost 🟢 Good Visibility, allocation, budgets, a monthly routine, −60 %
Security 🟡 Acceptable Defence in depth; the priority debt paid; active detection still missing
Governance 🟡 Under way This lesson: policies, inventory, immutable auditing
Compliance 🟠 Needs work Documentation out of date; professional validation still missing

What is left, honestly

Short term (this quarter):

  1. Finish enforcing the organization policies. They are in dry-run; the 4 weeks from section 13 have to be got through.
  2. Updated compliance documentation, with professional advice. It is the most backward box and the only one with legal consequences.
  3. Test the right to erasure procedure end to end, with a test customer.

Medium term (this year):

  1. Multi-region for Cloud SQL, which is the only thing preventing complete regional high availability (07-06).
  2. Active threat detection, once there is somebody who can deal with it (07-04).
  3. Temporary privilege elevation with PAM.
  4. An external security review over the already-hardened architecture.

What it is NOT going to do, and that is fine:

Will not do Why
GKE Enterprise / Google Distributed Cloud DA-004: there are no workloads outside GCP to manage
A service mesh One service. There is nothing to mesh
Multicloud There is no business reason
Active-active The cost is not justified with 1,200 orders/month (07-06)
A dedicated security team Forty people
Network Connectivity Center One VPC and one link

That last table is the most valuable in the lesson, because a mature organization is not the one that has adopted the most technology: it is the one that can justify what it has decided not to adopt. All six rows have their decision written down, their reason, and — in the case of DA-004 — their review conditions.

The complete journey

It is worth looking back once.

AlpinaShop started module 1 with no Google Cloud account, with a shop running somewhere and three people who did not know what a region was. It finished module 2 with the application migrated and a decision outstanding. Module 3 gave it network, load balancing, identity and secrets. Module 4, a data platform. Module 5, models that predict. Module 6, continuous delivery and observability. And module 7 has resolved what was left: the hybrid question, the definitive compute, the advanced network, the reviewed security, the understood bill, the measured reliability and the applied governance.

Today AlpinaShop has a platform that one person can operate, anybody can understand by reading a repository, and can be rebuilt in its entirety with terraform apply. It is not perfect — it has a written, prioritised list of debts, which is exactly what it should have — but it is defensible in front of a customer, in front of an auditor and in front of the person who joins next month.

Common Mistakes and Tips

  • Leaving governance until "when we are big". The cost grows with the exceptions you have to negotiate, not with the number of resources. It happens now or it does not happen.
  • Applying policies without first inventorying what breaches them. You generate hundreds of violations nobody analyses and the dry-run stops being useful.
  • Forgetting in:eu-locations in resourceLocations. Multi-region BigQuery and some buckets can no longer be created.
  • allowedPolicyMemberDomains with no exceptions for Google's service agents. Internal services fail in inexplicable ways.
  • Requiring CMEK without granting the service agent permissions on the key. New resources cannot be created.
  • Enforcing policies on a Friday. A weekend unable to deploy.
  • Only one person having orgpolicy.policyAdmin. If they lock themselves out, the way out is Google support.
  • A sink without --include-children. New projects are left out and nobody notices.
  • Forgetting to give logging.bucketWriter to the sink's writerIdentity. The sink exists and nothing arrives, with no errors.
  • Creating the audit project last. The logs from the build phase, which has the most changes, are lost.
  • Letting the administrator write to the audit logs. Then they are no use as evidence.
  • Locking the retention without calculating the volume. --locked is irreversible and you pay for every year you set.
  • Enabling DATA_READ on a bucket that serves images. Millions of entries and an absurd bill.
  • Not looking at serviceAccountDelegationInfo. In an organization with impersonation, without that field the trail back to the person is lost.
  • Exceptions with no expiry date. They stop being exceptions and hollow out the policy.
  • Copying a foundation blueprint as is. They are built for large organizations and bring structure you do not need.
  • Tip: the "who touched a log" query deserves a permanent alert. It is the first thing tampered with by anybody wanting to hide something.
  • Tip: give test projects a mandatory expiry date. It attacks the cause of phantom spend, not the symptom.
  • Tip: use policy denials as a thermometer. If somebody on the team turns up twenty times, the policy is badly calibrated.
  • Tip: write down what you decide NOT to adopt. It is what distinguishes a mature organization from one that simply never got there.

Exercises

Exercise 1 — Designing the policy set for a new company

AlpinaTech, a 25-person consultancy, is starting on Google Cloud from scratch. The facts:

  • Three teams: development (12), data (5), infrastructure (3). The rest are non-technical.
  • They work for external customers, and some require that their data does not leave the EU.
  • Two freelance consultants with Gmail accounts collaborate on specific projects.
  • One customer requires ISO 27001 certification within 18 months.
  • Budget: €2,500/month of cloud.

Design: the folder and project hierarchy with its criterion justified, the complete set of organization policies with their level and their risk, how you resolve the freelancers' case without disabling allowedPolicyMemberDomains, and the deployment order of the landing zone.

Exercise 2 — Investigating a suspicious change

On a Monday morning, the monthly audit query reveals this:

timestamp             quien                                              operacion       recurso
2026-08-01T23:47:12Z  [email protected]...  SetIamPolicy    projects/alpinashop-prod
2026-08-01T23:47:44Z  [email protected]...  CreateServiceAccount  projects/alpinashop-prod
2026-08-01T23:48:03Z  [email protected]...  CreateServiceAccountKey  projects/alpinashop-prod
2026-08-01T23:51:20Z  [email protected]...  storage.buckets.update  alpinashop-datalake

It was Saturday night. There was no scheduled deployment. The sa-mantenimiento account does not appear in AlpinaShop's Terraform.

Write the complete investigation: which queries you run and in what order, what you are looking for in each one, how you determine whether it was an attack or an undocumented legitimate automation, what you contain and in what order, and which governance controls would have detected or prevented this earlier.

Exercise 3 — Justifying governance to management

AlpinaShop's management asks why four days should be spent "putting in rules" when the system works perfectly and there has not been a single incident.

Write Marta's answer: what specific risk each block of work covers, what would happen without it with real examples from the course, what it costs to do it now against in two years' time, and what you ask for besides the time. One page maximum, in non-technical language.

Solutions

Solution 1 — Designing AlpinaTech's policies

The hierarchy, and the criterion that decides it.

The determining factor is not the size or the teams: it is that they work for external customers with different requirements. That makes isolation by customer more important than isolation by environment.

Organizacion alpinatech.example
├── carpeta: clientes
│   ├── carpeta: cliente-acme
│   │   ├── alpinatech-acme-prod
│   │   └── alpinatech-acme-dev
│   └── carpeta: cliente-beta
│       ├── alpinatech-beta-prod
│       └── alpinatech-beta-dev
├── carpeta: interno
│   ├── alpinatech-web-corporativa
│   └── alpinatech-herramientas
├── carpeta: compartido
│   ├── alpinatech-red
│   └── alpinatech-cicd
└── carpeta: seguridad
    └── alpinatech-auditoria

Why a folder per customer with environments inside:

Advantage Detail
Contractual isolation Acme's data cannot be mixed with Beta's, not even by accident
Policies per customer If Acme requires EU only and Beta does not, it is applied in each one's folder
Direct billing The cost per folder is what is invoiced to the customer
Clean exit When the contract ends, the whole folder is deleted
Permissions per project Only the team assigned to Acme has access to Acme

The last row matters most with freelancers involved, and it links to the next point.

The policy set.

# Constraint Level Risk Reason
1 iam.allowedPolicyMemberDomains Organization High They work with outsiders: it is the most important and the most delicate
2 gcp.resourceLocations = EU clientes folder Medium-high A contractual requirement; in interno it can be laxer
3 iam.disableServiceAccountKeyCreation Organization Medium The basis of ISO 27001
4 storage.publicAccessPrevention Organization Low Customer data
5 storage.uniformBucketLevelAccess Organization Low IAM as the only truth
6 compute.vmExternalIpAccess Organization Low
7 compute.requireOsLogin Organization Low A freelancer leaving = losing access to everything
8 iam.automaticIamGrantsForDefaultServiceAccounts Organization Low From day 1: it will never need fixing
9 sql.restrictPublicIp Organization Low
10 compute.disableSerialPortAccess Organization Low Avoids an unaudited access path
11 Custom: cliente and centro-coste labels mandatory Organization Medium Customer billing depends on it
12 gcp.restrictNonCmekServices clientes folder High If any customer requires it

Note policy 2 at the clientes folder level and not at the organization. Applying it at the organization would force the corporate website and the internal tools to be in the EU, which is probably fine but is a restriction that is not needed. Policies go at the highest level where they make sense, not at the highest level possible.

The freelancers' case — the interesting part of the exercise.

Disabling allowedPolicyMemberDomains to give access to two Gmail accounts would be throwing away the most valuable policy in the set. There are four options, and the last one is the right one:

Option Assessment
Not applying the policy ❌ Anybody can give access to any Google account in the world
A permanent exception for their two Gmail addresses ❌ Personal accounts outside the company's control: no mandatory MFA, no centralised offboarding, no ability to revoke the mailbox
An exception in the specific customer's folder ⚠️ Better, but it still rests on personal accounts
Cloud Identity accounts of their own for the freelancers The correct one

The solution: [email protected] managed by the company, with mandatory MFA, an IAM condition with an expiry date (03-04) matching the end of the contract, and permissions only on the project they work on.

resource "google_project_iam_member" "freelance_acme" {
  project = "alpinatech-acme-dev"
  role    = "roles/editor"
  member  = "user:[email protected]"

  condition {
    title      = "Acme contract until 2026-12-31"
    expression = "request.time < timestamp('2027-01-01T00:00:00Z')"
  }
}

The access expires by itself. It does not depend on anybody remembering to revoke it on 31 December, which is exactly the kind of task that gets forgotten. And the cost of a Cloud Identity licence is trivial compared with the risk of a consultant keeping access to a customer's data for two years after finishing.

Deployment order.

  1. Cloud Identity, domain, groups (at-desarrollo@, at-datos@, at-infra@, at-seguridad@), mandatory MFA from minute one.
  2. The organization and folders.
  3. Billing: account, export to BigQuery, a global budget of €2,500 and one per customer folder.
  4. alpinatech-auditoria with an aggregated sink and locked retention. Before anything else, to capture the whole build.
  5. Policies 1-11 in dry-run.
  6. alpinatech-red with Shared VPC.
  7. alpinatech-cicd with Workload Identity Federation.
  8. The first customer project as a template, with a reusable Terraform module.
  9. Policies in enforced mode.
  10. Workloads.

AlpinaTech's decisive advantage over AlpinaShop: it starts at step 1 with a blank slate. It will not have to negotiate a single exception, because when it enforces the policies there will be nothing breaching them. It is literally the best possible moment, and it only happens once.

On ISO 27001 within 18 months: practically everything in this lesson is direct evidence for the certification — access control, an immutable audit record, change management, periodic permission review, data classification. Doing it now means arriving at the audit with 18 months of records; doing it in a year means arriving with six. The auditor looks at the history, not at the snapshot of the day.

Solution 2 — Investigating the suspicious change

An initial reading of the indicators. Four signals, and none is conclusive on its own but together they draw a very recognisable pattern:

Indicator Why it is worrying
Saturday 23:47 Outside any working hours and with no scheduled deployment
SetIamPolicyCreateServiceAccountCreateServiceAccountKey in 51 seconds It is the canonical sequence for establishing persistence
CreateServiceAccountKey Policy 4.3 ought to prevent it. A new downloadable key exists
sa-mantenimiento is not in Terraform A resource created outside the process: either it is shadow IT or it is an attacker

Step 1 — Who really acted? (5 minutes)

sa-deploy-prod is a service account; it does not act on its own. The question is who used it:

SELECT
  timestamp,
  protopayload_auditlog.authenticationInfo.principalEmail AS identity,
  JSON_VALUE(protopayload_auditlog.authenticationInfo.serviceAccountDelegationInfo)
    AS delegation,
  protopayload_auditlog.requestMetadata.callerIp          AS ip,
  protopayload_auditlog.requestMetadata.callerSuppliedUserAgent AS agent,
  protopayload_auditlog.methodName                        AS operation
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`
WHERE protopayload_auditlog.authenticationInfo.principalEmail LIKE 'sa-deploy-prod%'
  AND timestamp BETWEEN TIMESTAMP('2026-08-01 22:00:00')
                    AND TIMESTAMP('2026-08-02 02:00:00')
ORDER BY timestamp

The three fields that decide the case:

Field If it is… It means
delegation Empty The account was used directly, with a credential. Where did it come from?
delegation A person's email address Somebody impersonated it. Who, and why on a Saturday?
agent Google-Cloud-Build It was the pipeline: you have to look for which build
agent gcloud/... Somebody from a terminal
agent A generic library or empty Very suspicious
ip A Google range (35.x, 34.x) Consistent with Cloud Build
ip The office IP Somebody on the team
ip Anything else 🚨 Incident confirmed

Step 2 — What exactly was granted? (5 minutes)

SELECT
  timestamp,
  protopayload_auditlog.resourceName AS resource_name,
  JSON_VALUE(d, '$.action') AS action,
  JSON_VALUE(d, '$.role')   AS role,
  JSON_VALUE(d, '$.member') AS member
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`,
  UNNEST(JSON_QUERY_ARRAY(
    protopayload_auditlog.servicedata_v1_iam.policyDelta.bindingDeltas)) AS d
WHERE protopayload_auditlog.methodName LIKE '%SetIamPolicy%'
  AND DATE(timestamp) = '2026-08-01'

If the result includes ADD roles/owner or ADD roles/editor to sa-mantenimiento, or any grant to an external identity, it is an incident and the 07-04 script is activated.

Step 3 — Was the key that was created used? (10 minutes)

This is the question that determines the scope:

SELECT
  timestamp,
  protopayload_auditlog.methodName               AS operation,
  protopayload_auditlog.resourceName             AS resource_name,
  protopayload_auditlog.requestMetadata.callerIp AS ip
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`
WHERE protopayload_auditlog.authenticationInfo.principalEmail LIKE 'sa-mantenimiento%'
ORDER BY timestamp

And the change to the data lake bucket, which is the obvious target:

SELECT timestamp, protopayload_auditlog.methodName, protopayload_auditlog.request
FROM `alpinashop-auditoria.auditoria.cloudaudit_googleapis_com_activity`
WHERE protopayload_auditlog.resourceName LIKE '%alpinashop-datalake%'
  AND DATE(timestamp) BETWEEN '2026-08-01' AND CURRENT_DATE()
ORDER BY timestamp

If buckets.update added allUsers, the data lake was public. That turns the case into a personal data breach with notification to the AEPD within 72 hours.

Step 4 — Attack or undocumented automation? (15 minutes)

Signal Legitimate automation Attack
IP A Google or office range External, or from another cloud provider
User agent Google-Cloud-Build, terraform Generic, empty, python-requests
Delegation Somebody on the team Empty or unknown
Associated build It exists in Cloud Build It does not exist
Role granted Scoped and coherent owner, editor
Key created None: the pipeline does not need them Yes
Resource name Consistent with the convention Generic and plausible: sa-mantenimiento
Subsequent activity Consistent with the task Enumeration, mass reads

The indicator that weighs most is the key creation. AlpinaShop's pipeline has used Workload Identity Federation since 06-02 and neither needs nor has ever created a JSON key. A new key in production, on a Saturday at 23:48, is almost certainly an attacker's persistence. And the name sa-mantenimiento is exactly the kind of plausible name chosen to go unnoticed in a list.

Step 5 — Containment, in this order.

# 1. DELETE the key: it is the exfiltrated credential
gcloud iam service-accounts keys delete KEY_ID \
  --iam-account=sa-mantenimiento@alpinashop-prod.iam.gserviceaccount.com

# 2. DISABLE (do not delete) the account: the evidence is preserved
gcloud iam service-accounts disable \
  [email protected]

# 3. Revert the improper IAM grants
gcloud projects remove-iam-policy-binding alpinashop-prod \
  --member="serviceAccount:[email protected]" \
  --role="roles/owner"

# 4. Close the bucket if it was left open
gcloud storage buckets update gs://alpinashop-datalake --no-public-access-prevention=false

# 5. Rotate sa-deploy-prod's credentials and review the pipeline
#    (if the compromise came from there, everything it touches is in doubt)

# 6. Freeze the evidence outside the affected project
gcloud logging read 'timestamp>="2026-07-01T00:00:00Z"' --project=alpinashop-prod \
  --format=json > /tmp/evidencia.json
gcloud storage cp /tmp/evidencia.json gs://alpinashop-evidencia-forense/

# 7. Look for MORE persistence: there is never only one
gcloud asset search-all-resources --scope=organizations/ORG_ID \
  --query="createTime>2026-07-25" --format="table(name, assetType, createTime)"

The order matters: the credential first (step 1), because while it exists the attacker is still inside; then the account; then the permissions. And step 7 is never skipped: whoever establishes persistence rarely does it in only one place.

Which governance controls would have detected or prevented it.

Control Effect Section
iam.disableServiceAccountKeyCreation It would have PREVENTED it. Without a key, there is no persistence 4.3
A Cloud Asset Inventory feed on ServiceAccountKey An alert in seconds, not 36 hours later 9
A feed on owner grants An immediate alert on the SetIamPolicy 9
storage.publicAccessPrevention It would have stopped the bucket being opened 4.5
The out-of-hours activity query Detection the next day, not at the monthly review 11
Terraform as the only route to creation + drift detection A plan would have shown the undeclared account 06-07
Immutable log retention It guarantees the evidence was not altered 12

And the conclusion that makes sense of the whole lesson: of the seven measures, the first would have prevented the attack entirely and the second would have detected it within seconds. Both are from this lesson, both cost less than an hour of configuration, and neither was in place on Saturday night.

A preventive control that costs an hour is worth more than the most brilliant investigation.

Solution 3 — Justifying governance to management


Why we are spending four days putting rules in the cloud Marta Ruiz, infrastructure lead

It is true that the system works and that we have not had a single serious incident. It is also true that it works because the three of us on the team remember to do things properly, not because anything prevents otherwise. Today, any of us — or anybody who joins next month — can, without meaning to and without any alarm going off, leave a data store open to the internet, create a permanent password nobody knows exists, or put customer data on a server outside Europe.

This is not a hypothesis. We have had two cases this year. We found a data access password that had been lost for fourteen months and still worked. And we discovered two services switched on that nobody was using and that had cost us €1,500. Both were resolved, but we found both of them by chance, months later.

What each block of work covers:

Automatic rules (day 1). They technically prevent what today we only avoid out of habit: no servers exposed to the internet, no data outside the EU, and — the most important — nobody can create permanent passwords. Without this, the lost password case can happen again tomorrow.

A protected record (day 2). We keep, in a separate place and behind a padlock not even I can open, the record of everything done on the platform. It serves three purposes: if something happens, we know exactly what and who; if a customer or the Data Protection Agency asks us for it, we can prove it; and because not even the administrator can modify it, it counts as evidence. A record I could delete would be no use at all.

Warnings as they happen (day 3). Today, if somebody grants themselves administrator permissions on a Saturday night, we would find out at the following month's review. With this we find out within seconds.

Inventory and reviews (day 4). Knowing at all times what exists, who has access to what, and what is costing money without delivering anything.

Why now and not later. It is a matter of arithmetic, and it is the most important argument in this note. Today we have five projects and around a hundred and twenty resources: putting the rules in affects zero existing things, because we already do everything properly. In two years, with fifteen projects and six hundred resources, putting those same rules in will mean finding everything that does not comply, talking to every team and accepting permanent exceptions that would leave the rules almost empty. Four days now are four weeks in two years' time, and by then it probably will not get done at all.

And there is a commercial reason worth bearing in mind too: when a large customer asks us for a security questionnaire — and they will — the difference between answering "yes, and here is the record for the last eighteen months" and answering "we trust our team" can be the difference between signing the contract or not.

What I am asking for besides the time:

  1. That a second person holds the master keys. Today I am the only one who can lift these rules. If I make a mistake or am unavailable, the team is left blocked.
  2. Half an hour a quarter to review together who has access to what. It is quick and it stops permissions accumulating on their own, which is what always ends up happening.
  3. Accepting that for two weeks we will go a bit more slowly, while we measure the impact of the rules before switching them on. We are not going to enable anything without testing it first: blocking the team out of haste would be the worst possible outcome.

The four communication decisions:

  1. Acknowledging they are right before countering. "It is true that it works" disarms the objection instead of confronting it. What gets corrected is why it works, not the fact.
  2. Two real incidents instead of hypothetical risks. "It could happen" gets dismissed; "it happened to us in March and we found it in July" does not. And both cases were resolved, which makes it possible to tell them without seeming alarmist or incompetent.
  3. The arithmetic argument as the backbone. Four days now against four weeks in two years is reasoning a finance director understands immediately and cannot argue with, because it does not depend on judging the risk: it depends on counting.
  4. The commercial angle at the end. It turns governance from a cost into a revenue enabler. It is the argument that carries the most weight with a board and that is why it comes after the others, not before: put first it would look like an excuse; put last, it clinches it.

And a fifth, visible in the requests: it explicitly asks to go more slowly for two weeks. Announcing the cost before it is felt stops the first friction being read as a planning error.

Conclusion

Module 7 ends where it had to end: turning into controls what until now were agreements.

You know what governance is and you answer its four questions with concrete mechanisms: what can be created (organization policies), what exists (Asset Inventory), who did what (Audit Logs) and who pays (structure and labels). And you have the argument that decides when to put it in: the cost does not grow with the resources, it grows with the exceptions you have to negotiate. Policies go in when they bother nobody; after that they never go in at all.

You know how to structure an organization with the three criteria — environment, team, application — and, more importantly, with their consequences for permissions, inherited policies, billing, quotas and blast radius. You understand why AlpinaShop structures by environment and why AlpinaTech, with external customers, must do it by customer.

You have mastered organization policies: what distinguishes them from IAM — they are not permissions, and not even an owner bypasses them — their three types, and inheritance with its key rule: a deny at any level always wins. You have AlpinaShop's nine policies with their reason, their level and their risk, including the two that close critical debts from 07-04.

You know how to apply them without blocking the team: the dry-run pattern for the fourth time in the course, the eight-step procedure that starts by inventorying what already breaches them, the five real ways to lock yourself out, and the safeguard to prepare before anything else: two people with orgpolicy.policyAdmin.

You know how to write custom constraints with CEL and to distinguish them from Policy Controller, which acts on Kubernetes and does not compete but complements. And you know that quotas prevent where budgets only warn.

You can handle Cloud Asset Inventory to know what exists, search resources and IAM policies, export to BigQuery and build the history that answers "what was there on 3 June?". And you set up feeds that warn within seconds about an owner grant, a public bucket or — most important of all — the creation of a service account key.

You know the Cloud Audit Logs properly: the four types, with the two facts to retain — admin activity is free and cannot be disabled; data access is off by default and without it a breach cannot be bounded — and the three decisions that make its cost bearable. You know how to read an entry field by field, including serviceAccountDelegationInfo, which almost nobody looks at and without which the trail back to the person behind an impersonation is lost.

You have the five audit queries saved before you need them, with the "who touched a log" one singled out as the most important and the least common, and with policy denials used as a thermometer for whether your rules are properly calibrated.

And you have set up the piece that makes everything else credible: immutable retention in a separate project, with an aggregated sink at organization level and --include-children so no future project is left out, with logging.bucketWriter granted to the writerIdentity — the step that is always forgotten — and with the irreversible --locked. Where Marta, who administers the whole platform, can only read. Because a log the administrator can delete is no use as evidence.

You know what a landing zone is and — what matters — why the order is what it is: auditing before the network and the workloads, so as not to lose the logs from the period with the most changes in the platform's whole life. With Terraform's explicit depends_on as the legitimate case, and with Google's blueprints as a reference and not a template.

And you have the change management processes sized for three people: reviews with their frequency and their script, a project lifecycle with a mandatory expiry date on test ones, and an exceptions process where the expiry date is the only thing separating an exception from a permanent repeal.


And here module 7 ends. Look at where AlpinaShop is.

It started this module with an application that built, deployed and observed itself, and with a list of deferred conversations. Today the list is empty. DA-004 decided not to adopt the hybrid platform and to connect Sabadell with a tunnel, with its review conditions written down. DA-001, promised in module 2 and carried for five, is fulfilled: the catalogue runs on Cloud Run, the MIG is switched off, and there is not a single virtual machine left in the serving path. The network is a Shared VPC with permissions per subnet, real hybrid connectivity and a perimeter that stops the data leaving. Security has been reviewed end to end layer by layer, with a checklist of 43 controls and a prioritised debt whose critical part is already paid. The bill has come down 60 % and — more importantly — it is understood, attributed and tracked by unit cost. Reliability has numbers: four SLIs, four SLOs, an error budget with a policy, a recovery plan with RTO and RPO, and a tested restore that found five problems nobody suspected. And governance turns all of that into controls that do not depend on anybody remembering.

Debt remains, written down and prioritised. And a list remains of things AlpinaShop has decided not to do, with its reasons — which is what really distinguishes a mature organization from one that simply never got there.

You have travelled seven modules following Marta, Dani and Lucía. You have seen an account and a hierarchy created, an application migrated, a choice made between five compute services, networks, identities and secrets built, a data platform constructed, models trained and served, delivery automated, the system observed, an incident resolved end to end, infrastructure written as code, and now it secured, measured, made cheaper and governed. You have seen decisions taken and also undone: App Engine was ruled out, GKE was retired, Anthos was rejected, an endpoint was switched off. You have seen four architecture decisions documented, reviewed and — some of them — fulfilled years after being written.

What you have not done yet is decide for yourself.

Module 8 is your project. There is no longer an AlpinaShop to follow: there is a company, a problem and a set of constraints you will have to resolve from start to finish. You are going to gather requirements and translate them into decisions (08-01), design the complete architecture justifying every choice just as DA-001 was justified (08-02), implement it with infrastructure as code and continuous delivery (08-03), test it and deploy it with a canary, SLOs and a recovery plan (08-04), and present it and defend it to whoever asks you why you chose what you chose (08-05). And you will finish by looking ahead, towards the Google Cloud certifications and towards what comes after this course (08-06).

Everything you need is in the seven modules you have just been through. Now it is your turn.

Google Cloud Platform (GCP) Course

Module 1: Introduction to Google Cloud Platform

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved