Skip to content

security(gis): restrict spatial stats to GIS-published indicators#359

Draft
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-gis-stats-unpublished-variables
Draft

security(gis): restrict spatial stats to GIS-published indicators#359
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-gis-stats-unpublished-variables

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Summary

The GIS spatial/proximity statistics path accepts request.variables from the
API and passes it straight to the analytics aggregation service, which resolves
the supplied names via sudo() against all spp.indicator records and then
all spp.cel.variable records — with no publication check. Only the default
(no-variables) path limited computation to get_published_for_context("gis").
So a caller with only gis:read/statistics:read could request unpublished
indicator names, or raw CEL variable names never surfaced as a GIS indicator,
and receive their aggregate counts/values — bypassing the intended GIS
publication boundary and potentially exposing sensitive beneficiary aggregates
(k-anonymity mitigates but does not close this).

Fix

Enforce the publication boundary as a positive allowlist (default-deny) at
the single chokepoint every query path funnels through
(spatial_query_service._compute_via_aggregation_service): compute the
GIS-published indicator names (spp.indicator.is_published_gis, via
get_published_for_context("gis")) and restrict both the supplied and
default paths to that set. Unknown/unpublished names — including raw CEL
variable names — are dropped before compute_aggregation, so the registry's
sudo resolution and the downstream metadata lookup only ever see published
names.

Dropping (not erroring) matches the existing "unknown variables → valid
response" contract (tests/test_batch_query.py) and avoids an existence oracle
(a client can't distinguish unpublished-but-existing from nonexistent).
total_count is a separate response field, so the basic area count is
unaffected.

Testing

  • ./spp t spp_api_v2_gis195 passed, 0 failed, 0 errors (4 new).
  • New tests: an unpublished indicator name, and a raw CEL variable name, are NOT
    computed (both fail before the fix); a published-GIS indicator name IS
    computed; the default path still returns the published set only.

Scope / follow-up

  • GIS-service only (per owner decision): the shared spp_analytics registry
    resolves names context-agnostically and is used by other contexts
    (dashboard/api/report) that may intentionally compute non-GIS stats, so the
    GIS boundary belongs in the GIS caller (mirrors the default path). I'll sweep
    the other API contexts for the same "user variables → compute_aggregation
    without an allowlist" pattern and file a follow-up if found.
  • No migration: behavior/authorization fix, no schema change (module is released
    at 19.0.2.0.0, but access/compute logic changes need no data migration).
  • No overlap with other in-flight security PRs.
  • README generated files left to CI's pinned oca-gen.

Draft: awaiting human security review before undraft/merge.

Client-supplied request.variables were passed straight to the aggregation
service, which resolves names via sudo() against ALL spp.indicator and ALL
spp.cel.variable records with no publication check. Only the default (no
variables) path limited to get_published_for_context('gis'). So a caller with
only gis:read/statistics:read could request unpublished indicator names, or raw
CEL variable names, and receive their aggregates - bypassing the intended GIS
publication boundary.

Enforce the boundary as a positive allowlist at the single chokepoint
(_compute_via_aggregation_service): filter supplied variables to the
GIS-published indicator names (is_published_gis), same set as the default path;
drop unknown/unpublished names before aggregation (matching the existing
'unknown variables -> valid response' contract, oracle-free). The registry
sudo-resolution and downstream metadata lookup therefore only ever see
published names.

Bump 19.0.2.0.0 -> 19.0.2.0.1 + HISTORY. No migration (behavior fix, no schema
change). 195 tests (4 new).
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Promised sweep done: the same pattern (user-supplied statistic names → compute_aggregation under sudo, no publication allowlist) exists in the simulation analytics API (spp_api_v2_simulation, context="api"). Filed as #360 (Medium) — kept out of this GIS-scoped PR per the fix-scope decision. No other external callers of compute_aggregation found.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 69.08%. Comparing base (1caf794) to head (004a081).
⚠️ Report is 3 commits behind head on 19.0.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #359      +/-   ##
==========================================
- Coverage   74.28%   69.08%   -5.21%     
==========================================
  Files         372      133     -239     
  Lines       25385    12174   -13211     
==========================================
- Hits        18857     8410   -10447     
+ Misses       6528     3764    -2764     
Flag Coverage Δ
spp_analytics ?
spp_api_v2_cycles ?
spp_api_v2_entitlements ?
spp_api_v2_gis 71.59% <100.00%> (+0.06%) ⬆️
spp_api_v2_programs ?
spp_api_v2_simulation ?
spp_audit_programs ?
spp_base_common 91.07% <ø> (+0.80%) ⬆️
spp_case_demo ?
spp_case_entitlements ?
spp_case_programs ?
spp_cr_type_assign_program ?
spp_dci_compliance ?
spp_dci_demo 94.28% <ø> (+0.88%) ⬆️
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
spp_farmer_registry_demo ?
spp_gis_report ?
spp_grm_demo ?
spp_indicator ?
spp_indicator_studio ?
spp_metric_service ?
spp_mis_demo_v2 70.99% <ø> (+0.03%) ⬆️
spp_programs 65.27% <ø> (+<0.01%) ⬆️
spp_registry 86.94% <ø> (+0.10%) ⬆️
spp_security 69.56% <ø> (+2.89%) ⬆️
spp_simulation ?

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

Files with missing lines Coverage Δ
spp_api_v2_gis/services/spatial_query_service.py 75.34% <100.00%> (+0.11%) ⬆️

... and 239 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.

Applies CI's pinned oca-gen output verbatim for the new HISTORY fragment.
- Preserve deterministic default-path ordering: use the ordered indicator
  recordset for the default statistics list, and the set only for the
  supplied-path membership test (was list(set(...)) -> non-deterministic across
  processes).
- Document the invariant that _convert_aggregation_result's metadata search
  only sees already-published names.
- Add a mixed published+unpublished list test (the realistic exfil attempt):
  the published name is kept, unpublished indicator and raw CEL var are dropped.

196 tests (was 195). Both staff reviews: SHIP.
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