GCP Lesson 61 of 98

BigQuery Fine-Grained Security: Column-Level, Row-Level, and Data Masking

In a nutshell

Granting someone access to a BigQuery table with plain IAM is all-or-nothing: the same badge that lets an analyst read aggregate revenue also hands them every customer’s ssn, email, and raw row. Fine-grained access control fixes that by adding four locks inside the table, so a single governed copy can serve everyone at the right level of detail — no leaked PII, no per-team shadow copies drifting out of sync.

Picture your table as a bank’s central records room. IAM is the badge that gets you through the door. Once inside, four more controls decide what you can actually read:

Every read is written to a sign-in sheet (audit logs). The golden rule: these locks only ever subtract — they can never grant more than your IAM badge already allows. That single property is what makes the whole stack safe to reason about.

Level: Advanced · Time: ~30 min · You’ll need: comfort with BigQuery datasets, tables, and queries, plus IAM roles, members, and bindings.

Prerequisites: the BigQuery deep dive for datasets, tables, and query basics, and IAM fundamentals for how roles, members, and bindings compose. The IAM deep dive helps if you want to combine these controls with IAM conditions.

After this lesson you can:

BigQuery fine-grained access: a query passing through the IAM gate, policy-tag column security, dynamic masking, and row access policies to a masked, filtered, and audited result

Read left → right: a query carries the caller’s identity through the broad IAM gate, then each fine-grained layer — policy-tag column security, dynamic masking, and row access policies — can only subtract what the badge already allows, before results land masked, filtered, and written to the audit log.

Dataset-level IAM is a blunt instrument. roles/bigquery.dataViewer on a dataset is all-or-nothing: the analyst who needs aggregate revenue also gets raw ssn, email, and every customer’s row regardless of region. Compliance teams hate it, and “just make another copy with the PII stripped out” is how you end up with twelve drifting shadow tables nobody can audit.

BigQuery’s answer is a stack of fine-grained controls that layer on top of IAM without copying data: policy tags for column-level security, dynamic data masking for partial exposure, row access policies for tenant and region filtering, and authorized views, datasets, and routines for controlled cross-dataset sharing. This guide builds them in the order you should actually deploy them, ending with a governed analytics zone you can hand to auditors.

1. Understand the access-control layers and their evaluation order

Before writing a single command, get the mental model right. BigQuery evaluates access in layers, and they compose rather than override:

Layer Granularity Mechanism Failure mode if misused
IAM Project / dataset / table roles/bigquery.dataViewer, custom roles Over-broad grants, all-or-nothing
Authorized views/datasets Query surface View runs as its owner, not the caller Forgotten authorization = Access Denied
Column-level security Column Policy tags on a Data Catalog taxonomy Tag without reader role blocks the column
Data masking Column (value transform) Data policy bound to a policy tag Wrong role precedence leaks raw values
Row-level security Row Row access policies with predicates Missing GRANT TO (... ) hides all rows

The rule that trips people up: column-level security and row-level security are restrictive, not additive. A user must hold a table read role (via IAM) and the relevant fine-grained reader role (via the policy tag) and match a row access policy (or the table has none) to see a value. Any layer can subtract access; none can grant past IAM. Start broad with IAM, then carve down.

Design principle: grant table read to a wide group, then use policy tags and row policies to subtract sensitive columns and rows. This keeps the IAM surface small and pushes the nuance into governed, auditable objects.

Here is the composition made concrete for one row of customers, where email carries a masked policy tag and the table has an EU-only row policy:

Principal Table read (IAM) Fine-Grained Reader on tag In an EU row policy Result of SELECT email on an EU row
Admin (owner, no fine-grained roles) 0 rows — a row policy exists and they match none
EU analyst (masked) XXXXX@acme.com — row visible, value masked
EU fraud (cleared) jane@acme.com — raw
US analyst 0 rows — cleared on the column, but no matching row

Read every row as an AND: miss any required layer and you lose access to that value, or that row. Notice the project owner in the first line — ownership does not exempt you from row-level security, which is exactly why “my dashboard went blank after I added one policy” is such a common incident.

2. Build a Data Catalog taxonomy for column-level security

Column-level security is driven by policy tags organized in a taxonomy. The taxonomy is the classification hierarchy (“PII > high”, “PII > low”, “Financial”); the policy tag is what you attach to a column. Enforce the taxonomy so tags actually gate access.

Create the taxonomy and tags in the same region as the data (a taxonomy in us cannot tag a table in eu):

# Create the taxonomy with access control enforced
gcloud data-catalog taxonomies create \
  --location=us \
  --display-name="Data Sensitivity" \
  --description="Column classification for analytics zone" \
  --activated-policy-types=FINE_GRAINED_ACCESS_CONTROL \
  --project=acme-data-prod

# Capture the taxonomy resource ID it returns, then add policy tags
export TAXONOMY="projects/acme-data-prod/locations/us/taxonomies/1234567890123456789"

gcloud data-catalog taxonomies policy-tags create \
  --taxonomy="$TAXONOMY" \
  --location=us \
  --display-name="pii_high"

gcloud data-catalog taxonomies policy-tags create \
  --taxonomy="$TAXONOMY" \
  --location=us \
  --display-name="pii_low"

Grant the Fine-Grained Reader role on the policy tag (not the dataset) to the principals allowed to read tagged columns in the clear. This is the only role that lets a query return a tagged column’s raw value:

# Grant fine-grained read on a single policy tag
gcloud data-catalog taxonomies policy-tags add-iam-policy-binding \
  "projects/acme-data-prod/locations/us/taxonomies/1234567890123456789/policyTags/pii_high_tag_id" \
  --member="group:pii-cleared@acme.com" \
  --role="roles/datacatalog.categoryFineGrainedReader"

The role lives in the datacatalog namespace even though you consume it in BigQuery: roles/datacatalog.categoryFineGrainedReader. Do not confuse it with roles/datacatalog.viewer, which does nothing for column access.

3. Attach policy tags to columns

You cannot set policy tags in a CREATE TABLE DDL statement. Apply them by patching the table schema with bq update (or tables.patch via the API / Terraform). Export the current schema, add the policyTags block to the sensitive columns, and push it back:

bq show --schema --format=prettyjson acme-data-prod:raw.customers > schema.json

Edit the columns you want to protect so they carry the tag resource name:

[
  { "name": "customer_id", "type": "INT64" },
  {
    "name": "email",
    "type": "STRING",
    "policyTags": {
      "names": ["projects/acme-data-prod/locations/us/taxonomies/1234567890123456789/policyTags/pii_high_tag_id"]
    }
  },
  {
    "name": "ssn",
    "type": "STRING",
    "policyTags": {
      "names": ["projects/acme-data-prod/locations/us/taxonomies/1234567890123456789/policyTags/pii_high_tag_id"]
    }
  },
  { "name": "country", "type": "STRING" }
]
bq update acme-data-prod:raw.customers schema.json

A column carries at most one policy tag. From this moment, anyone without Fine-Grained Reader on pii_high who runs SELECT * gets Access Denied: ... due to column-level security, while SELECT customer_id, country succeeds. Column-level security fails closed: the query errors rather than silently dropping columns, which is what you want for a hard boundary.

4. Add dynamic data masking instead of blocking

Hard blocking is right for ssn. But many use cases want the analyst to see a masked form of email for joins and bucketing without exposing the real address. That is dynamic data masking: a data policy bound to a policy tag that rewrites the value at query time based on the caller’s role.

Masking introduces a second role. With both roles in play on the same tag, precedence matters:

Create a data policy on the tag and pick a predefined masking rule. The built-in rules are: Nullify, Default masking value, SHA-256 hash, Random (salted) hash, Email mask, Date year mask, First four characters, Last four characters, and Custom routine.

Masking rule What a Masked Reader sees Typical use
Nullify NULL Hard-hide while keeping the column’s shape for schema stability
Default masking value Type default (0, empty string, epoch date) Non-null placeholder for BI tools that choke on NULL
SHA-256 hash 64-hex digest of the value Deterministic pseudonymous join key across tables
Random (salted) hash Different digest each run Anonymised value that must not stay join-correlatable
Email mask XXXXX@domain.com (keeps the domain) Domain-level bucketing without the local part
Date year mask Year kept, month/day zeroed (2026-01-01) Cohort or age analysis without exact DOB
First four / Last four characters 1234XXXX / XXXX6789 Card BIN or account tail for support workflows
Custom routine Whatever your UDF returns Format-preserving or region-specific redaction (below)

A tagged column can carry a masking rule and the hard block at once: Fine-Grained Reader → raw, Masked Reader → the rule above, everyone else → column-level-security block. The rule you pick is what the broad group gets, so choose the least revealing form that still lets their queries work.

# Email mask: keeps the domain, replaces the local part (e.g. XXXXX@acme.com)
gcloud data-catalog policy-tags create-data-policy \
  --location=us \
  --data-policy-id=mask_email_policy \
  --policy-tag="projects/acme-data-prod/locations/us/taxonomies/1234567890123456789/policyTags/pii_low_tag_id" \
  --data-policy-type=DATA_MASKING_POLICY \
  --masking-expression=EMAIL_MASK \
  --project=acme-data-prod

# Grant Masked Reader to the broad analyst group on the masked tag
gcloud data-catalog policy-tags add-iam-policy-binding \
  "projects/acme-data-prod/locations/us/taxonomies/1234567890123456789/policyTags/pii_low_tag_id" \
  --member="group:analysts@acme.com" \
  --role="roles/bigquerydatapolicy.maskedReader"

The query text never changes. An analyst runs SELECT email FROM raw.customers and receives XXXXX@acme.com; a cleared user runs the same statement and receives the real address. No view, no copy.

Masking gotcha: a masked column can still be used in WHERE, GROUP BY, and JOIN, but the predicate operates on the masked value for a Masked Reader. WHERE email = 'jane@acme.com' returns nothing for them because they only ever see XXXXX@acme.com. Tell your analysts, or they will file a bug against you.

Routine-based (custom) masking

When the built-in rules do not fit, point the data policy at a custom masking routine – a SQL UDF that takes the column value and returns the masked form. This is how you implement format-preserving masks, partial card numbers, or region-specific redaction.

-- A deterministic partial-mask UDF: show last 4, redact the rest
CREATE FUNCTION `acme-data-prod.masking.last4` (val STRING)
RETURNS STRING
OPTIONS (data_governance_type = 'DATA_MASKING')
AS (
  CONCAT(REPEAT('X', GREATEST(LENGTH(val) - 4, 0)), RIGHT(val, 4))
);

The OPTIONS (data_governance_type = 'DATA_MASKING') clause is mandatory – BigQuery refuses to bind a routine to a data policy without it, which prevents an arbitrary UDF from being smuggled into the masking path. Bind it by setting the data policy type to the routine instead of a predefined expression, then grant Masked Reader as before.

5. Filter rows with row access policies

Column controls protect what fields; row access policies protect which rows. They are the right tool for regional data residency and multi-tenant isolation. The DDL is a first-class statement – no schema editing:

-- EU analysts see only EU rows
CREATE ROW ACCESS POLICY eu_rows
ON `acme-data-prod.raw.customers`
GRANT TO ('group:eu-analysts@acme.com')
FILTER USING (country IN ('FR', 'DE', 'ES', 'IT'));

-- US analysts see only US rows
CREATE ROW ACCESS POLICY us_rows
ON `acme-data-prod.raw.customers`
GRANT TO ('group:us-analysts@acme.com')
FILTER USING (country = 'US');

Two behaviors define correct predicate design:

  1. Policies are a union (OR) across all policies a user matches. If a user is in both groups, they see EU and US rows.
  2. The moment a table has at least one row access policy, any user who is not in any policy’s grantee list sees zero rows – even a project owner. This is the most common “my dashboard went blank” incident. Add a deliberate escape hatch for admins:
CREATE ROW ACCESS POLICY admin_all
ON `acme-data-prod.raw.customers`
GRANT TO ('group:data-admins@acme.com')
FILTER USING (TRUE);

For scale, drive the predicate from a mapping table instead of hardcoding groups – one policy that resolves the caller’s allowed regions with SESSION_USER():

CREATE ROW ACCESS POLICY region_by_lookup
ON `acme-data-prod.raw.customers`
GRANT TO ('group:all-analysts@acme.com')
FILTER USING (
  country IN (
    SELECT region_code
    FROM `acme-data-prod.security.user_region_map`
    WHERE user_email = SESSION_USER()
  )
);

Now onboarding a region is an INSERT into a table, not a DDL change reviewed by three people. SESSION_USER() returns the email of the principal running the query, evaluated per query.

6. Share across datasets with authorized views, datasets, and routines

Fine-grained controls live on the source table. To expose a curated slice to another team or project without granting them access to the raw dataset, use authorized views. An authorized view runs with the view’s identity, so consumers need read on the view’s dataset only – never on the source:

-- Curated, pre-aggregated view in a separate "marts" dataset
CREATE VIEW `acme-data-prod.marts.customer_summary` AS
SELECT country, COUNT(*) AS customers, APPROX_COUNT_DISTINCT(email) AS distinct_emails
FROM `acme-data-prod.raw.customers`
GROUP BY country;

Then authorize the view on the source dataset. Prefer authorized datasets, which authorize every view in marts at once and survive new views without re-running grants:

# Authorize the entire marts dataset against the raw dataset (one-time)
bq update --source_dataset=acme-data-prod:marts acme-data-prod:raw

Consumers get roles/bigquery.dataViewer on marts and can query customer_summary, but SELECT * FROM raw.customers returns Access Denied. Because the view’s identity reads the source, row access policies and column masks on raw.customers still apply as the view owner – so author the view with a service account whose grants reflect exactly what consumers should see, not your own admin identity.

For logic that must touch raw data behind a tighter contract, authorized routines (UDFs and stored procedures) do the same for callable code: the routine reads the protected table, the caller cannot.

CREATE FUNCTION `acme-data-prod.marts.risk_score`(cust_id INT64)
RETURNS FLOAT64
AS (
  (SELECT some_score FROM `acme-data-prod.raw.customers` WHERE customer_id = cust_id)
);
# Authorize the routine against the source dataset
bq update --source_dataset=acme-data-prod:marts acme-data-prod:raw

Security warning, straight from the docs: an authorized stored procedure can run DDL and DML. A caller granted execute can create, modify, or delete objects in the source dataset, bypassing the IAM they would normally hit. Treat authorized stored procedures as a privilege boundary – code review them like production infra, and prefer authorized views or table-valued/scalar UDFs (which only read) when you do not need write side effects. Remote functions (Cloud Run / Cloud Functions endpoints) extend this same controlled-path idea outside SQL, but you own the egress and authentication on the remote side, so scope their service accounts tightly.

7. Audit who can see what, and who did

Controls you cannot prove are controls auditors reject. Three sources together give full coverage.

Enumerate the policies in place with INFORMATION_SCHEMA:

-- Every row access policy on tables in a dataset
SELECT table_name, row_access_policy_name, creation_time, last_modified_time
FROM `acme-data-prod.raw`.INFORMATION_SCHEMA.ROW_ACCESS_POLICIES
ORDER BY table_name;
-- Which columns carry a policy tag (look for non-null policy_tags)
SELECT table_name, column_name, policy_tags
FROM `acme-data-prod.raw`.INFORMATION_SCHEMA.COLUMNS
WHERE policy_tags IS NOT NULL;

See who actually read sensitive data via Cloud Audit Logs. Turn on Data Access logs for BigQuery (they are off by default and do incur cost), then query the read events. The policy tags referenced by a query are recorded, so you can answer “who touched pii_high last month”:

resource.type="bigquery_dataset"
logName:"cloudaudit.googleapis.com%2Fdata_access"
protoPayload.metadata.tableDataRead.policyId:*

Audit caveat to set expectations: creating and deleting a row access policy is logged (the policy name appears), but the filter_expression and grantee_list are deliberately omitted from the log because they can contain sensitive identifiers. Listing/viewing policies is not logged at all. So Cloud Logging tells you a policy changed and who changed it – to know what it now says, you must read INFORMATION_SCHEMA or the DDL. Pair a scheduled query over ROW_ACCESS_POLICIES with the audit log to reconstruct the full picture.

Enterprise scenario

A European retail bank ran a single customers table feeding both a fraud team and a marketing analytics team. GDPR and the bank’s data-residency policy demanded three things at once: marketing must never see raw iban or national_id; analysts in each country may only see that country’s customers; and fraud investigators need raw values but every access must be auditable. The platform team’s first instinct – maintain a masked copy per country – collapsed under drift: thirteen tables, nightly copy jobs, and a reconciliation backlog that itself became a compliance finding.

They rebuilt it as a single governed table with layered controls. iban and national_id got the pii_high tag with a DATA_MASKING_POLICY (last-four custom routine) for marketing’s Masked Reader role, and Fine-Grained Reader granted only to the fraud group. Residency was a lookup-driven row access policy keyed on SESSION_USER(), so adding a country was an INSERT, not a migration. Marketing consumed everything through an authorized dataset of pre-aggregated views, never touching raw. The decisive simplification was the lookup-driven predicate – it killed the thirteen-table sprawl outright:

CREATE ROW ACCESS POLICY residency
ON `bank-prod.raw.customers`
GRANT TO ('group:country-analysts@bank.eu')
FILTER USING (
  country_code IN (
    SELECT country_code
    FROM `bank-prod.security.analyst_residency`
    WHERE user_email = SESSION_USER()
  )
);

Data Access logs scoped to policyId gave the auditor a clean monthly report of every raw-PII read, and the masked-versus-raw split fell straight out of IAM role membership. Thirteen tables became one, the copy pipeline was deleted, and the next audit closed with no findings on access control.

Going deeper

The exact precedence, and why “restrictive” is the whole game

Walk a SELECT through the engine in order. IAM decides whether you may touch the table at all; fail here and nothing else runs. If a selected column carries a policy tag, column-level security checks your role on that tag: Fine-Grained Reader returns the raw value, Masked Reader returns the masked value, and neither role fails the query closed. Then row access policies filter the rows: you see the union of every policy you match, and zero rows if the table has policies and you match none. The masking transform is applied to whatever rows survive. Nothing in this chain can widen access — IAM is a hard ceiling and every later layer only subtracts. That is why “restrictive AND” is not a slogan: it is the guarantee that lets you grant dataViewer to a broad group and sleep at night.

Two consequences fall out of that design and matter in production:

Data Catalog is now Dataplex Universal Catalog

Google is consolidating Data Catalog’s cataloging capabilities into Dataplex Universal Catalog. For this lesson the practical impact is small: policy-tag taxonomies, the roles/datacatalog.categoryFineGrainedReader role, and the gcloud data-catalog taxonomies command surface all continue to work, and BigQuery column-level security is unchanged. What moves is the console — you now find and manage taxonomies under Dataplex rather than the standalone Data Catalog UI. Automation that references taxonomies by resource name (projects/.../locations/.../taxonomies/...) is unaffected. Treat any tutorial that says “open Data Catalog” as “open the taxonomy manager, now under Dataplex.”

Limits that bite at scale

Design to the documented ceilings, not to a demo:

Cost and performance nuances

Masking and row filtering are read-time transforms, so they do not reduce the bytes a query scans — a masked SELECT email still scans the full email column and bills for it. The only lever that lowers cost is pruning: partition/cluster on the row-policy key so the filter eliminates partitions before the scan. And because secured tables bypass the shared results cache, dashboards that fan out many identical queries across users lose cache benefit — front them with a BI Engine reservation or a pre-aggregated authorized view instead of hammering the raw table per user.

Terraform and automation reality

Most of this stack is first-class in the Google Terraform provider — google_data_catalog_taxonomy, google_data_catalog_policy_tag, google_bigquery_datapolicy_data_policy, and policy_tags inside a google_bigquery_table schema. The gap is row access policies: there is no stable first-class resource for them, so provision them with a google_bigquery_job that runs the CREATE ROW ACCESS POLICY DDL (or apply the DDL through your pipeline / the API). Version the DDL in the same module as the table so the policy and its table never drift apart, and keep the admin FILTER USING (TRUE) policy in code so a terraform apply can never accidentally lock every human out.

Enumerate policy tags across nested fields

INFORMATION_SCHEMA.COLUMN_FIELD_PATHS carries a policy_tags column and, unlike a flat column listing, it walks into STRUCT and ARRAY<STRUCT> sub-fields — so it catches a tag buried on address.national_id that a top-level scan would miss:

-- Every tagged field, including nested STRUCT sub-fields
SELECT table_name, field_path, data_type, policy_tags
FROM `acme-data-prod.raw`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
WHERE policy_tags IS NOT NULL
ORDER BY table_name, field_path;

Schedule this alongside the ROW_ACCESS_POLICIES query from Section 7 and you have a self-updating inventory of every fine-grained control in the dataset — the artifact auditors actually want.

Where fine-grained access stops — reach for a perimeter

Column and row controls govern what a permitted principal sees inside a table. They do nothing about a fully-cleared insider copying data to a personal project, or credentials leaking outside your org. That is a perimeter problem: pair this stack with VPC Service Controls to stop BigQuery data from leaving a service perimeter, and with IAM deny policies / conditions to bound who can even attempt access. Fine-grained access is defense in depth within the data; a perimeter is defense in depth around it. Real governance needs both.

Verify

Validate each layer as a non-privileged principal – impersonate a test service account that holds only table read, not the fine-grained roles. Self-testing as an owner hides every restriction.

# Impersonate a least-privilege identity for the checks below
gcloud config set auth/impersonate_service_account test-analyst@acme-data-prod.iam.gserviceaccount.com
# Reset impersonation when done
gcloud config unset auth/impersonate_service_account

Common beginner mistakes

Practice challenges

Work these against a scratch dataset. Each has a worked solution and the one-line reason it matters.

Challenge 1 — Enforce a taxonomy (beginner)

Create a taxonomy called Data Sensitivity in the us location with fine-grained access enforced, then add a pii_high policy tag. Why does the enforcement flag matter?

<details> <summary>Solution</summary>

gcloud data-catalog taxonomies create \
  --location=us --display-name="Data Sensitivity" \
  --activated-policy-types=FINE_GRAINED_ACCESS_CONTROL \
  --project=acme-data-prod

export TAXONOMY="projects/acme-data-prod/locations/us/taxonomies/<ID-RETURNED>"

gcloud data-catalog taxonomies policy-tags create \
  --taxonomy="$TAXONOMY" --location=us --display-name="pii_high"

Why: without --activated-policy-types=FINE_GRAINED_ACCESS_CONTROL the tags are just labels — they classify columns but never gate access. Enforcement is what turns a taxonomy into a security control. </details>

Challenge 2 — Predict and unblock (beginner)

ssn on raw.customers carries pii_high. A user with table read but no Fine-Grained Reader runs each of these. Which succeed, and how would you let them run their reporting query without the raw column?

SELECT * FROM raw.customers LIMIT 10;                 -- (a)
SELECT customer_id, country FROM raw.customers;       -- (b)
SELECT ssn FROM raw.customers;                        -- (c)

<details> <summary>Solution</summary>

To let them keep a wildcard-style query, exclude the tagged column:

SELECT * EXCEPT(ssn) FROM raw.customers LIMIT 10;

Why: column-level security fails closed on selection, so excluding the tag (or listing safe columns) runs cleanly without ever exposing the protected value. </details>

Challenge 3 — Custom last-four masking (intermediate)

Marketing should see only the last four characters of phone. Write the masking UDF and explain the one clause without which BigQuery refuses to bind it.

<details> <summary>Solution</summary>

CREATE FUNCTION `acme-data-prod.masking.last4` (val STRING)
RETURNS STRING
OPTIONS (data_governance_type = 'DATA_MASKING')
AS (
  CONCAT(REPEAT('X', GREATEST(LENGTH(val) - 4, 0)), RIGHT(val, 4))
);

Then set the data policy’s type to this routine (instead of a predefined expression) and grant roles/bigquerydatapolicy.maskedReader to group:analysts@acme.com on the tag.

Why: OPTIONS (data_governance_type = 'DATA_MASKING') is mandatory — it marks the function as a sanctioned masking routine so an arbitrary UDF can’t be smuggled into the read path. </details>

Challenge 4 — Residency with an escape hatch (intermediate)

Give EU analysts EU rows only, and make sure group:data-admins@acme.com never loses access. Which of the two policies prevents the blank-dashboard outage, and why?

<details> <summary>Solution</summary>

CREATE ROW ACCESS POLICY eu_rows
ON `acme-data-prod.raw.customers`
GRANT TO ('group:eu-analysts@acme.com')
FILTER USING (country IN ('FR','DE','ES','IT'));

CREATE ROW ACCESS POLICY admin_all
ON `acme-data-prod.raw.customers`
GRANT TO ('group:data-admins@acme.com')
FILTER USING (TRUE);

Why: the instant eu_rows exists, everyone not in a policy — admins included — drops to zero rows. admin_all with FILTER USING (TRUE) is the deliberate escape hatch that keeps operators (and dashboards owned by them) working. </details>

Challenge 5 — Kill the hardcoded groups (advanced)

Replace a sprawl of one-policy-per-country with a single lookup-driven policy so onboarding a region is an INSERT. Sketch the mapping table and the policy.

<details> <summary>Solution</summary>

-- Mapping table (cluster on user_email for cheap lookups)
CREATE TABLE `acme-data-prod.security.user_region_map` (
  user_email STRING, region_code STRING
) CLUSTER BY user_email;

CREATE ROW ACCESS POLICY region_by_lookup
ON `acme-data-prod.raw.customers`
GRANT TO ('group:all-analysts@acme.com')
FILTER USING (
  country IN (
    SELECT region_code
    FROM `acme-data-prod.security.user_region_map`
    WHERE user_email = SESSION_USER()
  )
);

Why: SESSION_USER() resolves the caller per query, so access follows the data in the table. Adding a country is an INSERT, not a reviewed DDL change — and there is one policy to audit, not thirty. </details>

Challenge 6 — Curated cross-project share (advanced)

Expose a country-level aggregate of raw.customers to a separate analytics project, authored by a scoped service account, with zero access to raw rows. Then prove a consumer is blocked on the source and enumerate what’s protecting it.

<details> <summary>Solution</summary>

-- 1. Curated view in a marts dataset, created by a scoped SA
CREATE VIEW `acme-data-prod.marts.customer_summary` AS
SELECT country, COUNT(*) AS customers
FROM `acme-data-prod.raw.customers`
GROUP BY country;
# 2. Authorize the whole marts dataset against raw (survives new views)
bq update --source_dataset=acme-data-prod:marts acme-data-prod:raw

# 3. Grant consumers read on marts ONLY
bq add-iam-policy-binding \
  --member="group:partner-analysts@other.com" \
  --role="roles/bigquery.dataViewer" acme-data-prod:marts
-- 4. Prove the block + inventory the controls
SELECT * FROM `acme-data-prod.raw.customers` LIMIT 1;   -- Access Denied for consumers

SELECT table_name, row_access_policy_name
FROM `acme-data-prod.raw`.INFORMATION_SCHEMA.ROW_ACCESS_POLICIES;

SELECT table_name, field_path, policy_tags
FROM `acme-data-prod.raw`.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS
WHERE policy_tags IS NOT NULL;

Why: the authorized dataset lets the view read raw as its owner SA, so consumers query the aggregate without any grant on the source — and INFORMATION_SCHEMA gives the auditor a live inventory of every row and column control in one place. </details>

Checklist

Glossary

bigquerydata-governancesecuritypolicy-tagsdata-masking
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments