From 0635de8dacf111288e0bf5f1c70eae9b5a8902dc Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sat, 29 Aug 2026 02:42:31 -0400 Subject: [PATCH] Stop alert webhooks putting raw transcripts in a third-party channel Alert dispatch attached a 200-character raw transcript snippet to the alert_events document and POSTed the same text to the org's Discord webhook, with no redaction of any kind. Board minutes #42 ratified that person names are suppressed on every surface until E&O is bound, and a Discord channel is the least recoverable surface there is: once the text lands we do not own it, cannot unsend it, and cannot audit who read it. Raw transcript text now requires two independent gates, both closed by default: 1. alert_transcript_snippet_enabled -- an operator switch in config, set from the environment. 2. alert_snippet_opt_in on the org document -- the customer's own explicit consent. Gate 1 is not redundant. The frontend reads and writes Firestore directly from the browser, so the org flag alone would let an org owner opt themselves into receiving person names lifted from live public-safety traffic. Capability is the operator's to grant; consent is the org's. The gate fails closed on a Firestore error and on a call with no org (a pre-tenancy node that has not been backfilled) -- a less informative alert is cheap, an unrecallable disclosure is not. Alerting itself is unchanged: the webhook still fires and still names the rule, the talkgroup and the matched keywords. This does not wait on the Gate B3 redactor (#43, 2026-09-30). The snippet was a convenience field and needed no redactor to withhold. Tests assert the person name in a sample transcript does not appear in either the outbound payload or the Firestore write, in every combination of the two gates. Closes server-26#85. Refs #42, #43, #48. --- drb-c2-core/app/config.py | 9 + drb-c2-core/app/internal/alerter.py | 47 +++++- drb-c2-core/tests/test_alerter_redaction.py | 178 ++++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 drb-c2-core/tests/test_alerter_redaction.py 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