diff --git a/drb-c2-core/app/config.py b/drb-c2-core/app/config.py index 06d51f2..1e25913 100644 --- a/drb-c2-core/app/config.py +++ b/drb-c2-core/app/config.py @@ -61,6 +61,15 @@ class Settings(BaseSettings): # against the talkgroup's own anchor instead of stuffing every road in town # into the prompt, so cost scales with location nouns rather than call volume. place_verification_enabled: bool = True + # Raw transcript text in alert payloads (server-26#85). Default CLOSED. + # Board minutes #42 suppress person names on every surface until E&O is + # bound, and an alert webhook is the least recoverable surface there is: + # once the text is in a Discord channel we do not own it, cannot unsend + # it, and cannot audit who read it. This switch is the operator-level + # gate and is deliberately NOT reachable from the app -- the per-org + # opt-in alone would let an org owner self-serve their way to somebody + # else's PII. Both gates must be open before any snippet leaves. + alert_transcript_snippet_enabled: bool = False place_verify_max_per_call: int = 3 # How close a candidate has to sound before it may rewrite a transcript. # Below this, Places Text Search will confidently hand back the nearest diff --git a/drb-c2-core/app/internal/alerter.py b/drb-c2-core/app/internal/alerter.py index cdbc1e3..d4ca3cb 100644 --- a/drb-c2-core/app/internal/alerter.py +++ b/drb-c2-core/app/internal/alerter.py @@ -6,11 +6,15 @@ talkgroup ID, tags, and transcript. On a match: 1. Creates an AlertEvent document in Firestore. 2. Optionally POSTs a Discord webhook message if the rule has one configured. +Raw transcript text is withheld from both by default -- see _snippet_allowed +and server-26#85. + Never raises — failures are logged as warnings so the pipeline always completes. """ import uuid from datetime import datetime, timezone from typing import Optional +from app.config import settings from app.internal.logger import logger from app.internal import firestore as fstore @@ -47,13 +51,17 @@ async def check_and_dispatch( logger.warning(f"Alerter: could not load rules: {e}") return + # Loop-invariant: every rule here belongs to the same org, so the opt-in is + # resolved once rather than per match. + snippet_allowed = await _snippet_allowed(org_id) + for rule in rules: matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript) if not matched_keywords: continue alert_id = str(uuid.uuid4()) - snippet = _snippet(transcript) + snippet = _snippet(transcript) if snippet_allowed else None now = datetime.now(timezone.utc).isoformat() event = { "alert_id": alert_id, @@ -85,6 +93,43 @@ async def check_and_dispatch( await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet) +async def _snippet_allowed(org_id: Optional[str]) -> bool: + """ + Whether raw transcript text may be attached to an alert (server-26#85). + + Two gates, both of which must be open: + + 1. ``settings.alert_transcript_snippet_enabled`` -- the operator switch, + default False, set from the environment and unreachable from the app. + 2. ``alert_snippet_opt_in`` on the org document -- the customer's own + explicit, contractual opt-in. + + Gate 1 exists because gate 2 alone is not a real control: the frontend + reads and (per the Firestore rules, not ``auth.py``) can write org state + directly from the browser, so an org owner could otherwise opt themselves + into receiving person names lifted from live public-safety traffic. Board + minutes #42 suppress names on every surface until E&O is bound. + + Fails CLOSED on any error, and on a call with no org (a pre-tenancy node + that has not been backfilled), because the cost of wrongly withholding a + snippet is a less informative alert and the cost of wrongly emitting one + is unrecallable disclosure to a third party. + """ + if not settings.alert_transcript_snippet_enabled: + return False + if not org_id: + return False + try: + org = await fstore.doc_get("organizations", org_id) + except Exception as e: + logger.warning( + f"Alerter: could not read snippet opt-in for org={org_id}, " + f"withholding transcript: {e}" + ) + return False + return bool((org or {}).get("alert_snippet_opt_in")) + + def _match_rule( rule: dict, talkgroup_id: Optional[int], diff --git a/drb-c2-core/tests/test_alerter_redaction.py b/drb-c2-core/tests/test_alerter_redaction.py new file mode 100644 index 0000000..4cc580b --- /dev/null +++ b/drb-c2-core/tests/test_alerter_redaction.py @@ -0,0 +1,178 @@ +""" +Alert payload redaction — server-26#85. + +Board minutes #42 suppress person names on every surface until E&O is bound. +A Discord webhook is the least recoverable surface the system has: once the +text is in a channel we do not own it, cannot unsend it, and cannot audit who +read it. These tests pin the default-closed behaviour so it cannot regress +quietly the way it shipped. + +The transcript below deliberately contains a person name; every assertion is +"this string did not leave the process", not "some flag was set". +""" +import pytest +from unittest.mock import AsyncMock, patch + +from app.config import settings +from app.internal import alerter + + +TRANSCRIPT = "Units respond, subject identified as Michael Brennan, 42 Elm Street" +ORG = "org-1" +RULE = { + "rule_id": "r1", + "name": "Structure fire", + "enabled": True, + "keywords": ["respond"], + "discord_webhook": "https://discord.example/webhook", +} + + +@pytest.fixture +def captured(monkeypatch): + """Capture what alerter would write to Firestore and POST outbound.""" + saved: list[dict] = [] + posted: list[dict] = [] + + async def _doc_set(collection, doc_id, data, merge=False): + saved.append(data) + + async def _post(url, json=None, **kwargs): + posted.append(json or {}) + + class _R: + status_code = 204 + return _R() + + monkeypatch.setattr(alerter.fstore, "doc_set", _doc_set) + monkeypatch.setattr( + alerter.fstore, "collection_list", AsyncMock(return_value=[dict(RULE)]) + ) + return saved, posted, _post + + +async def _run(captured, org_doc): + saved, posted, _post = captured + with patch.object( + alerter.fstore, + "doc_get", + AsyncMock(side_effect=lambda c, i: {"org_id": ORG} if c == "calls" else org_doc), + ): + client = AsyncMock() + client.post = _post + with patch("httpx.AsyncClient") as ac: + ac.return_value.__aenter__.return_value = client + await alerter.check_and_dispatch( + call_id="c1", + node_id="n1", + talkgroup_id=1, + talkgroup_name="Fire Dispatch", + tags=[], + transcript=TRANSCRIPT, + ) + return saved, posted + + +def _blob(payloads) -> str: + return " ".join(str(p) for p in payloads) + + +@pytest.mark.asyncio +async def test_webhook_carries_no_transcript_by_default(captured): + """The shipped default must not put raw transcript text on the wire.""" + saved, posted = await _run(captured, {}) + + assert posted, "the webhook should still fire — alerting is not disabled, only the text is" + assert "Michael Brennan" not in _blob(posted) + assert "Elm Street" not in _blob(posted) + # The alert is still useful: it names the rule and the talkgroup. + assert "Structure fire" in _blob(posted) + + +@pytest.mark.asyncio +async def test_alert_event_stores_no_transcript_by_default(captured): + """Firestore is a surface too — the frontend reads it directly.""" + saved, _ = await _run(captured, {}) + + assert saved, "the alert event should still be recorded" + assert saved[0]["transcript_snippet"] is None + assert "Michael Brennan" not in _blob(saved) + + +@pytest.mark.asyncio +async def test_org_opt_in_alone_does_not_open_the_gate(captured): + """ + An org owner writing their own org document must not be able to opt + themselves into receiving somebody else's PII. The operator switch is + the control; the org flag is only consent. + """ + assert settings.alert_transcript_snippet_enabled is False + saved, posted = await _run(captured, {"alert_snippet_opt_in": True}) + + assert "Michael Brennan" not in _blob(posted) + assert "Michael Brennan" not in _blob(saved) + + +@pytest.mark.asyncio +async def test_both_gates_open_emits_the_snippet(monkeypatch, captured): + """The opt-in path still works, so this is a gate and not a deletion.""" + monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True) + saved, posted = await _run(captured, {"alert_snippet_opt_in": True}) + + assert "Michael Brennan" in _blob(posted) + assert saved[0]["transcript_snippet"] is not None + + +@pytest.mark.asyncio +async def test_operator_switch_alone_does_not_open_the_gate(monkeypatch, captured): + """Consent is required as well as capability.""" + monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True) + saved, posted = await _run(captured, {}) + + assert "Michael Brennan" not in _blob(posted) + assert saved[0]["transcript_snippet"] is None + + +@pytest.mark.asyncio +async def test_unreadable_org_fails_closed(monkeypatch, captured): + """A Firestore error must withhold the transcript, not default to sending it.""" + monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True) + saved, posted, _post = captured + + async def _doc_get(collection, doc_id): + if collection == "calls": + return {"org_id": ORG} + raise RuntimeError("firestore unavailable") + + with patch.object(alerter.fstore, "doc_get", _doc_get): + client = AsyncMock() + client.post = _post + with patch("httpx.AsyncClient") as ac: + ac.return_value.__aenter__.return_value = client + await alerter.check_and_dispatch( + call_id="c1", node_id="n1", talkgroup_id=1, + talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT, + ) + + assert "Michael Brennan" not in _blob(posted) + assert saved[0]["transcript_snippet"] is None + + +@pytest.mark.asyncio +async def test_pre_tenancy_call_with_no_org_fails_closed(monkeypatch, captured): + """A call with no org_id has nobody who could have consented to anything.""" + monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True) + saved, posted, _post = captured + + with patch.object(alerter.fstore, "doc_get", AsyncMock(return_value={})): + client = AsyncMock() + client.post = _post + with patch("httpx.AsyncClient") as ac: + ac.return_value.__aenter__.return_value = client + await alerter.check_and_dispatch( + call_id="c1", node_id="n1", talkgroup_id=1, + talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT, + ) + + assert "Michael Brennan" not in _blob(posted) + assert saved[0]["transcript_snippet"] is None