Stop alert webhooks putting raw transcripts in a third-party channel
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 1m44s
Build & Deploy / Report a failed deploy (push) Skipped

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.
This commit is contained in:
Logan Cusano
2026-08-29 02:42:31 -04:00
parent 3df427f914
commit 0635de8dac
3 changed files with 233 additions and 1 deletions
+178
View File
@@ -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