Repository navigation
fix(aws): refuse a row filter on a binding that names no Glue-catalog table - #703
Merged
Merged
Conversation
… table What: lf_row_filters now refuses a filter (row-filter-unenforceable) on an aws binding whose format is not a Glue-catalog format (redshift_serverless, redshift_table, redshift_external_schema, kinesis_stream, glue_table, ...), or that has no location.database or location.table: the predicate lf_column_exclusions already refuses column restrictions with. The message names the formats that can carry a filter. A new lf_governance runs lf_column_exclusions, lf_row_filters and lf_masked_views in the emitter's order. AwsIacPlugin.emit and validate_governance both call it, so fluid validate reports every refusal those derivations raise, with the emitter's own message. Why: on AWS a row filter is a Lake Formation data cells filter on a Glue table, and _emit_lakeformation returns early for any other format and for a binding with no database. lf_row_filters checked only for grants and a table. So a filter on a Redshift or Kinesis binding passed fluid validate (exit 0), and fluid generate iac and fluid apply wrote the Redshift namespace and workgroup with no filter (exit 0). fluid validate also ran only lf_column_exclusions for an aws binding, so the no-grant, no-read-grant, unmapped-principal, no-maskedViews and text-mask refusals surfaced first at generate iac or apply. A mask or a deny on a non-Glue binding was already refused by lf_column_exclusions (column-restriction-unenforceable); that kind is kept, and the mask case is now pinned. lf_masked_views quotes names through validate_ident, which raises a plain ValueError. Run at validate, that would have escaped validate_governance, and cli/validate.py skips the whole governance check on any exception: a contract with a Redshift row filter and a masked expose on a "silver-zone" database printed "Valid FLUID contract". The aws branch now goes through _aws_refusal, which reports that ValueError as the expose's error. docs/governance-parity.md and the governance_validation docstring said these refusals happen at fluid plan, which never runs the emitter; corrected to fluid validate, fluid generate iac, fluid apply and fluid diff's state check. Prior art: the refusal is the in-repo triple of lf_column_exclusions and gcp_governance.refuse_unsupported_target (UnsupportedBindingError with a stable kind, a message ending "It would not be enforced.", one remediation sentence); no new kind is minted. Reporting at validate as well as at emit follows the Terraform plugin framework's ValidateConfig (diagnostics at validate, plan and apply, before any change) and Kubernetes KEP-2885 (silently dropped fields became strict validation errors). dbt's warn-and-drop for unsupported constraints was considered and rejected for an access-control policy. Validated: tests/iac/test_iac_aws_nonglue_governance.py, 55 tests: the research's 22 failed 11 on main; all 55 green with the fix. Seeded defects: disabling the guard turns 20 red, validate running only lf_column_exclusions turns 18 red, dropping the ValueError clause turns 1 red; each restored byte-identical, 55 green. tests/iac plus the startup budget and validate suites: 1661 passed, 243 skipped (tofu hidden). The tofu-gated tests/iac files with tofu on PATH, against moto and the in-process BigQuery fake only: 255 passed, 5 skipped; the three that reach validate_governance rerun on the final code: 175 passed. Full suite, tofu hidden: 20097 passed; the 6 failures are the dbt tests that need the dbt-duckdb adapter, absent from the local venv, and fail identically on main. Real CLI: fluid validate and fluid generate iac exit 1 on the Redshift repro (no main.tf.json written); the parquet Glue control validates and emits its data cells filter. fluid validate over the 53 example and template contracts: same exit codes and governance lines as main; validate_governance over 76 YAML contracts in the repo: no change. ruff, black 24.10.0 --check, the product guardrail and detect-secrets pass.
📄 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. |
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(aws): refuse a row filter on a binding that names no Glue-catalog table
Summary
A
policy.authz.rowFiltersentry on an AWS binding that is not a Glue-catalog table(
redshift_serverless,redshift_table,redshift_external_schema,kinesis_stream,glue_table, ..., or a Glue format with nolocation.database) was silently dropped:fluid validatepassed, andfluid generate iacandfluid applywrote the Redshiftnamespace and workgroup with no filter and exited 0. It is now refused with the existing
kind
row-filter-unenforceable.fluid validatenow runs the same Lake Formation derivations as the emitter(
lf_governance), so it reports at stage 2 every refusal they raise, with the emitter'sown message.
Why
On AWS a row filter is a Lake Formation data cells filter on a Glue table.
_emit_lakeformationreturns early for any format outside_GLUE_CATALOG_FORMATSandfor a binding with no
location.database. The refusal that should guard that earlyreturn,
lf_row_filters, checked only for grants and alocation.table, never theformat or the database.
Measured on
origin/main(63f3b40), on aredshift_serverlesscontract with LakeFormation grants and a row filter:
fluid validate:Valid FLUID contract (schema v0.7.6), exit 0.fluid generate iac: exit 0, module holds onlyaws_redshiftserverless_namespaceandaws_redshiftserverless_workgroup.parquetbinding withtablebut nodatabase: the module held only the S3 bucket,with no grant and no filter.
There was a second gap with the same symptom.
validate_governanceran onlylf_column_exclusionsfor an aws binding. Its docstring says it runs the emitter'sderivations so each refusal is reported at stage 2, but
lf_row_filtersandlf_masked_viewsnever ran. So a row filter with no read grant or with an unmappedprincipal, a mask with no
maskedViews, and a text mask on a non-string column allpassed
fluid validateand failed first atgenerate iacorapply.Masks and denies on a non-Glue binding were already refused. Every mask is a column
restriction, and
lf_column_exclusionsruns first and refuses non-Glue formats(
column-restriction-unenforceable). That kind is kept; the mask case had no test andnow has one.
What changes
fluid_build/iac/providers/aws.pylf_row_filters: before the grants check, it refuses when the format (defaultparquet, compared case-blind) is not in_GLUE_CATALOG_FORMATS, or whenlocation.databaseorlocation.tableis missing. This is the same predicatelf_column_exclusionsuses. The message names the format and lists the Glue-catalogformats, and the remediation is one sentence, in the style of
gcp_governance.refuse_unsupported_target:The missing-table test moved from the grants check into this guard. The grants
check keeps its message.
New
lf_governance(exposure, binding, index): runslf_column_exclusions, thenlf_row_filters, thenlf_masked_views, and returns(exclusions, row_filters, masked_views).AwsIacPlugin.emitcalls it in place ofthe three calls.
fluid_build/iac/governance_validation.py_aws_refusal(exposure, binding, index). It runslf_governanceand returns the refusal text, or""when there is none. Before,the branch called only
lf_column_exclusions.lf_masked_viewsquotes names throughvalidate_ident, which raises a plainValueError. Uncaught, that would escapevalidate_governance, andcli/validate.pytreats any exception there as "Governance binding check skipped"(shown only with
--verbose). Every governance finding of the contract would thenbe lost. I measured this with the first version of this change: a contract with a
Redshift row filter plus a masked expose on a
silver-zoneGlue database printed"Valid FLUID contract", exit 0.
_aws_refusalreports theValueErroras thatexpose's error (
exposes[masked]: Invalid SQL identifier: 'silver-zone'), and thecheck goes on. The helper also keeps
validate_governance's complexity at 27 (itwas 26); the inline version measured 33.
docs/governance-parity.md: refusals happen atfluid validate,fluid generate iac,fluid applyand the state check offluid diff, not atfluid plan(cli/plan.pynever runs the IaC emitter). The list now includes the non-Glue row-filter refusal,
and the Row filters row of the table says where AWS can enforce one.
CHANGELOG.md:[Unreleased]gets Fixed and Documentation entries.tests/iac/test_iac_aws_nonglue_governance.py(new, 55 tests): the research's 22tests, kept, with the mask and combined-policy assertions tightened to the exact kind,
plus these:
PARQUETand an omitted format still emit thefilter;
lf_governanceequals the three derivations in order, and the filter reaches themasked view's
WHERE;emit_module(thefluid applyandfluid diffpath) raisesunsupported_binding;lf_governancecan raise,validate_governancereports exactly the emitter's message and remediation;ValueErrorcase above;fluid validateCLI exits 1 on the Redshift repro and 0 on the Gluecontrol.
Prior art (borrow before build)
lf_column_exclusions(same guard, same exception) andgcp_governance.refuse_unsupported_target(message shape: where, what was asked,"It would not be enforced.", one-sentence remediation). The existing stable kind
row-filter-unenforceableis reused; no kind is minted.ValidateConfig(https://developer.hashicorp.com/terraform/plugin/framework/providers/validate-configuration):
configuration errors are reported at validate, plan and apply, before any change.
That is why validate now runs the emitter's own derivation.
dropped fields were treated as a bug class and moved to strict validation.
Glue-catalog tables
(https://aws.amazon.com/blogs/big-data/use-amazon-redshift-spectrum-with-row-level-and-cell-level-security-policies-defined-in-aws-lake-formation/).
That confirms the refusal is right for native Redshift bindings.
(https://docs.getdbt.com/reference/resource-properties/constraints). It is the
behaviour being fixed, and wrong for an access-control policy.
Decisions
location.databaseis refused too, not only a non-Glue format._emit_lakeformationdrops a filter in that case through the same early return. Imeasured it on main (parquet, table, no database: bucket only). The guard is exactly
lf_column_exclusions's predicate, so the two refusals agree on what "a Glue table"means.
_GLUE_CATALOG_FORMATS, sorted, so it followsthe set if the set changes. It includes
avro,orcanddelta, which are in the setbut not in the 0.7.6 binding-format enum. I listed the emitter's truth rather than add
a second list.
from this one." It does not say "use a Glue table", which would mislead a
glue_tableauthor.
column-restriction-unenforceable.column-mask-unenforceableis not minted fornon-Glue, because it cannot fire there.
with no Lake Formation grants used to get, at most, the generic "accessPolicy.grants
are not enforced" warning at validate. It is now the emitter's error.
ValueErrorcatch sits only in the aws branch (_aws_refusal). The GCPbranch is untouched, to keep this self-contained. A GCP-side
ValueErrorwould stillskip the check; that is listed as a follow-up.
lf_column_exclusions, rather than a sharedpredicate helper. That keeps the diff small for the sibling Lake Formation tags change
to rebase over.
lf_row_filterscomplexity goes from 16 to 18 (ripwire minor)._GLUE_CATALOG_FORMATSisnot widened, and a
governance.lakeFormationblock on non-Glue is not refused.Validation
All commands ran in the clone at
fix/aws-row-filter-nonglue-refused(commitb6575e1, onorigin/main63f3b40), withPYTHONPATHset to the clone (import fluid_buildresolves into it) and-p no:randomly. Unless a row says otherwise, theresults are for the final code (the md5 of the three code and test files was
recorded before the final runs and checked again before the commit).
pytest tests/iac/test_iac_aws_nonglue_governance.py(research version, unfixed source)lf_governancedoes not exist on mainpytest tests/iac/test_iac_aws_nonglue_governance.pyif False:)_aws_refusalruns onlylf_column_exclusionsexcept ValueErrorclause removedpytest tests/iac tests/perf/test_startup_budget.py tests/test_validate_cmd.py tests/test_validate_ext.py-xenv PATH=/usr/bin:/bin:/usr/sbin:/sbin:<venv>/bin pytest tests -q -x -p no:randomlytests/engines/test_dbt_model_contracts.py::TestLiveDbtBuildEnforcement("Could not find adapter type duckdb!"). The shared venv has dbt-core but not dbt-duckdb, which CI installs.-x(code before the_aws_refusalrefactor)-xtest_dbt_model_contractsx2,test_dbt_semantic_models::TestLiveDbtParse,test_build_runners::...test_uses_configured_dbt_command_prefix_verbatim,test_golden_path_e2e::test_deterministic_lane_golden_path,test_speed_transformation_dbt_e2e::test_forge_generate_dbt_parse_dimensionalfluid_build==origin/main)-x, the 6 deselectedpytest tests -q -x -p no:randomly --deselect <the 6>ValueErrorhandling)tests/iacfiles plus the new file, withPLUGIN_UNIX_SOCKET_DIR=/tmp/wfi3,TF_PLUGIN_CACHE_DIRset and cloud credential sources blanked; against moto and the in-process BigQuery fake onlytest_iac_gcp_governance.py,test_iac_aws_column_restrictions.py, the new file, tofu on PATHfluid validate/fluid generate iacon the Redshift reprorow-filter-unenforceable, nomain.tf.jsonwrittenaws_lakeformation_data_cells_filterwithfilter_expression: consent = truefluid validateon Redshift filter + maskedsilver-zoneexposefluid validateon all 53 example/template contracts, main vs finalvalidate_governanceover 76 contract documents,origin/mainsource vs finalruff check fluid_build/ tests/(ruff 0.16.10)black --check fluid_build/ tests/(24.10.0)python3 tools/product_guardrail/check.py --self-test && python3 tools/product_guardrail/check.pydetect-secrets-hook --baseline .secrets.baselineon the 5 changed filesripwire <clone> --quality-delta(vs HEAD =origin/main)Quality delta, weighed. The 10 gating rows:
short-horizon-churnrows (AwsIacPlugin,emit,lf_row_filters, churn=self).These symbols landed in feat(governance): column masking, row filters and governance labels on AWS and GCP #700 days ago, and the fix has to touch them.
duplicationrows pairTestTheValidateCommand::test_the_same_filter_on_a_glue_table_passes_fluid_validate(31 to 33 tokens: run the CLI, assert exit 0) with the same idiom in
test_iac_gcp_governance.pyandtest_iac_binding_target_resolution.py.duplicationrows pair the test helper_without(a dict-filter comprehension,21 to 26 tokens) with unrelated helpers:
_kwargs,canonical_model_aliases,without_yaml,_only_sensitive_keys.I judged these clone rows false positives. Sharing a helper across unrelated modules
would be a wrong abstraction. One real row I fixed: my first version nested a
tryinvalidate_governanceand took its complexity from 26 to 33 (gating). The_aws_refusalhelper brought it to 27 (minor).lf_row_filterswent from 16 to 18(minor). The remaining rows are new-symbol and do not gate: dead-code on pytest test
methods (collected by pytest), two parallel tests the research wrote, which I kept as
the brief asked,
_contract's keyword parameters, and the length of two test classes.No AWS or GCP call was made. The refusal fires during emit, before any module is
written or any tofu run, so no live test applies.
Risks
binding, or on a Glue format with no database, now fails
fluid validate,generate iacandapply. That is the intent. No contract inexamples/, thetemplates or the test fixtures is affected.
fluid validatenow runslf_row_filtersandlf_masked_viewsfor every awsbinding. A contract with a row filter or a mask may see a stage-2 error it used to
get first at apply, for example
principal-unmappedorcolumn-mask-type.lf_column_exclusionsalready does.It is the contract author's own input, printed to their own console through Rich,
which reads bracketed text as markup (see follow-ups).
runs (17/16 to 18/15). No xfail test touches iac governance; the only xfail markers
under
tests/iacare on emulator e2e tests, which are skipped here.Follow-ups (not in this change)
governance.lakeFormationblock on a non-Glue binding (grants, tags,registerLocation,maskedViews, a hand-writtenrowFilter) is still skippedsilently by
_emit_lakeformation, and so are_check_lf_grant_columns's checks.Refusing it breaks existing contracts, so it needs a decision.
_GLUE_CATALOG_FORMATSomits the schema-validglue_table,athena_table,delta_tableands3_file, and holdsavro,orcanddelta, which the 0.7.6binding enum does not accept. Widening it changes what apply creates and what
fluid diffinspects.fluid validateprints errors through Rich markup, soexposes[profile]renders asexposesand the expose id is lost. This predates the change: the existingcolumn-restriction message loses it the same way.
validate_governancedoes not run the emitter's other refusals:_check_lf_grant_columns,lakeformation-grant-columnsin_emit_lakeformation, andthe pool-prefix check. In its GCP branch, a plain
ValueErrorwould still end thewhole check silently. A broader fix belongs in
cli/validate.py, which shows "checkskipped" only under
--verbose.TestLiveDbtBuildEnforcementneeddbt-duckdb, which CI installsand the local shared venv lacks.
Lake Formation on a Redshift datashare) is future feature work.