Chapter 11

Cloud Storage

Scope. Cloud Storage as the book's object storage service: buckets, objects, storage classes and locations, access control (IAM, uniform bucket-level access, public access prevention, signed URLs and policy documents), data protection (versioning, lifecycle, retention, Bucket Lock, soft delete), encryption wiring, logging and auditability, and the upload and archival patterns the reference estates use in production. Cloud KMS mechanics are Chapter 14; Sensitive Data Protection is Chapter 15; VPC Service Controls perimeters are Chapter 20. Prerequisites. §1.7 inheritance, §1.10 service agents, §2.30 organization policy, §3.9 IAM roles, §5.17 Private Google Access. Verified against. Google Cloud console and API surface as of 2026-09, Cloud SDK 583.0.0, hashicorp/google provider 8.x; see sources at end.

Every SaaS product ends up with an object store holding the things a relational database is the wrong shape for: user uploads, generated reports, build artifacts, log exports, and backups. Cloud Storage is where rc-saas keeps all of it, and it is also where a surprising fraction of cloud data breaches originate, because a bucket is reachable from the public internet by default architecture (not by default configuration) the moment a single binding or a single flag is wrong.

The service's security model is unusual in one respect that this chapter treats as central: a bucket can grant access two different ways at once — IAM policy and, unless disabled, legacy per-object and per-bucket access control lists — and the two do not compose the way a reader expects. Uniform bucket-level access removes the second path entirely, and this chapter treats it as the default rather than an option to consider.

The second theme is that "object storage" is not one product decision. Storage class trades cost for access latency and minimum retention. Location trades availability and residency for replication behavior. Retention, holds, and Bucket Lock trade operational flexibility for a compliance guarantee that cannot be reversed by an angry administrator. None of these are defaults you inherit correctly; each is a choice this chapter shows how to make and how to enforce with organization policy so the choice cannot be quietly undone by a later change.

11.1 Object Storage Concepts §

Cloud Storage holds immutable objects — arbitrary byte blobs with metadata — inside buckets, a flat namespace with no true directory structure (/ in a name is cosmetic).

There are no filesystem semantics: no partial writes, no file locking, no rename without a copy-and-delete. Every write replaces the object wholesale, which is what makes object versioning meaningful (§11.13) — a "version" is a complete prior object, not a diff.

Three access surfaces, one policy. The JSON API, the interoperable XML API (for S3-compatible tooling), and gcloud storage, which wraps the JSON API and is the CLI this chapter uses. All three enforce the same IAM and the same organization policy; none is a lower-security back door, which matters when an audit asks whether S3-compatible tooling bypasses your controls. It does not.

A prefix is not an access boundary, and this is the misconception that causes real incidents. gs://rc-saas-prod-uploads-01/tenant-42/ looks like tenant isolation and is not one: IAM has no path-scoped role, and storage.objects.list enumerates across every prefix in the bucket. A path convention must be backed by an IAM condition on the object name (Chapter 3) or, for anything with a real isolation requirement, by a separate bucket per tenant.

Pitfall. Because the namespace is flat, listing cost and latency scale with the number of objects in the whole bucket, not the "folder" you asked for. A bucket with tens of millions of objects makes an innocuous-looking console browse slow enough to look like an outage, and a lifecycle audit expensive enough that teams stop running it.

11.2 Buckets §

A bucket is a project-scoped resource with a globally unique name, a single location, a default storage class, and a set of policies (IAM, lifecycle, retention, encryption, logging). Bucket names share one namespace across every Google Cloud customer, so a name available to rc-saas-dev-app-01 this morning may be gone by afternoon; the book copes with this by naming buckets rc-saas-<env>-<purpose>-<nn>, prefixed by the owning project so a collision inside the estate is the only kind that needs a renumber.

gcloud storage buckets create gs://rc-saas-prod-uploads-01 \
  --project=rc-saas-prod-app-01 \
  --location=us-central1 \
  --default-storage-class=STANDARD \
  --uniform-bucket-level-access \
  --public-access-prevention

Console. Cloud Storage → Buckets → Create. API. storage.buckets.insert, permission storage.buckets.create. IaC.

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.0"
    }
  }
}

resource "google_storage_bucket" "uploads" {
  name                        = "rc-saas-prod-uploads-01"
  project                     = "rc-saas-prod-app-01"
  location                    = "us-central1"
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
}

Pitfall. gcloud storage buckets update cannot rename a bucket — the name is immutable. A rename is a new bucket, a copy, and a cutover; plan the name before creation.

11.3 Objects §

An object is content plus metadata: a content type, optional custom metadata and custom time, a generation number for the content, and a metageneration number for the metadata. gcloud storage cp moves bytes; gcloud storage objects update changes metadata without re-uploading them.

gcloud storage cp ./report.pdf gs://rc-saas-prod-uploads-01/reports/report.pdf
gcloud storage objects update gs://rc-saas-prod-uploads-01/reports/report.pdf \
  --content-type=application/pdf \
  --custom-metadata=tenant=tenant-42

Generation numbers, not timestamps, are the concurrency primitive. --if-generation-match=0 makes a write fail if the object already exists, which is the only safe way to implement "create but never overwrite" against a store with no locking. Against a store where every write is a full replacement, a read-modify-write without a generation precondition silently loses whichever concurrent write finished first.

Custom metadata is not protected. It is readable by anyone who can read the object and writable by anyone who can write it, so it carries hints and correlation IDs — never a secret, and never an authorization claim the application later trusts.

Pitfall. --content-type is attacker-influenced on any upload path the client controls, and browsers act on it. An object stored as text/html and served from a bucket a user can reach executes as script in your domain's context. Set the content type server-side from a validated allowlist rather than accepting what the uploader declared (§11.22).

11.4 Storage Classes §

Storage class trades access cost against minimum storage duration and retrieval cost; it does not change durability, which is uniform across classes.

ClassMinimum storage duration
Standardnone
Nearline30 days
Coldline90 days
Archive365 days

Deleting or moving an object out of a class before its minimum duration elapses bills the remainder as an early-deletion charge. Autoclass automates the choice: new objects start Standard, cool to Nearline after 30 days of no access, and — if configured with Archive as the terminal class — continue to Coldline and Archive, moving back to Standard immediately on any access.

gcloud storage buckets update gs://rc-saas-prod-uploads-01 \
  --enable-autoclass \
  --autoclass-terminal-storage-class=ARCHIVE

Autoclass and lifecycle rules that set storage class (§11.14) are mutually exclusive management strategies for the same bucket; pick one.

11.5 Regional Storage §

A region bucket stores redundant copies across the availability zones of one geographic place. The three location types trade cost, availability, and residency precision:

Location typeRedundancyResidency precision
Region (§11.5)Across zones in one regionExact — one named region
Dual-region (§11.6)Across two named regionsExact — both regions named
Multi-region (§11.7)Across a continent-scale areaImprecise — Google places replicas within the area

Regional is the default choice: cheapest, lowest write latency, and the simplest residency story, because every replica stays inside one named jurisdiction.

gcloud storage buckets create gs://rc-saas-prod-data-01 \
  --project=rc-saas-prod-data-01 --location=us-central1

There is no cross-region redundancy, so a regional outage is an availability event for the bucket, not merely a latency one. Use regional for data that must not leave a jurisdiction, or that is already replicated elsewhere in the architecture.

Pitfall. Location is fixed at creation. Moving a bucket to a different location means creating a new bucket, copying every object, and cutting over — and the copy re-dates the objects, resetting lifecycle age conditions (§11.14) and any retention clock that was counting. Plan location against the residency requirement before the first write.

11.6 Dual-Region Storage §

A dual-region bucket replicates across an explicit pair of regions — predefined (NAM4 for us-central1 plus us-east1) or configurable, naming both regions yourself with --placement.

gcloud storage buckets create gs://rc-saas-prod-quarantine-01 \
  --project=rc-saas-prod-app-01 \
  --location=us-central1,europe-west1 \
  --placement=us-central1,europe-west1

Turbo replication is what you are buying: a defined recovery point objective, replicating within 15 minutes, rather than best-effort replication with no time bound. That is the difference between an RPO you can put in a contract and one you can only hope about.

Choose dual-region when the business has stated an RPO and needs to name exactly which two regions hold the data. The residency story stays precise because both locations are explicit — which is why a dual-region bucket can satisfy a residency requirement that multi-region cannot.

Pitfall. Replication being asynchronous means a read immediately after a write in the second region may return the previous version or a 404. Applications that write in one region and read in the other need to tolerate that window, or read from the write region until the object is confirmed.

11.7 Multi-Region Storage §

A multi-region bucket spans a continent-scale area (US, EU) for maximum availability and lets reads land close to the requester without you choosing a region.

gcloud storage buckets create gs://rc-saas-shared-log-archive-01 \
  --project=rc-saas-shared-log-01 --location=US

The trade is residency precision. Google places replicas anywhere inside the named area, so "multi-region US" is not a statement about which states hold the data. Treat multi-region as an availability and read-locality decision, never a residency control.

Where residency is a requirement, use regional (§11.5) or named dual-region (§11.6), and enforce the choice with constraints/gcp.resourceLocations (§2.30) so a new bucket cannot be created in a disallowed location by anyone, including automation.

Pitfall. US and EU are the wrong default for a bucket holding personal data of EU residents even though EU sounds compliant: the guarantee is "within the area," and whether that satisfies a specific regulator is a legal question, not a product one. Get the residency requirement stated precisely before choosing the location, because it cannot be changed afterward (§11.5).

11.8 Bucket IAM §

IAM is the only access-control mechanism this book uses on Cloud Storage; ACLs exist but are disabled the moment uniform bucket-level access is enabled (§11.9), which retires the legacy bucket and object roles (Browser and the legacy owner/reader/writer roles) along with them. Grant at the bucket, never the project, unless every bucket in the project genuinely shares the same readers and writers.

gcloud storage buckets add-iam-policy-binding gs://rc-saas-prod-uploads-01 \
  --member="serviceAccount:sa-app-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role="roles/storage.objectUser"

The predefined roles this book uses: roles/storage.objectViewer (read only), roles/storage.objectCreator (write, no read or delete — the correct grant for an upload-only client), roles/storage.objectUser (read and write, no bucket administration), and roles/storage.objectAdmin and roles/storage.admin for operators who manage the bucket itself. IAM conditions (Chapter 3) narrow any of these by prefix or by request time, which is the closest Cloud Storage comes to path-scoped access:

cat > condition-tenant-42.yaml << 'EOF'
expression: resource.name.startsWith(
  "projects/_/buckets/rc-saas-prod-uploads-01/objects/tenant-42/")
title: tenant-42-prefix
EOF
gcloud storage buckets add-iam-policy-binding gs://rc-saas-prod-uploads-01 \
  --member="serviceAccount:sa-app-prod@rc-saas-prod-app-01.iam.gserviceaccount.com" \
  --role="roles/storage.objectUser" \
  --condition-from-file=condition-tenant-42.yaml

Pitfall. storage.objects.list returns names for the whole bucket regardless of a prefix condition on get; a client can enumerate other tenants' object names even when it cannot read their content. Treat object names themselves as data your tenants should not see, or split tenants across buckets.

11.9 Uniform Bucket-Level Access §

Uniform bucket-level access (UBLA) makes IAM the bucket's only access-control mechanism: with it enabled, requests to set, read, or modify bucket or object ACLs fail with a 400 error, and the legacy bucket and object roles (roles/storage.legacyBucketOwner and its siblings) become meaningless because nothing consults them any more. Enable it at creation (§11.2) so no object ever carries a legacy ACL.

UBLA cannot be disabled after it has been active on a bucket for 90 consecutive days. Within the 90-day window it can still be turned off, which is the intended rollback path for a bucket that turns out to depend on per-object ACLs; after the window it is permanent. Enforce it organization-wide so nobody has to remember the window:

# policy-ubla.yaml
name: organizations/123456789012/policies/storage.uniformBucketLevelAccess
spec:
  rules:
    - enforce: true
gcloud org-policies set-policy policy-ubla.yaml

The constraint's evaluation and inheritance mechanics are §2.30; a project that needs a documented, time-boxed exception during migration is the only place to override it, and the override itself should expire.

11.10 Public Access Prevention §

Public access prevention (PAP) blocks a bucket or its objects from ever being made public, regardless of how a binding is attempted — IAM, an ACL if UBLA is off, or a resource-level exception. It has two states: inherited, meaning the bucket takes whatever the organization policy above it says, and enforced, set explicitly on the bucket and immune to a looser policy above it later. enforced is the correct state for every bucket in this book except the one this book does not have — nothing here binds a public principal.

gcloud storage buckets update gs://rc-saas-prod-uploads-01 \
  --public-access-prevention

Enforce it as the organization default and let individual buckets opt in to inherited only with a documented reason, never the reverse:

# policy-pap.yaml
name: organizations/123456789012/policies/storage.publicAccessPrevention
spec:
  rules:
    - enforce: true
gcloud org-policies set-policy policy-pap.yaml

A bucket that must serve static content publicly is a Chapter 10 delivery pattern (Cloud CDN and a load balancer in front of a private bucket), not a publicly bound Cloud Storage bucket.

11.11 Signed URLs §

A signed URL grants time-limited, credential-free access to one object, up to a maximum of 604,800 seconds (7 days), using the V4 signing process. Anyone holding the URL can use it for its entire lifetime: a signed URL bypasses IAM — it is not re-checked against the bearer's identity, so possession is the only control, and revocation before expiry means deleting or re-versioning the underlying object rather than editing the URL.

Never sign with a downloaded key. The keyless path impersonates a service account and lets Google hold the signing key:

gcloud storage sign-url gs://rc-saas-prod-uploads-01/reports/report.pdf \
  --impersonate-service-account=sa-app-prod@rc-saas-prod-app-01.iam.gserviceaccount.com \
  --duration=15m \
  --http-verb=GET

The caller needs roles/iam.serviceAccountTokenCreator on sa-app-prod@rc-saas-prod-app-01.iam.gserviceaccount.com and the impersonated account needs iam.serviceAccounts.signBlob, which the predefined role already carries — no separate grant, no key file on disk, and the impersonation is audit-logged against both identities (§3.24).

Pitfall. A 7-day signed URL for a report that is regenerated daily is a 7-day-old credential still walking around after the report changed. Keep signed-URL duration close to the actual task duration, not the maximum.

11.12 Signed Policy Documents §

A signed policy document (POST policy) authorizes a browser to upload directly to Cloud Storage through an HTML form, without ever handing the browser a credential. Where a signed URL authorizes one request against one existing object, a policy document authorizes a class of future uploads and is enforced by Cloud Storage against the form fields the browser actually submits.

The policy is a Base64-encoded JSON document with a list of conditions, signed the same way as a signed URL (service account impersonation, never a downloaded key). The conditions that matter for security are:

  • an exact-match condition pinning key or Content-Type to one value;
  • a starts-with condition constraining key to a prefix (["starts-with", "$key", "tenant-42/uploads/"]);
  • a content-length-range condition bounding the upload size (["content-length-range", 0, 10485760]).

There is no gcloud storage subcommand for generating a policy document; the upload service builds and signs it with a client library at request time, using the same service account and iam.serviceAccounts.signBlob permission as §11.11. The application, not Cloud Storage, decides content type, size limit, and key prefix per request — §11.22 shows the surrounding upload pattern.

11.13 Object Versioning §

Versioning preserves every replaced or deleted object as a noncurrent version instead of discarding it. Without it, a bucket loses the previous bytes the instant an object is overwritten or deleted — and because every Cloud Storage write is a full replacement (§11.1), that is a total loss, not a partial one.

gcloud storage buckets update gs://rc-saas-prod-data-01 --versioning

Versioning has no expiry of its own. Every noncurrent version is billed storage forever unless a lifecycle rule removes it (§11.14), typically on numNewerVersions or daysSinceNoncurrentTime. Enabling versioning without a matching lifecycle rule is the single most common Cloud Storage cost surprise in a bucket that churns.

Versioning is the control that survives a hostile delete, which soft delete (§11.17) does not: an attacker with storage.objects.delete removes the current version, and the prior generation remains until a lifecycle rule or an explicit generation-scoped delete removes it. That is why a bucket holding anything you would need after a compromise gets versioning, not just soft delete.

Pitfall. Deleting an object in a versioned bucket does not free its storage, so a team "cleaning up" to reduce cost sees the bill unchanged and concludes the cleanup failed. Only a generation-scoped delete or a lifecycle rule removes noncurrent versions.

11.14 Lifecycle Rules §

A lifecycle rule pairs one action with one or more conditions; an object must match every condition in a rule for the action to fire. Conditions include age, createdBefore, numNewerVersions, daysSinceNoncurrentTime, daysSinceCustomTime, and matchesStorageClass; actions are Delete, SetStorageClass, and AbortIncompleteMultipartUpload.

{
  "rule": [
    {
      "action": {"type": "SetStorageClass", "storageClass": "NEARLINE"},
      "condition": {"age": 30, "matchesStorageClass": ["STANDARD"]}
    },
    {
      "action": {"type": "SetStorageClass", "storageClass": "COLDLINE"},
      "condition": {"age": 90, "matchesStorageClass": ["NEARLINE"]}
    },
    {
      "action": {"type": "Delete"},
      "condition": {"daysSinceNoncurrentTime": 30, "numNewerVersions": 3}
    },
    {
      "action": {"type": "AbortIncompleteMultipartUpload"},
      "condition": {"age": 7}
    }
  ]
}
gcloud storage buckets update gs://rc-saas-prod-data-01 \
  --lifecycle-file=lifecycle.json

A Delete action honors soft delete (§11.17): the object survives the soft-delete window even after the lifecycle rule removes it as current. In Terraform, each rule is a lifecycle_rule block with nested condition and action:

resource "google_storage_bucket" "data" {
  name     = "rc-saas-prod-data-01"
  project  = "rc-saas-prod-data-01"
  location = "us-central1"

  lifecycle_rule {
    condition {
      age                   = 30
      matches_storage_class = ["STANDARD"]
    }
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
  }
}

Pitfall. A rule with no matchesStorageClass condition re-evaluates every object on every scan; an unbounded SetStorageClass rule can cycle an object that a separate process just moved back to Standard, generating storage-class churn and early-deletion fees.

11.15 Retention Policies §

A bucket-level retention policy sets a minimum age, in seconds, that every object in the bucket must reach before it can be deleted or overwritten; it protects the whole bucket uniformly and is the basis for Bucket Lock (§11.16).

gcloud storage buckets update gs://rc-saas-shared-log-archive-01 \
  --retention-period=94608000

Two finer-grained mechanisms sit alongside it. Holds — event-based and temporary — are placed on individual objects; while either is set, the object cannot be deleted or replaced, though its metadata can still be edited. Releasing a temporary hold has no effect on the object's retain-until time; releasing an event-based hold resets the object's time in the bucket for retention purposes, which is what makes it suit "retain until this order closes" workflows rather than a fixed clock. Separately, Object Retention Lock sets a per-object retention configuration directly, in Unlocked mode (authorized users can shorten or remove it) or Locked mode (the retain-until date can only be extended, never reduced or removed) — a finer, per-object alternative to the bucket-wide policy that Bucket Lock hardens.

gcloud storage objects update gs://rc-saas-shared-log-archive-01/audit/2026-08.log \
  --retention-mode=locked \
  --retain-until=2029-08-31T00:00:00Z

Enforce a minimum organization-wide with constraints/storage.retentionPolicySeconds, citing §2.30 for how the constraint is evaluated and inherited.

11.16 Bucket Lock §

Bucket Lock makes a bucket's retention policy (§11.15) permanent. Once locked, the retention period can be increased but never decreased or removed, and the bucket cannot be deleted while it holds any object still inside its retention window.

gcloud storage buckets update gs://rc-saas-shared-log-archive-01 \
  --lock-retention-period

Locking is irreversible, and that is the entire point — it is what converts a retention policy from a control your own administrators can undo into one they cannot, which is what a WORM requirement actually asks for. A retention policy without the lock protects against accident; the lock protects against an insider and against an attacker who reaches the control plane.

Lock only after the retention period has run in production for at least one full period. A locked value that is too short defeats the compliance purpose, and one that is too long cannot be reduced even to correct a typo — you would be storing, and paying for, the mistake for its full duration.

Pitfall. The bucket cannot be deleted while any object is still in its retention window, which also means the project cannot be cleanly deleted. A locked bucket created during a proof of concept with a ten-year retention period keeps its project alive for ten years. Never test Bucket Lock outside a sandbox, and never with a realistic duration.

11.17 Soft Delete §

Soft delete keeps a deleted or overwritten object recoverable for a retention window, defaulting to 7 days on every bucket that supports it and adjustable from 7 to 90 days; it is on by default for new buckets, which means an accidental rm or overwrite is recoverable unless someone has turned it off.

gcloud storage buckets update gs://rc-saas-prod-uploads-01 \
  --soft-delete-duration=30d

Soft delete is not a substitute for versioning (§11.13) or a retention policy (§11.15): it protects against accidental deletion and overwrite for a fixed short window, not against a hostile actor with delete permission who waits it out, and it does not preserve every historical version the way versioning does. Enforce a floor with constraints/storage.softDeletePolicySeconds (§2.30) so a workload cannot quietly disable it.

11.18 Encryption §

Every object is encrypted at rest with AES-256, with no configuration required — there is no "unencrypted" state for an object's bytes on disk. The only question is who controls the key.

Key modelWho holds itUse when
Google-managed (default)Google, rotated on Google's scheduleThe overwhelming majority of buckets
CMEK (§11.19)You, in Cloud KMSYou must revoke, rotate, or destroy the key independently of the data
CSEK (§14.9)You, supplied per requestAn existing tested external key lifecycle demands it

"Encrypted at rest" is not a meaningful control on its own, because it is always true and applies equally to an attacker reading the object through the API with valid credentials. Encryption at rest defends against physical media compromise. It does nothing about IAM, which is why §11.8 and §11.10 do the real work in this chapter.

CMEK's value is administrative separation, not stronger cryptography. The bytes are protected identically; what changes is that revoking or destroying the key (§14.17) makes the data unrecoverable independently of whoever controls the bucket. Chapter 14 owns what a key is and how it behaves; §11.19 shows only the wiring.

11.19 Customer-Managed Encryption Keys §

Setting a default Cloud KMS key on a bucket makes every object written after that point use it; objects written before the key was set keep whatever key they already had, so setting CMEK does not retroactively re-encrypt existing data.

KMS_KEY="projects/rc-saas-shared-sec-01/locations/us-central1/keyRings/\
kr-us-central1-storage/cryptoKeys/k-storage-data"
gcloud storage buckets update gs://rc-saas-prod-data-01 \
  --default-encryption-key="${KMS_KEY}"

Cloud Storage acts through its service agent, service-PROJECT_NUMBER@gs-project-accounts.iam.gserviceaccount.com, which must hold roles/cloudkms.cryptoKeyEncrypterDecrypter on that key before any write succeeds:

gcloud kms keys add-iam-policy-binding k-storage-data \
  --keyring=kr-us-central1-storage --location=us-central1 \
  --project=rc-saas-shared-sec-01 \
  --member="serviceAccount:service-PROJECT_NUMBER@gs-project-accounts.iam.gserviceaccount.com" \
  --role="roles/cloudkms.cryptoKeyEncrypterDecrypter"

What the key ring is, how rotation and protection level work, and how crypto-shredding revokes access to already-written data are §14.8 and §14.17. Cloud Storage also supports customer-supplied encryption keys, where you present the key material on every request instead of storing it in Cloud KMS; the mechanics and trade-offs of that model are §14.9. Enforce CMEK-only writes across CMEK-eligible services with constraints/gcp.restrictNonCmekServices (§2.30).

11.20 Logging §

Cloud Storage exposes two distinct logs with different purposes. Cloud Audit Logs record control-plane and data-plane API calls within seconds, in the structured format the rest of the platform uses; Admin Activity entries are on by default and free, but Data Access audit logs are disabled by default and are not written unless explicitly enabled — for a bucket handling regulated data, enabling Data Access logging on the storage.googleapis.com service is the only way to see who read which object.

Data Access logging is part of the IAM policy's auditConfigs, not an organization policy constraint, so it is read back from the policy itself:

gcloud projects get-iam-policy rc-saas-prod-data-01 \
  --format="value(auditConfigs)"

Storage logs (usage logs) are the older, separate mechanism: generated daily, describing storage consumption rather than individual access events, and configured with --log-bucket and --log-object-prefix on the source bucket. They answer "how much did this bucket cost," not "who touched this object" — for the latter, enable Data Access audit logs and route them to the centralized logging project (§2.22).

gcloud storage buckets update gs://rc-saas-prod-uploads-01 \
  --log-bucket=rc-saas-shared-log-archive-01 \
  --log-object-prefix=uploads-usage

11.21 Auditability §

Beyond per-request logging, two mechanisms answer fleet-scale questions.

Storage Insights inventory reports generate a periodic — daily or weekly — CSV or Apache Parquet summary of every object's metadata in a bucket: size, storage class, encryption state, retention configuration. This is the practical way to answer "which objects are not under CMEK" or "which buckets hold objects past their retention date" without walking every object interactively. Configuring a report source requires roles/storage.insightsCollectorService on the destination bucket's writer.

Data Access audit logs (§11.20) answer the per-request question — who read which object, when — and are off by default.

The two answer different questions and neither substitutes for the other: an inventory report describes state, an audit log describes events. A compliance question about what you currently hold needs the first; an investigation into what someone did needs the second, and it only has an answer if the logs were already enabled before the incident.

Neither inspects object content. Where the question is whether an object contains sensitive data — a card number, an identifier that should have been tokenized — that is discovery and classification, owned by Chapter 15: §15.1 for discovery scans and §15.4 for configuring a bucket as a scan target.

Pitfall. Inventory reports are generated on a schedule, so they describe the bucket as of the last run, not as of now. An object created and deleted between runs never appears in any report — which is exactly the pattern of a staging area used for exfiltration. Pair inventory with Data Access logs where that matters.

11.22 Secure File Uploads §

Untrusted client uploads are the section of this chapter most likely to be built wrong, because the naive approach — the client sends the file to your application, which forwards it to Cloud Storage — puts your application on the data path for every byte a user uploads. The pattern this book uses keeps the client from ever holding a Google credential and keeps unscanned content out of any bucket another service reads from.

  1. The client asks your application for permission to upload one object. Your application, holding no long-lived key, impersonates sa-uploader-prod@rc-saas-prod-app-01.iam.gserviceaccount.com and returns either a short-duration signed URL (§11.11, for a single known object name) or a signed policy document (§11.12, for a browser form with a size and content-type constraint baked into the policy).
  2. The upload target is a quarantine bucket, rc-saas-prod-quarantine-01, never the bucket your application or any other service reads from. The policy document's content-length-range and content-type conditions reject oversized or wrong-typed uploads before Cloud Storage accepts a single byte.
  3. A scanning pipeline, triggered on object finalize, promotes the object — copies it to rc-saas-prod-uploads-01 — only after it passes malware and content scanning. Nothing downstream ever reads from the quarantine bucket directly.
  4. The quarantine bucket has public access prevention enforced and uniform bucket-level access on, exactly as every other bucket in the estate, because a bucket a browser writes to directly is the bucket most likely to be probed for a public write path; a policy document only restricts what Cloud Storage's own service accepts, not what an attacker who bypasses it entirely might try.
resource "google_storage_bucket" "quarantine" {
  name                        = "rc-saas-prod-quarantine-01"
  project                     = "rc-saas-prod-app-01"
  location                    = "us-central1"
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"
  soft_delete_policy {
    retention_duration_seconds = 604800
  }

  lifecycle_rule {
    condition {
      age = 2
    }
    action {
      type = "Delete"
    }
  }
}

The short lifecycle rule bounds how long an unscanned or rejected upload lingers if the scanning pipeline fails to act on it.

11.23 SaaS Object Storage Patterns §

rc-saas uses a small, repeated set of bucket roles rather than one bucket per purpose per tenant:

  • rc-saas-prod-uploads-01 — promoted, scanned tenant content, read by the application with roles/storage.objectUser scoped by an object-name-prefix condition (§11.8) per tenant.
  • rc-saas-prod-quarantine-01 — the landing zone for direct uploads (§11.22), written only through signed URLs or policy documents, read only by the scanning service account.
  • rc-saas-shared-log-archive-01 — multi-region (§11.7), Coldline default class, retention-locked (§11.16), holding exported logs and compliance archives from every environment; write access limited to the logging pipeline's service account, never to a human principal.
  • A per-environment data bucket, rc-saas-<env>-data-01, regional (§11.5) in the environment's primary region, CMEK-wired to the environment's own key (§11.19), holding generated reports and exports that a customer downloads through a signed URL rather than a public link.

The pattern that recurs across all four: uploads and downloads never touch a long-lived credential, every bucket enforces UBLA and public access prevention regardless of whether it is ever expected to need a public binding, and CMEK plus retention are set at bucket creation — bolting them on later leaves a gap of unprotected objects that predates the change.

11.24 Enterprise Archival Patterns §

rc-ent layers a longer retention horizon and stricter placement on the same primitives. Regulatory archives use dual-region (§11.6) between us-east4 and a second named region so the recovery point objective is explicit in the architecture diagram, not left to a continent-wide multi-region placement. Every archival bucket is retention-policy locked (§11.16) at creation, with the retention period matched to the longest regulatory hold across the business units the folder structure separates, and CMEK keys for archival buckets live in their own key ring, separate from the operational data key ring, so a key rotation schedule for live data never touches archived evidence.

Object Retention Lock (§11.15) rather than bucket-level retention suits an archive that mixes hold periods within one bucket — a mix that a single bucket-wide retention period cannot express — at the cost of managing retention per object instead of once per bucket. rc-ent uses bucket-level Bucket Lock for its uniform statutory-retention buckets and per-object retention lock only where mixed retention periods within one bucket are a genuine business requirement, because a single bucket-wide setting is easier to audit than thousands of independent per-object settings.

Chapter Summary §

  • Bucket names are global; the book's rc-saas-<env>-<purpose>-<nn> convention keeps collisions confined to the estate's own naming, not the whole platform.
  • Uniform bucket-level access retires ACLs and the legacy bucket/object roles; it can be disabled only within its first 90 days, after which it is permanent.
  • Public access prevention distinguishes inherited (takes the org-policy default) from enforced (locked on the bucket regardless of policy above it).
  • Region, dual-region, and multi-region are one decision axis: residency simplicity, a named RPO with turbo replication, or continent-wide availability at the cost of an undefined placement within the area.
  • Signed URLs bypass IAM for their lifetime and expire at a maximum of 7 days; generate them by impersonating a service account, never with a downloaded key.
  • Signed policy documents authorize a class of browser uploads with exact, starts-with, and content-length-range conditions, distinct from a signed URL's single-object scope.
  • Storage classes carry minimum storage durations — 30, 90, and 365 days for Nearline, Coldline, and Archive — that an early lifecycle transition or deletion bills against.
  • Lifecycle rules require every listed condition to match; soft delete protects against accidental loss for 7–90 days but is not a substitute for versioning or a retention policy.
  • Event-based and temporary holds protect individual objects from deletion; releasing an event-based hold resets the retention clock, releasing a temporary hold does not.
  • Bucket Lock makes a retention policy irreversible; Object Retention Lock does the same per object, for buckets that mix retention periods.
  • Every object is encrypted at rest by default with a Google-managed key; CMEK wiring only changes which key is used going forward, never retroactively.
  • Data Access audit logs for Cloud Storage are off by default and must be enabled explicitly to see who read or wrote which object.
  • Untrusted uploads belong in a dedicated quarantine bucket, reached only through a signed URL or policy document, and promoted only after scanning.
  • Object name prefixes are not an access boundary by themselves; back tenant separation with an IAM condition or a dedicated bucket.

Security Checklist §

ControlWhy it mattersHow to verify (CLI + Console)
constraints/storage.uniformBucketLevelAccess enforced org-wideRemoves ACLs as a parallel, unaudited access pathgcloud org-policies describe constraints/storage.uniformBucketLevelAccess --organization=123456789012 --effective
constraints/storage.publicAccessPrevention enforced org-wideBlocks any accidental public binding regardless of mechanismgcloud org-policies describe constraints/storage.publicAccessPrevention --organization=123456789012 --effective
No bucket relies on legacy bucket/object rolesThey bypass the IAM condition and audit modelgcloud storage buckets get-iam-policy gs://BUCKET --format="value(bindings.role)" and check for legacy
Signed URL duration matches task duration, not the 7-day maximumA long-lived signed URL is a long-lived bearer credentialreview application code issuing gcloud storage sign-url / client-library equivalents
Signing uses service account impersonation, never a downloaded keyRemoves a static credential capable of signing arbitrarilygcloud iam service-accounts keys list --iam-account=SA_EMAIL --managed-by=user returns nothing
Upload-facing buckets enforce PAP and UBLA regardless of expected useThe bucket most exposed to probing is the one a browser writes togcloud storage buckets describe gs://BUCKET --format="value(iamConfiguration)"
Quarantine bucket is distinct from the bucket applications read fromPrevents unscanned content from reaching production consumersreview bucket inventory against the upload pipeline design (§11.22)
Data Access audit logs enabled for storage.googleapis.com on regulated bucketsOff by default; without it there is no per-object read/write trailgcloud projects get-iam-policy PROJECT_ID --format="value(auditConfigs)"
Retention policy locked (Bucket Lock) on every compliance-archival bucketAn unlocked policy can be shortened or removed by an administratorgcloud storage buckets describe gs://BUCKET --format="value(retentionPolicy)"
CMEK service agent holds roles/cloudkms.cryptoKeyEncrypterDecrypter only on its own keyOver-broad key access widens the blast radius of a compromised bucketgcloud kms keys get-iam-policy KEY --keyring=RING --location=REGION
constraints/gcp.restrictNonCmekServices applied where CMEK is mandatedPrevents a bucket from being created with only the default keygcloud org-policies describe constraints/gcp.restrictNonCmekServices --organization=123456789012 --effective
Soft delete duration meets or exceeds the organization floorProtects against accidental overwrite or deletiongcloud storage buckets describe gs://BUCKET --format="value(softDeletePolicy)"

Sources §