Skip to content

security(dci): scope notification delivery per subscriber (consent + filter)#258

Open
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-dci-notify-pii
Open

security(dci): scope notification delivery per subscriber (consent + filter)#258
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-dci-notify-pii

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Summary

The DCI Social Registry notification pipeline leaked PII to overbroad subscribers. res.partner create/update/delete built full DCI person/group records via DCISocialSearchService with no sender context and notify_event fanned the identical payload out to every subscription matched only on event/registry/active/expiry. The sender's stored filter_expression was never applied, consent/legal-basis was never checked, and sender.auto_approve removed the human gate. Net: any active/auto-approved subscriber to a broad event (REGISTRATION/UPDATE) received the full PII record (names, birthdate, gender, phone, email, address, household, programme enrollments) for every changed registrant — outside its requested filter, allowed fields, consent, or legal basis.

Fix — per-subscription, sender-scoped delivery

Build and queue the payload per subscription, scoped to that subscription's sender, instead of once globally.

spp_dci_server

  • New filter_type field on spp.dci.subscription (the discriminator the subscribe handler previously dropped), now captured at subscribe time.
  • Generic eligibility helpers: _consent_allows_partner (uses the correct primitive — has_legal_basis_bypass / can_access_registrantcheck_api_consent; not build_consented_domain, which filters the non-existent status == "active" and matches nothing), _partner_matches_filter (base = fail-closed for any unparseable filter), _eligible_partner_ids, _build_notification_records (registry hook), _delete_records_for.
  • notify_event(event_type, partner_ids, reg_type, delete_payloads=None) — per matching subscription, delivers only consent-allowed + filter-matching records, built with that subscription's sender context.

spp_dci_server_social

  • Overrides _partner_matches_filter (compiles the stored filter via the search service and tests the partner; drops on parse failure) and _build_notification_records (builds with sender_id context).
  • _dci_delete_payloads snapshots per-subscription eligibility at unlink (record still exists) so delete notifications reach only eligible subscribers; _execute_dci_notification hands off partner_ids / scoped delete payloads instead of a single global payload.

Decisions (with maintainer)

  • Legal-basis-bypass senders (legal_obligation/public_interest/…) pass consent by design for interop; filter_expression still applies. Documented that auto_approve + bypass = full firehose by design.
  • Delete path scoped via unlink-time eligibility snapshot.

Deferred / tracked separately (not this PR)

  • Layer C — field minimization: persist + enforce response_fields (also dropped at subscribe time) so eligible senders receive only authorised fields. Records currently carry the full field set for eligible senders.
  • Adjacent bug: the search path's consent filter (build_consented_domain / _apply_consent_filter) uses the same dead status == "active" predicate — it returns nothing for consent-required senders and would flip open if naively "fixed." Worth its own ticket.

Tests

Updated the 4 tests affected by the notify_event signature/behavior change (delete payloads moved to a kwarg; legacy delete without an eligibility snapshot now fail-closed). Added a scoping suite: consent blocks without basis; legal-basis bypass allows; filter match / non-match / unparseable-fail-closed / no-filter; record building with sender context; delete eligibility include/exclude.

All green: spp_dci_server 320/320 · spp_dci_server_social 82/82. Lint-clean (ruff/ruff-format).

…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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the DCI subscription notification system to scope delivery per subscriber, ensuring that notifications are only sent to authorized senders and match their subscription filters. It introduces registry-specific hooks for consent checks, filter evaluation, and payload building, and implements these hooks for the social registry. Additionally, delete notifications now precompute eligibility before records are unlinked. The review feedback highlights a performance bottleneck where evaluating filters individually for each partner ID leads to an O(N * M) database query pattern. To resolve this, the reviewer suggests implementing a batched filter evaluation helper (_filter_matching_partners) in both the base subscription model and the social registry subclass to process all partner IDs in a single query.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread spp_dci_server/models/subscription.py Outdated
Comment thread spp_dci_server_social/models/dci_subscription_social.py Outdated
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.34951% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.23%. Comparing base (f04d936) to head (7465ae5).

Files with missing lines Patch % Lines
...ci_server_social/models/dci_subscription_social.py 85.45% 8 Missing ⚠️
spp_dci_server/models/subscription.py 89.74% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #258      +/-   ##
==========================================
+ Coverage   74.75%   75.23%   +0.48%     
==========================================
  Files        1090     1093       +3     
  Lines       64813    64954     +141     
==========================================
+ Hits        48453    48871     +418     
+ Misses      16360    16083     -277     
Flag Coverage Δ
spp_base_common 90.26% <ø> (ø)
spp_dci_client_dr 85.60% <ø> (+31.98%) ⬆️
spp_dci_client_ibr 92.27% <ø> (+31.97%) ⬆️
spp_dci_compliance 92.76% <ø> (ø)
spp_dci_indicators 95.61% <ø> (ø)
spp_dci_server 90.25% <89.74%> (-0.03%) ⬇️
spp_dci_server_social 89.63% <87.50%> (+1.18%) ⬆️
spp_programs 65.12% <ø> (ø)
spp_registry 86.83% <ø> (ø)
spp_security 66.66% <ø> (ø)
spp_starter_farmer_registry 0.00% <ø> (?)
spp_starter_social_registry 0.00% <ø> (ø)
spp_starter_sp_mis 81.25% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_dci_server/routers/async_router.py 92.39% <ø> (ø)
spp_dci_server_social/models/__init__.py 100.00% <100.00%> (ø)
...dci_server_social/models/res_partner_dci_notify.py 91.35% <100.00%> (+7.68%) ⬆️
spp_dci_server/models/subscription.py 91.70% <89.74%> (-0.65%) ⬇️
...ci_server_social/models/dci_subscription_social.py 85.45% <85.45%> (ø)

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
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.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Staff/adversarial review of the implemented branch (post-Gemini refactor) found one HIGH issue, now fixed in 7465ae58:

  • The social filter evaluator defaulted query_type to "expression" when filter_type was unset. Since the DCI client never sends filter_type (and defaults its filter to {"type":"*","value":"*"}), a stored idtype-value filter for 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 doesn't backstop), this re-introduced the unscoped PII fan-out this PR fixes.

Fix: 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 and fail closed when the discriminator is missing/unknown rather than guessing. Added regressions: real idtype-value filter without filter_type → matches nothing; wildcard without filter_type → matches all. spp_dci_server_social 84/84.

The rest of the implementation (consent-then-filter ordering, per-subscriber scoped build, fail-closed delete snapshot, base hooks denying for non-overridden registries) was confirmed sound by the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant