Repository navigation
fix(gcp): a BigQuery replace or rollback keeps the table's governance - #705
Merged
Merged
Conversation
…grants back What: each google_bigquery_row_access_policy and each google_bigquery_table_iam_member on a table this module declares (the shared dataset's table grants, and a planned `iam.bind_bq_table` on the module's own table) carries `lifecycle.replace_triggered_by = [google_bigquery_table.<t>.id]`. Why: BigQuery deletes a table's row access policies and IAM policy with the table. When tofu replaced a table (partitioning or a key added to a live table, allowed with --allow-data-loss), the policies' table_id was the same string, so tofu planned them as unchanged and the new table stood ungoverned until the next apply. How the trigger was chosen (measured with tofu 1.12 and terraform-provider-google 6.50 against tests/iac/_fake_bigquery.py): - no trigger (main): the table is replaced, the policies plan no-op, and the live table has none after the apply; - `[google_bigquery_table.<t>]` (the whole resource): fixed, but OpenTofu fires a resource reference on every in-place update too, so a label or a retention change replaced both row access policies, two removals the data-loss gate refuses without --allow-data-loss; - `.creation_time`: correct, but `tofu validate` checks a replace_triggered_by attribute against the schema of the resource carrying it, and a table IAM member has no creation_time; - `.id`: unknown in the plan only when a new table is created, valid on both resource types; replaces with the table, no-op on an in-place update. Prior art: OpenTofu's documented replace_triggered_by, and the terraform_data trigger this emitter already uses for partitioning. Validated: the fake BigQuery now serves row access policies and table IAM and deletes both with their table, as BigQuery does. New real-tofu tests show a replaced table gets both policies and its table grant back in the same apply, and an in-place update (label, retention period) leaves them; they fail without the trigger, and the in-place test fails with the whole-resource form. Emit tests pin the lifecycle, and `tofu validate` accepts a row-filtered and a shared-dataset module.
What: nothing forge-cli runs recreates a governed BigQuery table to replace
its rows any more.
- `fluid rollback` (cli/rollback.py::_restore_bigquery) restores with one
query job, `SELECT * FROM <backup>` into the live table, write disposition
from `_bigquery_load.write_disposition("full_refresh")` (WRITE_TRUNCATE_DATA,
WRITE_TRUNCATE on an emulator), CREATE_NEVER, client from
`_bigquery_load.bigquery_client`. The destination is a TableReference of the
identifiers `_validated_bq_fqn` checked; the recorded ddl[] is still never
run and the state file stays untrusted. Before writing, it counts the rows
the restoring identity can read against the backup's own numRows and
refuses (`rollback_bigquery_backup_filtered`) when a row access policy
shows it only some, which would otherwise restore that subset.
- `GcpProvider.restore_ddl` returns [] as AWS does.
- The GCP planner's replace emits a SQL build as the SELECT, a structured
destination and WRITE_TRUNCATE_DATA / CREATE_NEVER; it keeps the table's
`bq.ensure_table`; SQL that names its own sink passes through untouched.
- The pre-replace backup is `CREATE SNAPSHOT TABLE IF NOT EXISTS ... CLONE
... OPTIONS(expiration_timestamp = now + 30 days)`, not a plain CTAS copy
with no row access policy and no policy tag. The "BigQuery has no CLONE"
comment is corrected.
- BasePlanner, GcpPlanner and provider docstrings; docs/apply.md (what
replace and replace-and-build do on the OpenTofu engine),
docs/governance-parity.md (dbt's table materialisation still recreates the
table: a known gap, follow-up), AGENTS.md, scaffold_ide.py, CHANGELOG.
Why: BigQuery documents that CREATE OR REPLACE TABLE drops every row access
policy of the table, and the new table takes its schema from the SELECT, so
the column policy tags (column-level security and masking) went too. Every
filtered or restricted principal then read every row and column until the
next apply. The rollback restore is the path that executes on main; the
planner's CTAS and backup are latent since the OpenTofu cutover but wrong.
Prior art: dlt's BigQuery destination does its atomic replace as one
WRITE_TRUNCATE_DATA job into the existing table (dlt-hub/dlt,
destinations/impl/bigquery), which forge-cli's load path already follows;
BigQuery's REST reference (JobConfigurationQuery.writeDisposition) says
WRITE_TRUNCATE_DATA "overwrites the data, but keeps the constraints and
schema of the existing table". dbt-bigquery, Dataform and SQLMesh all
create-or-replace and re-apply tags afterwards (an exposure window, and no
row access policies), so their pattern was not copied.
Validated: the new and rewritten tests (tests/providers/
test_gcp_replace_keeps_governance.py, tests/cli/test_rollback.py::
TestRestoreBigQuery, with the IaC tests: 37) go 22 failed / 15 passed with
the source fix stashed and 37 passed with it restored (the 15 are invariants
that hold on main too). Full suite without tofu on PATH: 20067 passed, 660
skipped, rc=0, with six dbt tests deselected that fail identically on
origin/main (dbt-duckdb is not in the venv). ruff, black 24.10.0, the product
guardrail and detect-secrets pass. .secrets.baseline only moves one AGENTS.md
line number (the entry set is unchanged, 250 before and after). A live probe
of a WRITE_TRUNCATE_DATA query job on real BigQuery (tags, policies, column
mapping, snapshot governance) is written but not run here.
…governance # Conflicts: # CHANGELOG.md
📄 Documentation ReminderThis PR appears to be missing a documentation reference. Our docs live in a separate repo. Please update the PR description with one of:
See the Contributing Guide for details. |
…t proven' A probe on 6 Oct ran this branch's planner replace action and fluid rollback against a temporary governed table on real BigQuery. The replace's WRITE_TRUNCATE_DATA query job and the restore kept the row access policy, the policy tag, the partitioning and its expiration. The snapshot copied the policies and the tag. A restricted identity was refused the job (403) and nothing changed, and the old CREATE OR REPLACE dropped both on a control table. The parity doc still listed all of this as not proven.
…governance # Conflicts: # .secrets.baseline # CHANGELOG.md # docs/governance-parity.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(gcp): a BigQuery replace or rollback keeps the table's governance
Branch
fix/bq-replace-keeps-governance, two commits on origin/main 63f3b40:0954326fix(iac),0739491fix(gcp).Summary
Nothing forge-cli runs recreates a governed BigQuery table to replace its rows any more.
fluid rollbackrestores a BigQuery table with one query job:SELECT * FROM <backup>into the live table, with
WRITE_TRUNCATE_DATAandCREATE_NEVER.definition planned, and its backup is a zero-copy
CREATE SNAPSHOT TABLE … CLONE.tofureplaces a BigQuery table, its row access policies and table IAM membersare now recreated in the same apply.
dbt's
tablematerialisation still recreates the table. That is a known gap, writtenup in
docs/governance-parity.md, and a follow-up. The dbt runner is unchanged.Why
BigQuery documents that
CREATE OR REPLACE TABLEdrops every row access policy on thetable. The new table also takes its schema from the SELECT, so the column policy tags
went too, and with them column-level security and masking. Every principal that a row
filter or a column restriction limits then read every row and every column, until the
next
fluid apply.This class of bug showed up in three places:
fluid rollback(cli/rollback.py::_restore_bigquery). This one executes onmain. It ran
CREATE OR REPLACE TABLE <orig> AS SELECT * FROM <backup>for anyBigQuery entry in
.fluid/rollback-state.json.dropped
bq.ensure_tablefor their targets. Its backup was a plainCREATE TABLE … AS SELECTcopy with no row access policy and no policy tag, so anyreader of the dataset could read all of it. These paths are latent on the OpenTofu
engine but still wrong.
GcpProvider.restore_ddlrecorded the same CTAS.tofureplaced a table (adding partitioning or a key to a livetable, with
--allow-data-loss), BigQuery deleted the table's row access policiesand table IAM along with it.
tofuplanned them as unchanged, because theirtable_idis the same string before and after, so the new table stood ungoverneduntil the next apply.
What changes
fluid_build/cli/rollback.py_restore_bigquerybuildsSELECT * FROM <validated backup FQN>.bigquery.TableReferencebuilt from the identifiers that_validated_bq_fqnchecked. No destination SQL is interpolated.write_dispositioncomes from_bigquery_load.write_disposition("full_refresh"):WRITE_TRUNCATE_DATA, orWRITE_TRUNCATEunderBIGQUERY_EMULATOR_HOST.create_dispositionisCREATE_NEVER._bigquery_load.bigquery_client: ADC, or anonymous credentialson an emulator.
ddl[]is still never executed, and state-file input is still validatedbefore any client exists.
restoring identity can read (
COUNT(*)) with the backup's ownnumRows, which nopolicy filters. If a row access policy shows the identity only some of the rows, the
restore stops with
rollback_bigquery_backup_filteredinstead of replacing the tablewith that subset. It also refuses when
numRowsis missing._run_bigquery_restoreand_readable_backup_rows.fluid_build/providers/gcp/provider.pyGcpProvider.restore_ddlreturns[], as the AWS provider does, with a docstringsaying that
fluid rollbackrestores BigQuery with a data-only query job that keepsthe table and its governance.
fluid_build/providers/gcp/plan/planner.pysql(the SELECT), a structureddestination({project, dataset, table}, validated) andwrite_disposition: WRITE_TRUNCATE_DATA/create_disposition: CREATE_NEVER.INSERT INTO.INSERT/CREATE/MERGE/UPDATE/DELETE/COPY/TRUNCATEpasses throughuntouched, as before.
_bq_wrap_sql_for_targetbecame_bq_build_for_target, with the target lookuppulled out into
_bq_build_target._plan_exposuresno longer skipsbq.ensure_tablefor SQL-build targets._plan_replace_snapshotsemitsCREATE SNAPSHOT TABLE IF NOT EXISTS <backup> CLONE <table> OPTIONS(expiration_timestamp = TIMESTAMP_ADD(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)).allow_failureand therollback_snapshotmarker are kept, and the "BigQuery has noCLONE" comment is corrected.
plan_actions,GcpPlannerand the provider are fixed.fluid_build/providers/_planner_base.pyGCP a
WRITE_TRUNCATE_DATAquery job.fluid_build/iac/providers/gcp.pygoogle_bigquery_row_access_policygetslifecycle.replace_triggered_by = ["google_bigquery_table.<t>.id"].google_bigquery_table_iam_memberon a table the module declares. Thatcovers the shared-dataset table grants, and a planned
iam.bind_bq_tableon themodule's own table, matched by
_module_table.Docs
docs/apply.md: what--mode replaceandreplace-and-builddo on the OpenTofuengine, and how rollback restores BigQuery.
docs/governance-parity.md: replace and rollback keep governance; a replaced tablecomes back governed; the dbt gap is stated plainly; the "not proven yet" list is
updated.
AGENTS.mdandfluid_build/cli/scaffold_ide.py: a note under the mode matrix.CHANGELOG.md: an[Unreleased]Fixed entry.Tests
tests/providers/test_gcp_replace_keeps_governance.py: the research tests, extended.tests/cli/test_rollback.py::TestRestoreBigQuery: rewritten. The old assertionspinned the CTAS.
tests/iac/_fake_bigquery.py: now serves row access policies and table IAM, anddeletes both along with their table, as BigQuery does.
tests/iac/test_iac_gcp_governance_plan.py: three real-tofutests.tests/iac/test_iac_gcp_governance.py: emit tests, plus two newtofu validateshapes.
Decisions taken (beyond the founder's binding ones)
The trigger references the table's
id, notgoogle_bigquery_table.<t>. Imeasured this with tofu 1.12 and terraform-provider-google 6.50 against the fake
BigQuery (script and output in the scratch dir,
rtb/):but OpenTofu fires a resource reference on every in-place update too. A label
change or a new retention period replaced both row access policies. That is
remove: 2in the plan, which the data-loss gate refuses without--allow-data-loss, because row access policies are not access-only resources.It would also briefly leave the table with no policies.
creation_time: correct, buttofu validatechecks areplace_triggered_byattribute against the schema of the resource that carries it (an OpenTofu quirk I
confirmed), and a table IAM member has no
creation_time, so the module failed tovalidate.
id: valid on both resource types. It is unknown in the plan only when a newtable is created, so it replaces with the table and is a no-op on an in-place
update.
A seeded test proves the difference.
The rollback refuses a filtered backup. A table snapshot can carry row-level
security, and the restore reads the backup as the operator. A filtered operator
would otherwise replace the table with the rows it can see and silently delete the
rest. The guard fails closed, including when
numRowsis missing.Snapshot expiry is 30 days. The old copy had no expiry. It was deleted only by
the rollback-state pruning (the last 20 per env and product), which still deletes
snapshots through
cleanup_backups. There is no GCP setting for this, so 30 daysmatches the Snowflake provider's
BackupConfig.backup_retentiondefault.The rollback result keeps the
ddlkey. For BigQuery it now holds the querytext, so consumers that read
ddlkeep working. It also addsdestination,write_dispositionandcreate_disposition.The renamed planner helper (
_bq_wrap_sql_for_targetbecame_bq_build_for_target) returns action fields instead of a SQL string. Its onlycaller is the planner.
OpenTofu-first audit
Audited on 6 October against
git diff origin/main...HEAD, with tofu 1.12 andterraform-provider-google 6.50. The audit changed no code.
1. Governance stays in the module. Row access policies, policy tags, data policies and
IAM are only ever emitted as OpenTofu resources (
google_bigquery_row_access_policy,google_bigquery_table_iam_member, the Data Catalog and data policy resources). The diffadds three client calls, none of which touches governance: the restore's query job into the
existing table (
client.querywithWRITE_TRUNCATE_DATA/CREATE_NEVER), aget_tableread for
numRows, and the existing_bigquery_load._count_tableCOUNT(*). Nothing inthe branch creates, recreates or patches a row policy, policy tag, data policy or IAM
binding through the BigQuery or Data Catalog SDK. The restore's job is the reason the table
keeps its governance: BigQuery documents that
WRITE_TRUNCATE_DATA"keeps the constraintsand schema of the existing table" (REST discovery document, revision 20260922).
2.
replace_triggered_byon.idis the documented attribute form. The OpenTofulifecycle page lists three triggers: any plan to update or replace a referenced resource
instance, and for a single attribute reference "any change to the attribute value". The
repo already uses the same meta-argument for the table itself
(
replace_triggered_by = [terraform_data.<trigger>]). The real-tofu plan tests cover:tofu+ provider 6.50 +_fake_bigquery)test_a_replaced_table_gets_its_row_access_policies_backtest_a_replaced_table_in_a_shared_dataset_gets_its_grants_backtest_an_in_place_table_update_leaves_its_row_access_policies(label, retention period)The in-place case for table IAM members has no committed real-tofu test. It was run as a
probe outside the branch (retention period and label, shared dataset): the table is an
update, the member a no-op, remove 0; with the whole-resource reference the member is
delete+create on a label change. Same mechanism as the row policies, so no change.
3. Snapshot and restore stay data operations. They are not declared policy, and the
google provider cannot express them as desired state:
google_bigquery_jobcopytakes onlycreate_disposition,write_disposition,source_tables,destination_tableanddestination_encryption_configuration.tofu validateagainst provider 6.50 rejectsoperation_type("No argument or block typeis named operation_type"), though the REST API has
operationTypeSNAPSHOT / RESTORE.queryblock rejectswrite_disposition = "WRITE_TRUNCATE_DATA"(expected ... to be one of ["WRITE_TRUNCATE" "WRITE_APPEND" "WRITE_EMPTY" ""]). The one overwrite it accepts,WRITE_TRUNCATE, removes every row access policy, which is the bug this branch fixes.google_bigquery_tablehas nosnapshot or clone argument (1,096 resources searched).
so a job per backup would accumulate in state and never be undone by teardown. A
pre-replace backup must run before the table's destroy, and a resource that depends on the
table is created after it; I found no OpenTofu feature that runs a step before a
resource's destroy (Terraform's action triggers gained
before_destroyonly in 1.16).A rollback is an operator's request, not an apply. OpenTofu's provisioner documentation
calls actions its plan cannot model a last resort.
engine for GCP, no snapshot is taken at all (
docs/apply.md), so these actions do notrun there.
build_runners/_bigquery_load.py, the same way dlt's BigQuery destination loads.Validation
All commands ran from the clone with the shared venvs and
-p no:randomly.pytest tests/providers/test_gcp_replace_keeps_governance.py tests/cli/test_rollback.py tests/iac/test_iac_gcp_governance.pygit stash push -- fluid_build/cli/rollback.py fluid_build/providers/gcp/provider.py fluid_build/providers/gcp/plan/planner.py fluid_build/iac/providers/gcp.py, then the 37 new or changed testsgit stash pop, same 37 testsreplace_triggered_byset to[google_bigquery_table.<t>], then the two real-tofu row-policy tests['delete','create'] != ['no-op']), 1 passed. Fix restored afterwardsPLUGIN_UNIX_SOCKET_DIR=/tmp/wfi1,TF_PLUGIN_CACHE_DIR=wf78/tfcache)pytest tests/iac/test_iac_gcp_governance.py tests/iac/test_iac_gcp_governance_plan.py tests/iac/test_iac_tofu_validate.py tests/iac/test_iac_runner.pyenv PATH=/usr/bin:/bin:/usr/sbin:/sbin:<venv>/bin pytest tests -q -x -p no:randomlytest_dbt_model_contracts.py::TestLiveDbtBuildEnforcement::test_conformant_model_builds_then_drift_fails(No module named 'dbt.adapters.duckdb'), after 4845 passed. Run 2 deselected the six dbt-environment tests: 20067 passed, 660 skipped, 15 deselected (the six plus the nine the config deselects), 17 xfailed, 16 xpassed, rc=0 in 775 sorigin/main63f3b40, the same six testslintvenv/bin/ruff check fluid_build/ tests/lintvenv/bin/black --check fluid_build/ tests/(24.10.0)python3 tools/product_guardrail/check.py --self-test && python3 tools/product_guardrail/check.pydsvenv/bin/detect-secrets-hook --baseline .secrets.baseline <16 changed files>ripwire <clone> --quality-delta_snapshotdict helper or apytest.raisespattern with unrelated test helpers, twonew-clone-of-reused-helperrows on the same helper, and verbosity ofTestRestoreBigQuery(107 to 152 lines) andFakeBigQuery(179 to 251), which grew with the new coverage. The thirteenth isshort-horizon-churnon_emit_row_policies, added in #700 the day before. In source, only minor or new-symbol rows remain:_plan_replace_snapshots73 to 79 lines, and_bq_build_for_targetat 6 params by ripwire's count (the same keyword arguments the old_bq_wrap_sql_for_targettook). Before review,_restore_bigquery(131 to 172 lines) and_bq_build_for_target(complexity 17) also gated; both were refactored (_run_bigquery_restore,_readable_backup_rows,_bq_build_target) and no longer appear. I did not reshape test code just to lower the countlive_probe.py --dry-rununder a wrapper that refuses every socket connectionLive probe
r1-scratch/live_probe.pyhas not been run. It runs against projectfluid-testing-a5d2b1ineurope-west3.Identity. It asserts that the ADC identity is tordering@gmail.com, using tokeninfo
with the token in a POST body, before it creates anything.
What it creates. Everything is prefixed
fluid_probe_<UTC timestamp>:customer_profiletable: a taggedemailcolumn, DAY ingestion-timepartitions with an expiration, 3 rows of which 2 are consented, and the row access
policy
probe_consented;mapping table.
Steps.
what it keeps.
S1, then drop the policy from the live table.
it, then check governance again (after-planner).
fluid rollbackfrom S0 (expected: refused bythe new guard), then from S1 without Fine-Grained Reader (expected: refused on the
tag).
it takes effect, run the rollback from S1, revoke it, and poll until the column is
refused again. Then check governance (after-rollback).
reordered: two STRING columns swapped, then mismatched types. A third job with a
NULLABLE column missing.
CREATE OR REPLACE TABLE … AS SELECTon the copy. It isexpected to drop the policy and the tag.
Teardown runs in a
finally. It deletes the dataset with its contents and thetaxonomy, then lists datasets and taxonomies to show that nothing prefixed
fluid_probe_remains. The probe prints a JSON summary with per-step results andverdicts.
Risks
row access policies is documented, and was measured for a load job on 5 October, but
not for a query job. The probe settles it.
position, and what it does with a missing NULLABLE column, is unknown until the probe
runs. A planner action whose SELECT column order differs from the table could
misplace values if the mapping is positional.
numRowsfor a snapshot, everyBigQuery rollback is refused, because the guard fails closed. The probe records
snapshot_num_rows.policy covers and that is a Fine-Grained Reader of the backup's policy tags. Before,
the old CTAS backup needed the same access when it was taken; now it is needed at
restore time.
for up to 30 days. It is zero-copy until the base table changes.
idplans. Depending onidbeing unknown in the planwhen a resource is replaced is standard Terraform and OpenTofu behaviour, and the
tests pin it against provider 6.50.
Follow-ups
create or replace tableunder the
tablematerialisation, and under--full-refreshin replace-and-build,drops row access policies, and drops policy tags unless
persist_docsre-appliesthem. Founder decision: separate follow-up.
providers/snowflake/plan/planner.py::_wrap_sql_for_targetemits
CREATE OR REPLACE TABLE … ASwhen destructive. TheCREATE OR REPLACE TABLEexample in
docs/apply.mdunder embedded SQL runs verbatim and would drop directlyattached masking and row access policies.
_bq_wrap_sql_for_target(now_bq_build_for_target) checksonly the first 32 characters for a sink keyword, so SQL that begins with a comment is
wrapped into invalid SQL. The Snowflake wrapper has the same check.
forge/core/apply_modes.py. Its docstring mode matrix still says"auto-snapshot → CREATE OR REPLACE".
GcpProvider.cleanup_backupsbuildsbigquery.Client(project=...)directlyrather than
_bigquery_load.bigquery_client, so it is not emulator-aware.Live validation on real BigQuery (6 Oct 2026)
A probe ran this branch's code against real BigQuery: one temporary dataset in europe-west3 and one Data Catalog taxonomy, both deleted afterwards and verified absent by listing.
Setup: a DAY-partitioned table (7-day partition expiration) with
emailunder a policy tag, and a row access policyprobe_consented(consent = true) for the probing identity, which is no fine-grained reader of the tag. The identity got a TRUE row policy and Fine-Grained Reader only while it wrote, the access a pipeline identity holds; both were removed before the final checks.emailprobe_consentedemailrefusedCREATE SNAPSHOT TABLE IF NOT EXISTS … CLONE … OPTIONS(expiration_timestamp = …)WRITE_TRUNCATE_DATA,CREATE_NEVERfluid rollbackrestore from the snapshotprobe_consentedemailrefusedfluid rollbackwithout that accessrollback_bigquery_execute_failed, 403 on the tagged column); nothing changedCREATE OR REPLACE TABLE … AS SELECT(unpartitioned copy)emailreadableAlso measured:
WRITE_TRUNCATE_DATAquery job maps the SELECT's columns by name, not by position, and a missing NULLABLE column lands as NULL;