Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions spp_analytics/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,14 @@ Common Issues
Changelog
=========

19.0.2.0.1
~~~~~~~~~~

- feat: expose ``spp.analytics.service.get_effective_k_threshold()`` so
other services that emit their own counts (e.g. GIS spatial queries)
can suppress small counts using the caller's access-rule k-anonymity
threshold instead of a hardcoded value.

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_analytics/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "OpenSPP Analytics",
"summary": "Query engine for indicators, simulations, and GIS analytics",
"category": "OpenSPP",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
16 changes: 16 additions & 0 deletions spp_analytics/models/service_aggregation.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ def _k_threshold_from_rule(self, rule):
return rule.minimum_k_anonymity
return self.env["spp.metric.privacy"].DEFAULT_K_THRESHOLD

@api.model
def get_effective_k_threshold(self):
"""
Return the k-anonymity threshold that applies to the current user.

Resolves the caller's effective access rule and returns its
``minimum_k_anonymity`` (falling back to the privacy service default).
Exposed so other services that emit their own counts (e.g. GIS spatial
queries) can suppress small counts with the SAME threshold the
aggregation engine applies to statistics, rather than a hardcoded value.

:returns: k threshold value
:rtype: int
"""
return self._k_threshold_from_rule(self._get_effective_rule())

def _check_scope_allowed(self, scope, rule=None):
"""
Check if scope is allowed for current user.
Expand Down
4 changes: 4 additions & 0 deletions spp_analytics/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.2.0.1

- feat: expose `spp.analytics.service.get_effective_k_threshold()` so other services that emit their own counts (e.g. GIS spatial queries) can suppress small counts using the caller's access-rule k-anonymity threshold instead of a hardcoded value.

### 19.0.2.0.0

- Initial migration to OpenSPP2
27 changes: 27 additions & 0 deletions spp_analytics/tests/test_analytics_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,30 @@ def test_no_registrant_ids_in_result_for_public_user(self):
# Privacy enforcement should strip individual IDs for aggregate access
self.assertNotIn("registrant_ids", result)
self.assertNotIn("partner_ids", result)


class TestEffectiveKThreshold(AnalyticsTestCase):
"""Tests for the public get_effective_k_threshold() accessor."""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.service = cls.env["spp.analytics.service"]

def test_default_when_no_rule(self):
"""With no access rule, the privacy-service default applies."""
default_k = self.env["spp.metric.privacy"].DEFAULT_K_THRESHOLD
self.assertEqual(self.service.get_effective_k_threshold(), default_k)

def test_reads_rule_threshold(self):
"""The caller's access-rule minimum_k_anonymity is returned."""
self.env["spp.analytics.access.rule"].create(
{
"name": "K Threshold Rule k8",
"access_level": "aggregate",
"user_id": self.env.user.id,
"minimum_k_anonymity": 8,
"allow_inline_scopes": True,
}
)
self.assertEqual(self.service.get_effective_k_threshold(), 8)
19 changes: 19 additions & 0 deletions spp_api_v2_gis/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,25 @@ Dependencies
Changelog
=========

19.0.2.0.1
~~~~~~~~~~

- fix(security): apply k-anonymity suppression to registrant counts
returned by the spatial-query, batch, and proximity endpoints. A
client with ``gis:read``/``statistics:read`` could previously send
tiny polygons or proximity buffers and read ``total_count`` — or the
presence of accompanying
``statistics``/``access_level``/``computed_at``/``query_method``
metadata — to learn whether beneficiaries live at a precise location,
even when the aggregate statistics were suppressed. When the count is
below the caller's access-rule k-anonymity threshold (which includes
genuinely empty areas), the response is now canonicalized:
``total_count = 0``, a new ``count_suppressed`` flag is set, and every
people-correlated field (``statistics``, ``access_level``,
``from_cache``, ``computed_at``, ``query_method``, ``areas_matched``)
is blanked to a fixed value, so a small region and an empty one are
byte-identical and no field can be used as a presence oracle.

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_api_v2_gis/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{
"name": "OpenSPP GIS API",
"category": "OpenSPP/Integration",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
4 changes: 4 additions & 0 deletions spp_api_v2_gis/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.2.0.1

- fix(security): apply k-anonymity suppression to registrant counts returned by the spatial-query, batch, and proximity endpoints. A client with `gis:read`/`statistics:read` could previously send tiny polygons or proximity buffers and read `total_count` — or the presence of accompanying `statistics`/`access_level`/`computed_at`/`query_method` metadata — to learn whether beneficiaries live at a precise location, even when the aggregate statistics were suppressed. When the count is below the caller's access-rule k-anonymity threshold (which includes genuinely empty areas), the response is now canonicalized: `total_count = 0`, a new `count_suppressed` flag is set, and every people-correlated field (`statistics`, `access_level`, `from_cache`, `computed_at`, `query_method`, `areas_matched`) is blanked to a fixed value, so a small region and an empty one are byte-identical and no field can be used as a presence oracle.

### 19.0.2.0.0

- Initial migration to OpenSPP2
36 changes: 29 additions & 7 deletions spp_api_v2_gis/schemas/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ class SpatialQueryRequest(BaseModel):
class SpatialQueryResponse(BaseModel):
"""Response from spatial query."""

total_count: int = Field(..., description="Total number of registrants in query area")
total_count: int = Field(..., description="Total number of registrants in query area (0 when suppressed)")
count_suppressed: bool = Field(
default=False,
description="True when the count is withheld for k-anonymity (fewer than the "
"minimum threshold); total_count is reported as 0 and cannot be distinguished from empty",
)
query_method: str = Field(
...,
description="Method used for query (coordinates, area_fallback)",
description="Query method: coordinates, area_fallback, or 'suppressed' (count withheld for k-anonymity)",
)
areas_matched: int = Field(..., description="Number of areas intersecting query polygon")
statistics: dict = Field(..., description="Computed aggregate statistics")
Expand Down Expand Up @@ -70,10 +75,15 @@ class BatchResultItem(BaseModel):
"""Result for a single geometry in a batch query."""

id: str = Field(..., description="Geometry identifier matching the request")
total_count: int = Field(..., description="Total number of registrants in this geometry")
total_count: int = Field(..., description="Total number of registrants in this geometry (0 when suppressed)")
count_suppressed: bool = Field(
default=False,
description="True when the count is withheld for k-anonymity (fewer than the "
"minimum threshold); total_count is reported as 0 and cannot be distinguished from empty",
)
query_method: str = Field(
...,
description="Method used for query (coordinates, area_fallback)",
description="Query method: coordinates, area_fallback, or 'suppressed' (count withheld for k-anonymity)",
)
areas_matched: int = Field(..., description="Number of areas intersecting this geometry")
statistics: dict = Field(..., description="Statistics computed for this geometry")
Expand All @@ -94,7 +104,12 @@ class BatchResultItem(BaseModel):
class BatchSummary(BaseModel):
"""Aggregated summary across all geometries in a batch query."""

total_count: int = Field(..., description="Combined total registrants across all geometries")
total_count: int = Field(..., description="Combined total registrants across all geometries (0 when suppressed)")
count_suppressed: bool = Field(
default=False,
description="True when the combined count is withheld for k-anonymity (fewer than the "
"minimum threshold); total_count is reported as 0 and cannot be distinguished from empty",
)
geometries_queried: int = Field(..., description="Number of geometries in the batch")
statistics: dict = Field(..., description="Combined statistics across all geometries")
access_level: str | None = Field(
Expand Down Expand Up @@ -165,10 +180,17 @@ class ProximityQueryRequest(BaseModel):
class ProximityQueryResponse(BaseModel):
"""Response from proximity-based spatial query."""

total_count: int = Field(..., description="Number of registrants matching the proximity criteria")
total_count: int = Field(
..., description="Number of registrants matching the proximity criteria (0 when suppressed)"
)
count_suppressed: bool = Field(
default=False,
description="True when the count is withheld for k-anonymity (fewer than the "
"minimum threshold); total_count is reported as 0 and cannot be distinguished from empty",
)
query_method: str = Field(
...,
description="Method used for query (coordinates, area_fallback)",
description="Query method: coordinates, area_fallback, or 'suppressed' (count withheld for k-anonymity)",
)
areas_matched: int = Field(
...,
Expand Down
75 changes: 75 additions & 0 deletions spp_api_v2_gis/services/spatial_query_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,62 @@ def __init__(self, env):
"""
self.env = env

def _get_k_threshold(self):
"""Return the k-anonymity threshold that applies to the calling user.

Reuses the analytics access-rule threshold (the same value the
aggregation engine applies to statistics), falling back to the privacy
service default. Resolved once per query so all counts in the response
are suppressed consistently.

Returns:
int: k-anonymity threshold
"""
return self.env["spp.analytics.service"].get_effective_k_threshold()

# People-correlated response fields that must be canonicalized when a count
# is suppressed. Fixed values so that an empty region (0) and a small one
# (1..k-1) are byte-identical — otherwise these fields reconstruct the exact
# presence bit the count suppression is meant to hide (a non-null computed_at,
# a populated statistics dict, or query_method="coordinates" each imply >=1
# beneficiary is present at an attacker-chosen location).
_SUPPRESSED_RESPONSE_FIELDS = {
"statistics": {},
"access_level": None,
"from_cache": False,
"computed_at": None,
"query_method": "suppressed",
"areas_matched": 0,
}

def _apply_suppression(self, result, k_threshold):
"""Canonicalize a query result in place when its count is below k.

Sets ``count_suppressed`` and, when suppression fires, floors
``total_count`` to 0 and overwrites every people-correlated field with a
fixed value (see ``_SUPPRESSED_RESPONSE_FIELDS``). Request echoes
(reference_points_count, radius_km, relation, geometries_queried, id) and
geography that does not imply presence are left untouched. Returns the
same dict for convenience.

Args:
result: Query result dict (mutated in place)
k_threshold: Minimum count before suppression

Returns:
dict: the mutated result
"""
privacy = self.env["spp.metric.privacy"]
if not privacy.is_count_suppressed(result.get("total_count", 0), k_threshold):
result["count_suppressed"] = False
return result
result["total_count"] = 0
result["count_suppressed"] = True
for field, value in self._SUPPRESSED_RESPONSE_FIELDS.items():
if field in result:
result[field] = value
return result

def query_statistics_batch(self, geometries, filters=None, variables=None):
"""Execute spatial query for multiple geometries.

Expand All @@ -47,6 +103,7 @@ def query_statistics_batch(self, geometries, filters=None, variables=None):
"""
results = []
all_registrant_ids = set()
k_threshold = self._get_k_threshold()

for item in geometries:
geometry_id = item["id"]
Expand All @@ -62,10 +119,13 @@ def query_statistics_batch(self, geometries, filters=None, variables=None):
registrant_ids = result.pop("registrant_ids", [])
all_registrant_ids.update(registrant_ids)

# total_count / count_suppressed are already k-anonymity
# suppressed by query_statistics for this geometry.
results.append(
{
"id": geometry_id,
"total_count": result["total_count"],
"count_suppressed": result.get("count_suppressed", False),
"query_method": result["query_method"],
"areas_matched": result["areas_matched"],
"statistics": result["statistics"],
Expand All @@ -80,6 +140,8 @@ def query_statistics_batch(self, geometries, filters=None, variables=None):
{
"id": geometry_id,
"total_count": 0,
# A failed query is not a disclosed exact count.
"count_suppressed": True,
"query_method": "error",
"areas_matched": 0,
"statistics": {},
Expand All @@ -102,6 +164,8 @@ def query_statistics_batch(self, geometries, filters=None, variables=None):
"from_cache": summary_stats_with_metadata.get("from_cache", False),
"computed_at": summary_stats_with_metadata.get("computed_at"),
}
# k-anonymity: canonicalize the deduplicated summary when below threshold
self._apply_suppression(summary, k_threshold)

return {
"results": results,
Expand All @@ -128,6 +192,7 @@ def query_statistics(self, geometry, filters=None, variables=None):
"""
filters = filters or {}
variables = variables or []
k_threshold = self._get_k_threshold()

# Convert GeoJSON to PostGIS-compatible format
geometry_json = json.dumps(geometry)
Expand All @@ -143,6 +208,8 @@ def query_statistics(self, geometry, filters=None, variables=None):
# Compute statistics for the matched registrants with metadata
stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables)
result.update(stats_with_metadata)
# k-anonymity: canonicalize the response when the count is small
self._apply_suppression(result, k_threshold)
return result
except Exception as e:
_logger.warning(
Expand All @@ -160,6 +227,9 @@ def query_statistics(self, geometry, filters=None, variables=None):
stats_with_metadata = self._compute_statistics(result["registrant_ids"], variables)
result.update(stats_with_metadata)

# k-anonymity: canonicalize the response when the count is small
self._apply_suppression(result, k_threshold)

return result

def _query_by_coordinates(self, geometry_json, filters):
Expand Down Expand Up @@ -482,6 +552,7 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter
filters = filters or {}
variables = variables or []
radius_meters = radius_km * 1000
k_threshold = self._get_k_threshold()

# Try coordinate-based query first
try:
Expand All @@ -499,6 +570,8 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter
result["reference_points_count"] = len(reference_points)
result["radius_km"] = radius_km
result["relation"] = relation
# k-anonymity: canonicalize the response when the count is small
self._apply_suppression(result, k_threshold)
return result
except Exception as e:
_logger.warning(
Expand All @@ -521,6 +594,8 @@ def query_proximity(self, reference_points, radius_km, relation="within", filter
result["reference_points_count"] = len(reference_points)
result["radius_km"] = radius_km
result["relation"] = relation
# k-anonymity: canonicalize the response when the count is small
self._apply_suppression(result, k_threshold)
return result

def _create_proximity_temp_table(self, reference_points, radius_meters):
Expand Down
Loading
Loading