Chapter 15·Part V · Reflection
The Access and Permissions Model
A GA4 property is not an island. To audit it programmatically, to write configuration changes into it, and to link it to adjacent Google products, a practitioner must first reach it through an authenticated identity that holds the right roles and carries a token minted with the right scopes. Most of this chapter's material is invisible in the finished analytics setup. It sits underneath the work: the identity that ran the audit, the token that authorized each API call, the role that permitted one PATCH and forbade another, and the security decisions that governed how much power the automation identity was allowed to hold. Chapter 4 (Auditing Methodology: Programmatic and Adversarial) described what the audit read and how it reasoned about the findings. This chapter describes the substrate that made those reads possible, and it draws a boundary that the practitioner should treat as a security invariant: the automation identity earns exactly the privileges it needs to do configuration work, and no more, even when a broader grant would have removed a moment of friction.
The case that anchors this book is instructive precisely because it hit the ceiling. The automation identity did most of the reconstruction on its own, then ran into a hard permission wall at the BigQuery link that no amount of self-service escalation should have been allowed to climb over. That wall is the pedagogical heart of the chapter. It shows where the least-privilege line falls in practice, and why the correct response to a 403 is sometimes to hand the step to a human owner rather than to widen the automation's grant.
Two APIs, One Property
GA4 exposes two distinct programmatic surfaces, and conflating them is a common early error. The Admin API (analyticsadmin.googleapis.com) reads and writes the property's configuration: the property object itself, its data streams, its custom dimensions and metrics, its key events, its data-retention settings, its Enhanced Measurement settings, its Google Signals settings, its product links, and its access bindings. The Data API (analyticsdata.googleapis.com) reads reporting data: the events, users, and sessions that flow through the pipeline, queried through runReport and its relatives. The Admin API answers "how is this property built"; the Data API answers "what happened inside it."
The audit used both. The programmatic inventory in Chapter 4 read accountSummaries, the property resource, dataStreams, customDimensions, keyEvents, dataRetentionSettings, enhancedMeasurementSettings, and googleSignalsSettings through the Admin API. It then pulled the thirty-day traffic-source and event totals through the Data API to establish the baseline (2,328 events, 401 active users, 527 sessions, and the channel mix that revealed a live google/cpc spend against an unlinked Google Ads account). The reconstruction in Phase 0 and Phase 1 wrote back through the Admin API: it patched dataRetentionSettings from TWO_MONTHS to FOURTEEN_MONTHS, deleted the cta_click key event, registered the service_type, cta_label, and error_message custom dimensions, and promoted generate_lead to a key event. Every one of those operations targeted a different Admin API resource, and each resource carries its own permission expectations.
Impersonation, Not a Key File
The identity that ran all of this was a Google Cloud service account, id-01-{company}@gen-lang-client-0444184725.iam.gserviceaccount.com, living in the same project that owns the GA4 property, gen-lang-client-0444184725. The practitioner did not reach it with a downloaded JSON key. The access token was minted by impersonation:
gcloud auth print-access-token \
--impersonate-service-account=id-01-{company}@gen-lang-client-0444184725.iam.gserviceaccount.com \
--scopes=https://www.googleapis.com/auth/analytics.edit
This command returns a short-lived OAuth 2.0 access token, valid for roughly an hour, that the calling identity is authorized to obtain because it holds the Service Account Token Creator role on the target service account. The token bearer then attaches it as a Bearer credential on each Admin or Data API request. No private key material ever touches the practitioner's disk.
The security argument for impersonation over a downloaded key is direct, and it is worth stating in mechanistic terms rather than as a platitude. A downloaded service-account key is a long-lived credential: a JSON file containing a private key that authenticates as the service account until someone explicitly revokes or rotates it. It is a bearer secret that can be copied, committed to a repository by accident, captured from a laptop backup, or left in a shell history, and any holder of the file wields the full authority of the service account for as long as the key lives. Impersonation removes the persistent secret from the equation. The credential that actually authorizes an API call expires within the hour, so the blast radius of a leaked token is bounded by its lifetime rather than by an administrator's diligence in rotating a file. The durable trust lives in an IAM binding (who may impersonate whom) that is centrally visible, auditable in Cloud Logging, and revocable in one place, instead of in an artifact scattered across developer machines. For an automation identity that will run repeatedly against a production property, this difference is the whole ballgame: the practitioner keeps the ability to act while giving up the standing liability of a key that outlives its usefulness.
There is a second, quieter benefit. Because the token is requested per invocation with an explicit --scopes flag, the practitioner declares the scope at mint time and can mint a narrower token for a read than for a write. A downloaded key, by contrast, is scope-agnostic at rest; the scope gets decided later in the client library, which makes it easier to over-broaden by habit. Impersonation pushes the scope decision to the surface where a reviewer can see it.
The Scope Model
OAuth scopes and IAM roles are two independent gates, and a request must pass both. The role answers "is this identity allowed to touch this resource at all"; the scope answers "was this particular token minted with permission to exercise this class of operation." A request can fail on either. The distinction matters because the failure modes read differently, and a practitioner who cannot tell a role failure from a scope failure will waste time granting roles that were never the problem.
Three scopes governed this project:
https://www.googleapis.com/auth/analytics.editauthorized every configuration write and the corresponding reads on the Admin API. The retention PATCH, the key-event deletion, the custom-dimension registrations, and thegenerate_leadpromotion all rode on a token minted with this scope.https://www.googleapis.com/auth/analytics.readonlyauthorized the Data API reporting reads that established the baseline. A read-only scope is the correct minimum for pulling reports; it cannot mutate configuration even if the underlying role could.https://www.googleapis.com/auth/analytics.manage.users.readonlyauthorized reading the property'saccessBindings, the list of who holds which GA4 role on the property.
That third scope is the one that catches practitioners off guard, and the case demonstrated the trap directly. Reading accessBindings is not covered by analytics.edit, even though editing configuration feels like a broader power than listing users. GA4 treats user-and-access management as a separate, more sensitive scope family than configuration management, on the reasoning that the roster of who can touch a property is itself governance-critical data. A token minted with analytics.edit and pointed at the accessBindings endpoint returns:
ACCESS_TOKEN_SCOPE_INSUFFICIENT
The remedy is not a role change and not a retry. It is to mint a fresh token with the analytics.manage.users.readonly scope added, then repeat the exact same request. The endpoint that needs the broader scope will accept the broader token and return the bindings. The lesson generalizes: ACCESS_TOKEN_SCOPE_INSUFFICIENT is never a signal to widen a role, because the identity may already hold ample role authority. It is a signal that the token in hand was declared too narrowly at mint time. Read the scope error as a statement about the token, not about the identity.
In practice this meant the audit minted more than one token during a single run. The reporting reads went out under an analytics.readonly token, the configuration reads and writes under an analytics.edit token, and the accessBindings read under an analytics.manage.users.readonly token, each obtained by re-running gcloud auth print-access-token with a different --scopes value against the same service account. Minting three narrow tokens rather than one maximal token costs the practitioner nothing at runtime (each mint is a sub-second call) and buys a real property: no single token in the working set carried more authority than the operation it was about to perform. If any one of those tokens had leaked from a shell or a log, its holder would have inherited only the slice of power that token was scoped to, and only for the hour before it expired. That is defense in depth applied to credentials, and impersonation is what makes it cheap enough to bother with, because there is no per-scope key file to manage, only a per-scope flag on a command that already runs.
The Role Model
Scopes decide what class of operation a token may attempt. GA4 property roles decide what the underlying identity is permitted to do once the token clears the scope gate. The reconstruction lived almost entirely inside the Editor role, and the boundary where Editor stopped is exactly the boundary where the project needed a human owner.
Editor on a GA4 property can create and modify the configuration objects that constitute the day-to-day work of a measurement build. In this case that covered creating custom dimensions (service_type, cta_label, error_message), creating and deleting key events (the generate_lead promotion, the cta_click deletion), changing data-retention settings (the TWO_MONTHS to FOURTEEN_MONTHS PATCH), and adjusting Enhanced Measurement settings (switching off pageChangesEnabled and formInteractionsEnabled to stop the double-fired page_view and the collision with the site's own form events). The great majority of Phase 0 and Phase 1 required nothing more than Editor.
Administrator sits above Editor and holds powers Editor does not, and the cleanest of these is user management: Administrator can create, modify, and delete the accessBindings that Editor can, at most, read. That boundary is unambiguous and it holds throughout this chapter. Product links are the muddier case, and the muddiness is the point. Google's role matrix files product-link management under Administrator, so the reflex is to treat the BigQuery export link as an Administrator-only action. The link's own setup documentation says otherwise: creating it needs Editor or above on the Analytics property plus Owner on the destination BigQuery project. The binding constraint was never the GA4 role. Editor would have carried enough Analytics-side standing; what the link demanded was project-side authority in Google Cloud that no GA4 role confers. The service account in this project held predefinedRoles/admin, more Analytics standing than the link needed, and the link still could not be created through the API. What happened next is the chapter's central object lesson, and it lives entirely on the Cloud-project side of the boundary.
The BigQuery-Link Permission Wall
Chapter 12 (Warehousing and Reporting) covers why the BigQuery export matters for this property: it lifts the fourteen-month retention ceiling on custom-dimension-scoped analysis into effectively permanent event-level history, at zero cost given the property's roughly 5 MB of monthly event data against BigQuery's 10 GB storage and 1 TB query free tiers. This chapter covers why the automation could not create that link, and why that failure was correct rather than a misconfiguration to be fixed.
The practitioner called bigQueryLinks.create on the Admin API with the service account that already held GA4 Administrator. The call returned:
HTTP 403: The caller does not have permission
The reflexive response to a 403 is to grant more permission, so the practitioner did: the service account received the project role roles/bigquery.admin, full administrative authority over BigQuery within gen-lang-client-0444184725. The call was repeated. It returned the same 403.
The reason the second grant did not help is the crux, and it rewards close reading. Creating a GA4-to-BigQuery link is not a single write into the GA4 property. It is a compound operation: GA4 must provision its own managed export service account with project-level IAM in the destination Google Cloud project, so that the export pipeline can write event tables into BigQuery on a daily schedule. Granting project-level IAM to another principal is a project-IAM-administration action. It requires the caller to hold the power to modify the project's IAM policy (for instance roles/resourcemanager.projectIamAdmin or Owner), which is categorically distinct from roles/bigquery.admin. Being an administrator of BigQuery lets you manage datasets, tables, and jobs; it does not let you rewrite who has IAM on the project. The GA4 Administrator role, likewise, lets you request a product link but does not carry the project-IAM-admin authority the link's provisioning step demands. The service account fell into the gap between "can administer BigQuery" and "can grant IAM to a new principal at the project level," and no combination of GA4 Administrator plus roles/bigquery.admin bridges it.
The practitioner could have bridged it. Granting the service account roles/resourcemanager.projectIamAdmin, or Owner, would very likely have let bigQueryLinks.create succeed, because the automation would then have held the power to provision GA4's export service account itself. The practitioner declined, and the reasoning is the security thesis of this chapter. Project-IAM-admin is the authority to hand any principal any role on the project, including the authority to escalate itself further. Vesting that in a headless automation identity, permanently, to smooth a one-time convenience, inverts the cost-benefit of least privilege. The link needs to be created once. Its provisioning step needs project-IAM-admin exactly once. A standing grant of the most dangerous project role to a non-human identity, in exchange for saving a human a single click, is a bargain no security review should accept.
So the owner created the link in the GA4 UI: project gen-lang-client-0444184725, US location, daily frequency. The owner's human account already carried the project authority that the link's provisioning required, so the grant of IAM to GA4's export service account happened under a human's standing privilege, used once, rather than under an automation's permanent one. The Admin API then confirmed the outcome the automation was allowed to read even though it was not allowed to write: dailyExportEnabled true, streaming export off. The read stayed inside the automation's remit; the privileged write went to the human who legitimately held the privilege.
The Principle, Stated Plainly
Least privilege for an automation identity is not a slogan to recite; it is a decision procedure to apply when a 403 appears. The procedure this project followed, and the one the practitioner should adopt, runs in this order. First, distinguish the failure: ACCESS_TOKEN_SCOPE_INSUFFICIENT is a token-scope problem, so mint a narrower-or-broader token as the endpoint requires and retry, without touching roles. A plain 403 "does not have permission" is a role-or-IAM problem, so identify precisely which permission the operation demands. Second, once the missing permission is named, ask whether it is a durable need of the automation or a one-time step. A durable need (reading accessBindings across future audits, for example) justifies a scoped, standing grant. A one-time step whose missing permission is high-blast-radius (project-IAM-admin, to create a single product link) does not justify a standing grant to a headless identity; it justifies handing that single step to a human owner who already holds the privilege.
The automation identity in this project ended where it should have ended: holding GA4 Administrator and BigQuery Admin, able to read every configuration and reporting surface the audit required, able to write every custom dimension, key event, and setting the reconstruction required, and deliberately stopped short of the project-IAM-admin power that one convenience step wanted. The three product links that GA4 Administrator can request but that reach across product boundaries (BigQuery, Google Ads, Search Console) remain owner-gated by design, and that is the correct resting state. Chapter 4 showed the audit reading through this identity; Chapter 12 shows the warehouse it fed. The permission model between them is the quiet guarantee that neither the reading nor the linking ever cost the property more standing risk than the work was worth.