Skip to content

Commit 9102547

Browse files
security(dci): scope notification delivery per subscriber (consent + filter) (#258)
* security(dci): scope notification delivery per subscriber (consent + filter) The DCI notification pipeline built full DCI person/group records with no sender context and fanned the identical payload out to every subscription matched only on event/registry/active/expiry. Stored filter_expression was never applied, consent was never checked, and auto_approve removed the human gate -- so any active/auto-approved subscriber to a broad event received full PII (names, birthdate, address, phone, email, household, programmes) for every changed registrant, outside its requested filter, consent, or legal basis. Make delivery per-subscription and sender-scoped: - spp_dci_server: capture filter_type at subscribe time; add eligibility helpers on spp.dci.subscription -- _consent_allows_partner (correct primitive: has_legal_basis_bypass / can_access_registrant; NOT the dead status=="active" domain), _partner_matches_filter (base fail-closed), _eligible_partner_ids, _build_notification_records (hook), _delete_records_for. notify_event now takes (event_type, partner_ids, reg_type, delete_payloads) and, per matching subscription, delivers only consent-allowed + filter-matching records built with that subscription's sender context. - spp_dci_server_social: override _partner_matches_filter (compile the stored filter via the search service; drop on parse failure) and _build_notification_records (build with sender_id). Snapshot per-subscription eligibility at unlink so delete notifications go only to eligible subscribers; _execute_dci_notification hands off partner_ids / scoped delete payloads instead of a single global payload. Decisions: legal-basis-bypass senders pass consent (filter still applies), documented; delete scoped via unlink-time eligibility snapshot. Deferred to follow-ups: field minimization (response_fields / layer C), and the parallel dead consent filter on the search path (build_consented_domain status=="active"). Tests: spp_dci_server 320/320, spp_dci_server_social 82/82. * security(dci): batch filter eval + cover notification scoping helpers Address review on PR #258: - Gemini (perf): replace per-partner filter evaluation (O(partners x subscriptions) queries) with a batched `_filter_matching_partners(partner_ids)` that evaluates the filter in a single query per subscription. The social override now intersects partner_ids with the compiled filter domain in one search; `_partner_matches_filter` is a thin wrapper; `_eligible_partner_ids` applies consent per record then filters in one batch. - Codecov: add generic unit tests for the new subscription helpers (_filter_matching_partners base policy, _partner_matches_filter wrapper, _consent_allows_partner bypass, _eligible_partner_ids, _delete_records_for, notify_event delete scoping) that the generic spp_dci_server test DB exercises. Tests: spp_dci_server 326/326, spp_dci_server_social 82/82. * security(dci): fail closed on missing filter_type (staff-review fix) Staff review of the implemented branch caught a HIGH over-delivery regression: the social filter evaluator defaulted query_type to "expression" when filter_type was unset. The DCI client never sends filter_type, so a stored idtype-value filter (a specific person) was parsed as an expression with no seq/or keys -> empty domain -> matched EVERY registrant in the batch. For legal-basis-bypass senders (consent does not backstop) this re-introduced the full unscoped PII fan-out the PR set out to fix. Fix: in _filter_matching_partners, treat the SPDCI {"type":"*","value":"*"} wildcard as match-all (the client's "subscribe to all" default; consent still gates), but for any other filter require a known filter_type (idtype-value/expression/predicate) and fail closed (match nothing) when the discriminator is missing/unknown rather than guessing. Tests: add regressions for the two cases that would have caught this - real idtype-value filter without filter_type -> matches nothing; wildcard filter without filter_type -> matches all. spp_dci_server_social 84/84.
1 parent e495ee5 commit 9102547

7 files changed

Lines changed: 449 additions & 75 deletions

File tree

spp_dci_server/models/subscription.py

Lines changed: 107 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ class DCISubscription(models.Model):
9696
string="Filter Expression",
9797
help="Optional DCI expression to filter which events trigger notifications",
9898
)
99+
filter_type = fields.Char(
100+
string="Filter Type",
101+
help=(
102+
"How to interpret filter_expression (DCI query_type: idtype-value, "
103+
"expression, predicate). Required to evaluate the filter at notify time; "
104+
"an unparseable/unknown filter is treated as non-matching (fail closed)."
105+
),
106+
)
99107

100108
# Subscription State
101109
active = fields.Boolean(
@@ -195,28 +203,101 @@ def check_expired_subscriptions(self):
195203
_logger.info("Deactivated %d expired subscriptions", len(expired))
196204
return True
197205

198-
def notify_event(self, event_type: str, records: list, reg_type: str):
199-
"""Queue notifications for matching subscriptions.
206+
@api.model
207+
def _matching_subscriptions(self, event_type: str, reg_type: str):
208+
"""Active, unexpired subscriptions for an event/registry type."""
209+
return self.search(
210+
[
211+
("event_type", "=", event_type),
212+
("reg_type", "=", reg_type),
213+
("active", "=", True),
214+
("state", "=", "active"),
215+
"|",
216+
("expires", "=", False),
217+
("expires", ">", fields.Datetime.now()),
218+
]
219+
)
200220

201-
Called when a registry event occurs (registration, update, delete).
221+
def _consent_allows_partner(self, partner_id: int) -> bool:
222+
"""Whether this subscription's sender may receive this registrant's data.
202223
203-
Args:
204-
event_type: Type of event (registration, update, delete)
205-
records: List of affected record data dicts
206-
reg_type: Registry type (SOCIAL_REGISTRY, DR, etc.)
224+
Uses the proper consent primitive (legal-basis bypass, or per-registrant
225+
consent via the API consent service). Fail-closed when no sender.
207226
"""
208-
# Find matching active subscriptions
209-
domain = [
210-
("event_type", "=", event_type),
211-
("reg_type", "=", reg_type),
212-
("active", "=", True),
213-
("state", "=", "active"),
214-
"|",
215-
("expires", "=", False),
216-
("expires", ">", fields.Datetime.now()),
227+
self.ensure_one()
228+
from ..services.consent_adapter import DCIConsentAdapter
229+
230+
adapter = DCIConsentAdapter(self.env, self.sender_id)
231+
if adapter.has_legal_basis_bypass():
232+
return True
233+
return adapter.can_access_registrant(partner_id)
234+
235+
def _filter_matching_partners(self, partner_ids: list) -> list:
236+
"""Subset of partner_ids matching this subscription's filter_expression.
237+
238+
Base policy: no filter -> all; a filter that this (generic) layer cannot
239+
evaluate -> none (fail closed). Registry modules override this to
240+
evaluate their filter shape in a single batched query (avoiding an
241+
O(partners x subscriptions) per-record query pattern).
242+
"""
243+
self.ensure_one()
244+
if not self.filter_expression:
245+
return list(partner_ids or [])
246+
return []
247+
248+
def _partner_matches_filter(self, partner_id: int) -> bool:
249+
"""Whether a single partner matches this subscription's filter."""
250+
self.ensure_one()
251+
return bool(self._filter_matching_partners([partner_id]))
252+
253+
def _eligible_partner_ids(self, partner_ids: list) -> list:
254+
"""Subset of partner_ids this subscription is allowed to be told about.
255+
256+
Applies consent per record, then the filter in a single batch.
257+
"""
258+
self.ensure_one()
259+
if not partner_ids:
260+
return []
261+
consented = [pid for pid in partner_ids if self._consent_allows_partner(pid)]
262+
return self._filter_matching_partners(consented)
263+
264+
def _build_notification_records(self, partner_ids: list) -> list:
265+
"""Build the DCI record payloads for partner_ids (registry-specific).
266+
267+
Base returns nothing; registry server modules (e.g. spp_dci_server_social)
268+
override this to materialise records with this subscription's sender scope.
269+
"""
270+
self.ensure_one()
271+
return []
272+
273+
def _delete_records_for(self, delete_payloads: list) -> list:
274+
"""Identifier payloads this subscription is eligible to receive on delete.
275+
276+
Eligibility is precomputed at unlink time (while the record still exists)
277+
and carried per payload as ``eligible_subscription_ids``.
278+
"""
279+
self.ensure_one()
280+
return [
281+
{"identifiers": p.get("identifiers", [])}
282+
for p in (delete_payloads or [])
283+
if self.id in (p.get("eligible_subscription_ids") or [])
217284
]
218-
subscriptions = self.search(domain)
219285

286+
def notify_event(self, event_type: str, partner_ids: list, reg_type: str, delete_payloads: list = None):
287+
"""Queue notifications for matching subscriptions, scoped per subscriber.
288+
289+
Each matching subscription only receives records its sender is permitted
290+
to see (consent/legal-basis) and that match its filter_expression; the
291+
payload is built with that subscription's sender context.
292+
293+
Args:
294+
event_type: Event type (registration, update, delete)
295+
partner_ids: Affected res.partner ids (create/update). Ignored for delete.
296+
reg_type: Registry type (SOCIAL_REGISTRY, DR, ...)
297+
delete_payloads: For delete, per-record identifier payloads carrying
298+
precomputed ``eligible_subscription_ids`` (records are gone by now).
299+
"""
300+
subscriptions = self._matching_subscriptions(event_type, reg_type)
220301
_logger.info(
221302
"Found %d subscriptions for event %s/%s",
222303
len(subscriptions),
@@ -225,7 +306,15 @@ def notify_event(self, event_type: str, records: list, reg_type: str):
225306
)
226307

227308
for sub in subscriptions:
228-
# Queue notification job for each subscription
309+
if event_type == "delete":
310+
records = sub._delete_records_for(delete_payloads or [])
311+
else:
312+
eligible = sub._eligible_partner_ids(partner_ids or [])
313+
records = sub._build_notification_records(eligible) if eligible else []
314+
315+
if not records:
316+
continue
317+
229318
sub.with_delay(
230319
channel="dci",
231320
timeout=60,

spp_dci_server/routers/async_router.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ async def subscribe(
302302
"event_type": event_type,
303303
"reg_type": reg_type,
304304
"filter_expression": filter_expression,
305+
"filter_type": req_item.subscribe_criteria.filter_type,
305306
"original_message_id": envelope.header.message_id,
306307
"original_transaction_id": sub_request.transaction_id,
307308
"state": "pending",

spp_dci_server/tests/test_subscription.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,15 +269,21 @@ def test_notify_event_finds_matching_subscriptions(self):
269269
}
270270
)
271271

272-
# Patch with_delay at the model class level to avoid read-only attribute error
273-
with patch.object(type(self.Subscription), "with_delay") as mock_delay:
274-
mock_job = MagicMock()
275-
mock_delay.return_value = mock_job
272+
# notify_event now scopes per subscription: it queues a job only when the
273+
# subscription's sender is eligible AND records can be built. The generic
274+
# layer builds no records (registry modules do), so stub those hooks to
275+
# verify the matching/queueing orchestration.
276+
with (
277+
patch.object(type(self.Subscription), "with_delay") as mock_delay,
278+
patch.object(type(self.Subscription), "_eligible_partner_ids", return_value=[1]),
279+
patch.object(type(self.Subscription), "_build_notification_records", return_value=[{"id": "rec-001"}]),
280+
):
281+
mock_delay.return_value = MagicMock()
276282

277283
# Trigger notification
278284
self.Subscription.notify_event(
279285
event_type="registration",
280-
records=[{"id": "rec-001", "name": "Test"}],
286+
partner_ids=[1],
281287
reg_type="SOCIAL_REGISTRY",
282288
)
283289

@@ -302,13 +308,73 @@ def test_notify_event_ignores_inactive(self):
302308
# Trigger notification
303309
self.Subscription.notify_event(
304310
event_type="registration",
305-
records=[{"id": "rec-001"}],
311+
partner_ids=[1],
306312
reg_type="SOCIAL_REGISTRY",
307313
)
308314

309315
# Should not have been called for inactive subscription
310316
mock_delay.assert_not_called()
311317

318+
def _make_sub(self, **vals):
319+
base = {
320+
"sender_id": self.test_sender.id,
321+
"event_type": "update",
322+
"reg_type": "SOCIAL_REGISTRY",
323+
"state": "active",
324+
}
325+
base.update(vals)
326+
return self.Subscription.create(base)
327+
328+
def test_filter_matching_partners_base_policy(self):
329+
"""Generic layer: no filter -> all; an (unparseable here) filter -> none."""
330+
self.assertEqual(self._make_sub()._filter_matching_partners([1, 2]), [1, 2])
331+
# A present filter cannot be evaluated by the generic layer -> fail closed.
332+
self.assertEqual(self._make_sub(filter_expression="{}")._filter_matching_partners([1, 2]), [])
333+
334+
def test_partner_matches_filter_wrapper(self):
335+
self.assertTrue(self._make_sub()._partner_matches_filter(1))
336+
self.assertFalse(self._make_sub(filter_expression="{}")._partner_matches_filter(1))
337+
338+
def test_consent_allows_partner_with_legal_basis_bypass(self):
339+
self.test_sender.write({"legal_basis": "legal_obligation"})
340+
# Bypass short-circuits before any per-registrant consent lookup.
341+
self.assertTrue(self._make_sub()._consent_allows_partner(999999))
342+
343+
def test_eligible_partner_ids_consent_then_filter(self):
344+
self.test_sender.write({"legal_basis": "legal_obligation"})
345+
self.assertEqual(self._make_sub()._eligible_partner_ids([1, 2, 3]), [1, 2, 3])
346+
# Present-but-unevaluable filter drops everything even with consent.
347+
self.assertEqual(self._make_sub(filter_expression="{}")._eligible_partner_ids([1, 2, 3]), [])
348+
349+
def test_delete_records_for_scopes_by_eligibility(self):
350+
sub = self._make_sub(event_type="delete")
351+
payloads = [
352+
{"identifiers": [{"identifier_value": "A"}], "eligible_subscription_ids": [sub.id]},
353+
{"identifiers": [{"identifier_value": "B"}], "eligible_subscription_ids": [sub.id + 999]},
354+
]
355+
self.assertEqual(sub._delete_records_for(payloads), [{"identifiers": [{"identifier_value": "A"}]}])
356+
357+
def test_notify_event_delete_scoped_to_eligible(self):
358+
sub = self._make_sub(event_type="delete")
359+
with patch.object(type(self.Subscription), "with_delay") as mock_delay:
360+
mock_delay.return_value = MagicMock()
361+
self.Subscription.notify_event(
362+
"delete",
363+
[],
364+
"SOCIAL_REGISTRY",
365+
delete_payloads=[{"identifiers": [], "eligible_subscription_ids": [sub.id]}],
366+
)
367+
mock_delay.assert_called()
368+
369+
with patch.object(type(self.Subscription), "with_delay") as mock_delay_none:
370+
self.Subscription.notify_event(
371+
"delete",
372+
[],
373+
"SOCIAL_REGISTRY",
374+
delete_payloads=[{"identifiers": [], "eligible_subscription_ids": [sub.id + 999]}],
375+
)
376+
mock_delay_none.assert_not_called()
377+
312378
def test_build_notification_structure(self):
313379
"""Test notification envelope structure."""
314380
sub = self.Subscription.create(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22

33
from . import fastapi_endpoint_social
4+
from . import dci_subscription_social
45
from . import res_partner_dci_notify
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Social Registry scoping for DCI event subscriptions.
3+
4+
Implements the registry-specific hooks the generic subscription model uses to
5+
deliver notifications per subscriber: evaluating the subscription's stored
6+
filter against a registrant, and building the DCI record payload with the
7+
subscription's sender context (so consent/sender-scoped serialization applies).
8+
"""
9+
10+
import json
11+
import logging
12+
from types import SimpleNamespace
13+
14+
from odoo import models
15+
16+
_logger = logging.getLogger(__name__)
17+
18+
_SOCIAL = "SOCIAL_REGISTRY"
19+
20+
21+
class DCISubscriptionSocial(models.Model):
22+
_inherit = "spp.dci.subscription"
23+
24+
def _filter_matching_partners(self, partner_ids):
25+
"""Evaluate the subscription's filter_expression against registrants in batch.
26+
27+
No filter -> all. Otherwise the stored filter (with its filter_type
28+
discriminator) is compiled to a domain via the Social Registry search
29+
service and intersected with partner_ids in a single query. Any
30+
parse/eval failure is treated as matching NOTHING (fail closed) so an
31+
unparseable filter never widens delivery.
32+
"""
33+
self.ensure_one()
34+
if self.reg_type != _SOCIAL:
35+
return super()._filter_matching_partners(partner_ids)
36+
if not self.filter_expression:
37+
return list(partner_ids or [])
38+
if not partner_ids:
39+
return []
40+
41+
try:
42+
raw_filter = json.loads(self.filter_expression)
43+
except Exception as e:
44+
_logger.warning(
45+
"Subscription %s: unparseable filter, dropping records (fail-closed): %s",
46+
self.subscription_code,
47+
e,
48+
)
49+
return []
50+
51+
# The SPDCI "match all" wildcard means no real filter -> deliver all
52+
# (consent still gates non-bypass senders).
53+
if self._is_match_all_filter(raw_filter):
54+
return list(partner_ids)
55+
56+
# A real filter needs its discriminator to be interpreted correctly.
57+
# Missing/unknown filter_type must NOT be guessed: defaulting to
58+
# "expression" silently collapses an idtype-value filter to "all
59+
# registrants" (over-delivery). Fail closed instead.
60+
query_type = (self.filter_type or "").strip().lower()
61+
if query_type not in ("idtype-value", "expression", "predicate"):
62+
_logger.warning(
63+
"Subscription %s: filter present but filter_type is %r; dropping records (fail-closed)",
64+
self.subscription_code,
65+
self.filter_type,
66+
)
67+
return []
68+
69+
try:
70+
from ..services.search_service import DCISocialSearchService
71+
72+
criteria = SimpleNamespace(query_type=query_type, query=raw_filter)
73+
service = DCISocialSearchService(self.env, self.sender_id)
74+
domain = service._build_domain(criteria)
75+
# sudo: matching the sender's declared filter against the registry;
76+
# consent/authorization is enforced separately by _consent_allows_partner.
77+
# nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context
78+
Partner = self.env["res.partner"].sudo()
79+
return Partner.search([("id", "in", list(partner_ids))] + domain).ids
80+
except Exception as e:
81+
_logger.warning(
82+
"Subscription %s: could not evaluate filter, dropping records (fail-closed): %s",
83+
self.subscription_code,
84+
e,
85+
)
86+
return []
87+
88+
@staticmethod
89+
def _is_match_all_filter(raw_filter):
90+
"""SPDCI clients use {"type": "*", "value": "*"} to mean 'all events'."""
91+
return isinstance(raw_filter, dict) and raw_filter.get("type") == "*" and raw_filter.get("value") == "*"
92+
93+
def _build_notification_records(self, partner_ids):
94+
"""Build DCI records for partner_ids using this subscription's sender.
95+
96+
Called only for already-eligible partners (consent + filter checked by
97+
the generic layer). Builds with sender context so any sender-scoped
98+
serialization applies.
99+
"""
100+
self.ensure_one()
101+
if self.reg_type != _SOCIAL:
102+
return super()._build_notification_records(partner_ids)
103+
104+
from ..services.search_service import DCISocialSearchService
105+
106+
service = DCISocialSearchService(self.env, self.sender_id)
107+
partners = self.env["res.partner"].browse(partner_ids).exists()
108+
records = []
109+
for partner in partners:
110+
try:
111+
dci_record = service._to_dci_group(partner) if partner.is_group else service._to_dci_person(partner)
112+
records.append(dci_record.model_dump(mode="json", by_alias=True, exclude_none=True))
113+
except Exception as e:
114+
_logger.warning("Failed to convert partner %d to DCI format: %s", partner.id, e)
115+
return records

0 commit comments

Comments
 (0)