From 59f716c1af78aede1f140c1ae9b45655dd31995c Mon Sep 17 00:00:00 2001 From: mdheller Date: Tue, 4 Aug 2026 03:47:03 -0400 Subject: [PATCH] feat(compute-gateway): turn the read-path masking PDP ON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ PRODUCTION BEHAVIOUR CHANGE. graph-query/graph-stats reads will begin returning masked direct identifiers to callers that previously received them in cleartext. masking.py has been mounted in the dispatch path since it landed, but with no policy configured it returned outputs unchanged. The 2026-08-04 audit found GATEWAY_MASKING_POLICY set nowhere under deploy/ — not a missing mechanism, an unused one. The moat existed and was switched off, so the WKC-parity claim rested on code that never ran. The policy masks direct identifiers on read (email/phone/msisdn hmac_pseudonym, ssn/national_id one_way_hash, patient_mrn chameleon_token, dob generalize, name/street redact) and encodes the no_health_adtech veto as a forbidden_mixture: an adtech-realm requester reading health-topic records is DENIED and the records are WITHHELD, not masked. The masking decision is appended as a sealed ComputeOutput so the Ed25519 receipt attests exactly what the caller received — the property WKC-style dynamic masking cannot produce, because there the enforcement leaves no verifiable artifact. tests/test_masking_policy_values.py parses GATEWAY_MASKING_POLICY out of the values file itself and exercises THAT string, not a fixture written to agree with it. A YAML typo now fails CI instead of silently reverting the gateway to passthrough — which is exactly how this ended up off in the first place. Covered: identifiers masked and non-identifiers preserved, the decision emitted, the veto withholding records, and an unknown scheme failing closed to redact rather than passing raw through. FOUND WHILE TESTING, and asserted rather than assumed: masking._iter_records reads `data["nodes"]` and returns [] for anything else, so a result shaped as rows/table/edges is returned UNMASKED even with this policy active. test_masking_coverage_boundary_is_nodes_only pins that limit so the next person reads it from a failing assertion rather than an incident. Widening it means changing _iter_records, which is a bigger change than turning the policy on and is deliberately not bundled here. 249 compute-gateway tests pass; preflight_deploy_contract green. --- .../tests/test_masking_policy_values.py | 115 ++++++++++++++++++ deploy/values/compute-gateway.yaml | 23 ++++ 2 files changed, 138 insertions(+) create mode 100644 apps/compute-gateway/tests/test_masking_policy_values.py diff --git a/apps/compute-gateway/tests/test_masking_policy_values.py b/apps/compute-gateway/tests/test_masking_policy_values.py new file mode 100644 index 00000000..dd1234fc --- /dev/null +++ b/apps/compute-gateway/tests/test_masking_policy_values.py @@ -0,0 +1,115 @@ +"""The DEPLOYED masking policy must actually mask. + +These tests read `GATEWAY_MASKING_POLICY` out of deploy/values/compute-gateway.yaml — the +literal string the cluster will receive — rather than a fixture written to agree with them. +A policy that is only asserted to be correct in a test fixture proves nothing about the one +that ships, and this whole layer exists because the policy was previously absent and the PDP +silently returned outputs unchanged. A YAML typo must fail CI, not quietly revert the gateway +to passthrough. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[3] +VALUES = ROOT / "deploy" / "values" / "compute-gateway.yaml" +sys.path.insert(0, str(ROOT / "apps" / "compute-gateway" / "src")) + +from compute_gateway import masking # noqa: E402 +from compute_gateway.contract import ComputeOutput # noqa: E402 + + +@pytest.fixture(scope="module") +def deployed_policy() -> dict: + raw = yaml.safe_load(VALUES.read_text(encoding="utf-8"))["config"]["GATEWAY_MASKING_POLICY"] + return json.loads(raw) + + +@pytest.fixture(autouse=True) +def _install_policy(monkeypatch, deployed_policy): + monkeypatch.setenv("GATEWAY_MASKING_POLICY", json.dumps(deployed_policy)) + monkeypatch.delenv("GATEWAY_MASKING_POLICIES", raising=False) + + +def _records(*recs) -> list[ComputeOutput]: + """The PDP walks `data["nodes"]` and nothing else — see test_masking_coverage_boundary.""" + return [ComputeOutput(type="graph", data={"nodes": [dict(r) for r in recs]})] + + +def test_policy_is_parseable_and_non_empty(deployed_policy): + """The failure this guards is a YAML/JSON typo silently disabling enforcement.""" + assert deployed_policy["mask_fields"], "an empty mask_fields set is passthrough with extra steps" + assert deployed_policy["forbidden_mixtures"], "the no_health_adtech veto must be configured" + assert deployed_policy.get("policy_version"), "a decision with no policy version is unattributable" + + +def test_direct_identifiers_are_masked_not_returned_raw(deployed_policy): + out = masking.apply( + _records({"email": "ada@example.com", "ssn": "123-45-6789", "city": "Cambridge"}), + kind="graph-query", project="default", actor="analyst", entitlement=None) + rec = out[0].data["nodes"][0] + assert rec["email"] != "ada@example.com", "email left in cleartext by the deployed policy" + assert rec["ssn"] != "123-45-6789", "ssn left in cleartext by the deployed policy" + # A masking policy that redacts everything is useless; non-identifiers must survive. + assert rec["city"] == "Cambridge" + + +def test_the_masking_decision_is_emitted_as_a_sealed_output(): + """The decision IS the evidence — that is the whole claim against WKC-style masking, + whose enforcement leaves no verifiable artifact.""" + out = masking.apply(_records({"email": "ada@example.com"}), + kind="graph-query", project="default", actor="analyst", entitlement=None) + decisions = [o for o in out if o.type == "masking-decision"] + assert len(decisions) == 1, "no masking-decision output — the enforcement left no artifact" + d = decisions[0].data + assert d["schema_version"] == "identity-prime.masking-decision.v1" + assert d["verdict"] == "allow_masked" + assert d["applied_transforms"], "verdict claims masking but names no transformed field" + + +def test_forbidden_mixture_withholds_the_records_entirely(): + """no_health_adtech is a veto, not a masking rule: the records are withheld, not masked.""" + out = masking.apply( + _records({"topic": "health", "email": "ada@example.com"}), + kind="graph-query", project="default", actor="adtech-bot", entitlement="adtech") + assert len(out) == 1 and out[0].type == "masking-decision", \ + "records survived a forbidden identity mixture — the veto did not withhold them" + assert out[0].data["verdict"] == "deny" + assert out[0].data["forbidden_mixture"] + + +def test_unknown_scheme_fails_closed(monkeypatch, deployed_policy): + """Fail-closed under a bad policy: an unrecognised scheme must redact, never pass raw + through. A typo in a scheme name is otherwise an invisible hole.""" + broken = json.loads(json.dumps(deployed_policy)) + broken["mask_fields"]["email"] = "not-a-real-scheme" + monkeypatch.setenv("GATEWAY_MASKING_POLICY", json.dumps(broken)) + out = masking.apply(_records({"email": "ada@example.com"}), + kind="graph-query", project="default", actor="analyst", entitlement=None) + assert out[0].data["nodes"][0]["email"] != "ada@example.com" + + +def test_non_read_kinds_are_untouched(): + """The PDP governs reads. A write path must not be silently rewritten by it.""" + assert "notebook" not in masking.READ_KINDS + + +def test_masking_coverage_boundary_is_nodes_only(): + """KNOWN LIMIT, asserted so it cannot be mistaken for coverage. + + masking._iter_records reads `data["nodes"]` and returns [] for anything else, so a result + shaped as rows / table / edges is returned UNMASKED even with this policy active. That is + the current boundary of the moat, not a claim about it. Widening it means changing + _iter_records, which is a larger change than turning the policy on; this test exists so + the next person reads the boundary from a failing assertion rather than from an incident. + """ + rows = [ComputeOutput(type="table", data={"rows": [{"email": "ada@example.com"}]})] + out = masking.apply(rows, kind="graph-query", project="default", + actor="analyst", entitlement=None) + assert out[0].data["rows"][0]["email"] == "ada@example.com", \ + "row-shaped outputs are now masked — good; update this test and the PR note" diff --git a/deploy/values/compute-gateway.yaml b/deploy/values/compute-gateway.yaml index 98550b45..08f34fa7 100644 --- a/deploy/values/compute-gateway.yaml +++ b/deploy/values/compute-gateway.yaml @@ -21,6 +21,29 @@ config: COMPUTE_ENTITLEMENTS: "demo,default,graph-query,graph-stats,gyg-reporting,materialize,governance,engine-seal" # every ok run writes its ComputeRun/Receipt subgraph to hellgraph (best-effort). GATEWAY_WRITE_PROVENANCE: "true" + # READ-PATH MASKING PDP — ON. Until now this was code that never ran: masking.py has been + # mounted in the dispatch path since it landed, but with no policy configured it returned + # outputs unchanged, so the moat existed and was switched off. The 2026-08-04 catalog/masking + # audit found GATEWAY_MASKING_POLICY set nowhere in deploy/ — not a missing mechanism, an + # unused one. + # + # Applies to graph-query/graph-stats reads only (masking.READ_KINDS). Direct identifiers are + # masked before the outputs are sealed, so the Ed25519 receipt attests exactly what the + # caller received, and the masking decision is appended as a sealed output — the property + # WKC-style dynamic masking does not produce, because there the enforcement leaves no + # verifiable artifact. + # + # forbidden_mixtures encodes the no_health_adtech veto: a requester whose realm carries the + # adtech topic reading records that carry the health topic is DENIED outright and the records + # are withheld, not merely masked. + # + # The exact JSON below is parsed out of this file and exercised by + # apps/compute-gateway/tests/test_masking_policy_values.py, so a typo here fails CI rather + # than silently reverting the gateway to passthrough. A policy nobody tests is a policy that + # is one YAML mistake away from being off again. + GATEWAY_MASKING_POLICY: >- + {"policy_version":"masking-v1-2026-08-04","decided_by":"prophet-platform/compute-gateway","record_topic_fields":["topic","domain"],"requesting_realm_topics":{"adtech":"adtech","marketing":"adtech"},"forbidden_mixtures":[["health","adtech"]],"mask_fields":{"email":"hmac_pseudonym","email_address":"hmac_pseudonym","phone":"hmac_pseudonym","msisdn":"hmac_pseudonym","ssn":"one_way_hash","national_id":"one_way_hash","patient_mrn":"chameleon_token","date_of_birth":"generalize","dob":"generalize","full_name":"redact","street_address":"redact"}} + # Durable proof store: receipts + content-addressed artifacts + the doc→SQL sink land on # the PVC mounted here, so a governed run survives a restart (compute-gateway persistence). GATEWAY_STORE_DIR: "/data"