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"