Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/compute-gateway/tests/test_masking_policy_values.py
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions deploy/values/compute-gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading