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/googleprovider 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.
| Class | Minimum storage duration |
|---|---|
| Standard | none |
| Nearline | 30 days |
| Coldline | 90 days |
| Archive | 365 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 type | Redundancy | Residency precision |
|---|---|---|
| Region (§11.5) | Across zones in one region | Exact — one named region |
| Dual-region (§11.6) | Across two named regions | Exact — both regions named |
| Multi-region (§11.7) | Across a continent-scale area | Imprecise — 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
keyorContent-Typeto one value; - a starts-with condition constraining
keyto 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 model | Who holds it | Use when |
|---|---|---|
| Google-managed (default) | Google, rotated on Google's schedule | The overwhelming majority of buckets |
| CMEK (§11.19) | You, in Cloud KMS | You must revoke, rotate, or destroy the key independently of the data |
| CSEK (§14.9) | You, supplied per request | An 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.
- 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.comand 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). - 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. - 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. - 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 withroles/storage.objectUserscoped 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) fromenforced(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 §
| Control | Why it matters | How to verify (CLI + Console) |
|---|---|---|
constraints/storage.uniformBucketLevelAccess enforced org-wide | Removes ACLs as a parallel, unaudited access path | gcloud org-policies describe constraints/storage.uniformBucketLevelAccess --organization=123456789012 --effective |
constraints/storage.publicAccessPrevention enforced org-wide | Blocks any accidental public binding regardless of mechanism | gcloud org-policies describe constraints/storage.publicAccessPrevention --organization=123456789012 --effective |
| No bucket relies on legacy bucket/object roles | They bypass the IAM condition and audit model | gcloud 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 maximum | A long-lived signed URL is a long-lived bearer credential | review application code issuing gcloud storage sign-url / client-library equivalents |
| Signing uses service account impersonation, never a downloaded key | Removes a static credential capable of signing arbitrarily | gcloud iam service-accounts keys list --iam-account=SA_EMAIL --managed-by=user returns nothing |
| Upload-facing buckets enforce PAP and UBLA regardless of expected use | The bucket most exposed to probing is the one a browser writes to | gcloud storage buckets describe gs://BUCKET --format="value(iamConfiguration)" |
| Quarantine bucket is distinct from the bucket applications read from | Prevents unscanned content from reaching production consumers | review bucket inventory against the upload pipeline design (§11.22) |
Data Access audit logs enabled for storage.googleapis.com on regulated buckets | Off by default; without it there is no per-object read/write trail | gcloud projects get-iam-policy PROJECT_ID --format="value(auditConfigs)" |
| Retention policy locked (Bucket Lock) on every compliance-archival bucket | An unlocked policy can be shortened or removed by an administrator | gcloud storage buckets describe gs://BUCKET --format="value(retentionPolicy)" |
CMEK service agent holds roles/cloudkms.cryptoKeyEncrypterDecrypter only on its own key | Over-broad key access widens the blast radius of a compromised bucket | gcloud kms keys get-iam-policy KEY --keyring=RING --location=REGION |
constraints/gcp.restrictNonCmekServices applied where CMEK is mandated | Prevents a bucket from being created with only the default key | gcloud org-policies describe constraints/gcp.restrictNonCmekServices --organization=123456789012 --effective |
| Soft delete duration meets or exceeds the organization floor | Protects against accidental overwrite or deletion | gcloud storage buckets describe gs://BUCKET --format="value(softDeletePolicy)" |
Sources §
- https://cloud.google.com/storage/docs/concepts-and-terminology (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/buckets (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/storage-classes (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/locations (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/uniform-bucket-level-access (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/public-access-prevention (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/access-control/signed-urls (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/authentication/signatures (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/object-versioning (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/lifecycle (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/bucket-lock (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/object-holds (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/object-lock (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/soft-delete (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/encryption/default-keys (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/encryption/using-customer-managed-keys (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/encryption/customer-supplied-keys (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/autoclass (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/access-logs (last validated 2026-09-03)
- https://cloud.google.com/logging/docs/audit/configure-data-access (last validated 2026-09-03)
- https://cloud.google.com/storage/docs/insights/using-inventory-reports (last validated 2026-09-03)