From c5015997f2af45793c44a40bbe88771f96eeb1a2 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 09:52:00 +0800 Subject: [PATCH 01/53] test(spp_api_v2_programs): add failing tests for unambiguous membership addressing (#554) Covers item A (GET/PUT resolve to an arbitrary program's membership, PUT re-parents or reassigns the membership from the body, POST Location is not followable) and item B (If-Match rejects the resource's own ETag). --- spp_api_v2_programs/tests/__init__.py | 1 + .../tests/test_program_membership_identity.py | 328 ++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 spp_api_v2_programs/tests/test_program_membership_identity.py diff --git a/spp_api_v2_programs/tests/__init__.py b/spp_api_v2_programs/tests/__init__.py index c8d2b6f67..5a7131b0a 100644 --- a/spp_api_v2_programs/tests/__init__.py +++ b/spp_api_v2_programs/tests/__init__.py @@ -4,4 +4,5 @@ from . import test_program_membership_api from . import test_program_service from . import test_program_membership_service +from . import test_program_membership_identity from . import test_scope_enforcement_program diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py new file mode 100644 index 000000000..117120d7f --- /dev/null +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -0,0 +1,328 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Tests that a ProgramMembership is addressed unambiguously (#554 items A and B). + +A beneficiary can be enrolled in several programs, so the beneficiary's +identifier alone does not identify one membership. Reads and updates must +either be told the program (``?program=``) or refuse to guess. +""" + +import json +from urllib.parse import quote + +from odoo.exceptions import ValidationError + +from odoo.addons.spp_api_v2.schemas.base import Reference +from odoo.addons.spp_api_v2.tests.common import ApiV2HttpTestCase, ApiV2TestCase + +from ..schemas.program_membership import ProgramMembership +from ..services import program_membership_service +from ..services.program_membership_service import ProgramMembershipService + +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" +PROGRAM_1_REF = "Program/urn:openspp:program|first-identity-program" +PROGRAM_2_REF = "Program/urn:openspp:program|second-identity-program" +PROGRAM_3_REF = "Program/urn:openspp:program|third-identity-program" + + +def _beneficiary_ref(value): + return f"Individual/{NATIONAL_ID}|{value}" + + +class TestProgramMembershipIdentityAPI(ApiV2HttpTestCase): + """HTTP contract for addressing one membership among several""" + + def setUp(self): + super().setUp() + self.api_base_url = "/api/v2/spp/ProgramMembership" + + self.program_1 = self.create_test_program(name="First Identity Program") + self.program_2 = self.create_test_program(name="Second Identity Program") + self.program_3 = self.create_test_program(name="Third Identity Program") + + # Enrolled in two programs. P1 is created first, so it is the *older* + # membership: a lookup that ignores the program picks P2 (newest first). + self.multi = self.create_test_individual(identifier_value="MULTI-001") + self.multi_p1 = self.create_test_membership(partner=self.multi, program=self.program_1, state="enrolled") + self.multi_p2 = self.create_test_membership(partner=self.multi, program=self.program_2, state="enrolled") + + # Enrolled in exactly one program. + self.single = self.create_test_individual(identifier_value="SINGLE-001") + self.single_p1 = self.create_test_membership(partner=self.single, program=self.program_1, state="enrolled") + + # Not enrolled anywhere. + self.other = self.create_test_individual(identifier_value="OTHER-001") + + self.client = self.create_api_client( + name="Membership Identity Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "search"}, + {"resource": "program_membership", "action": "create"}, + {"resource": "program_membership", "action": "update"}, + ], + ) + for registrant in (self.multi, self.single, self.other): + self.create_consent( + registrant=registrant, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + self.token = self.generate_jwt_token(self.client) + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _headers(self, **extra): + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + } + headers.update(extra) + return headers + + def _url(self, value, program_ref=None): + url = f"{self.api_base_url}/{quote(NATIONAL_ID, safe=':')}|{value}" + if program_ref: + url += f"?program={quote(program_ref, safe=':/|')}" + return url + + def _put(self, url, payload, **extra_headers): + # url_put bypasses url_open's flush, so push setUp data to the + # database the HTTP worker reads, and drop our cache for the reads after. + self.env.cr.flush() + self.env.invalidate_all() + response = self.url_put(url, data=json.dumps(payload), headers=self._headers(**extra_headers)) + self.env.invalidate_all() + return response + + def _payload(self, program_ref, beneficiary_value, status): + return { + "type": "ProgramMembership", + "program": {"reference": program_ref}, + "beneficiary": {"reference": _beneficiary_ref(beneficiary_value)}, + "status": status, + } + + # ------------------------------------------------------------------ + # GET + # ------------------------------------------------------------------ + + def test_get_without_program_is_409_when_beneficiary_has_several_memberships(self): + """Refuses to pick one of several memberships instead of returning an arbitrary one""" + response = self.url_open(self._url("MULTI-001"), headers=self._headers()) + + self.assertEqual(response.status_code, 409, response.text) + self.assertIn("program", response.json()["detail"]) + + def test_get_with_program_returns_that_programs_membership(self): + """?program= selects the membership in that program, not the newest one""" + response = self.url_open(self._url("MULTI-001", PROGRAM_1_REF), headers=self._headers()) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["program"]["reference"], PROGRAM_1_REF) + + def test_get_without_program_still_works_for_single_membership(self): + """Backward compatible: one membership needs no ?program=""" + response = self.url_open(self._url("SINGLE-001"), headers=self._headers()) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["program"]["reference"], PROGRAM_1_REF) + + def test_get_with_program_the_beneficiary_is_not_in_is_404(self): + """?program= naming a program without a membership for this beneficiary is 404""" + response = self.url_open(self._url("MULTI-001", PROGRAM_3_REF), headers=self._headers()) + + self.assertEqual(response.status_code, 404, response.text) + + def test_get_with_unknown_program_is_404(self): + """?program= naming no existing program is 404""" + response = self.url_open( + self._url("MULTI-001", "Program/urn:openspp:program|no-such-program"), + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 404, response.text) + + def test_get_with_malformed_program_is_400(self): + """?program= that is not a Program/{system}|{value} reference is 400""" + response = self.url_open(self._url("MULTI-001", "not-a-reference"), headers=self._headers()) + + self.assertEqual(response.status_code, 400, response.text) + + def test_ambiguity_is_not_revealed_without_consent(self): + """Consent is checked before ambiguity: no 409 leaks membership counts""" + no_consent = self.create_test_individual(identifier_value="NOCONSENT-MULTI-001") + self.create_test_membership(partner=no_consent, program=self.program_1) + self.create_test_membership(partner=no_consent, program=self.program_2) + + response = self.url_open(self._url("NOCONSENT-MULTI-001"), headers=self._headers()) + + self.assertEqual(response.status_code, 403, response.text) + + # ------------------------------------------------------------------ + # PUT + # ------------------------------------------------------------------ + + def test_put_with_program_updates_only_that_programs_membership(self): + """PUT ?program=P1 exits P1 and leaves P2 alone (#554 A reproduction)""" + response = self._put( + self._url("MULTI-001", PROGRAM_1_REF), + self._payload(PROGRAM_1_REF, "MULTI-001", "exited"), + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["program"]["reference"], PROGRAM_1_REF) + self.assertEqual(self.multi_p1.state, "exited") + self.assertEqual(self.multi_p2.state, "enrolled") + self.assertEqual(self.multi_p1.program_id, self.program_1) + self.assertEqual(self.multi_p2.program_id, self.program_2) + + def test_put_without_program_is_409_when_beneficiary_has_several_memberships(self): + """PUT refuses to guess, and changes nothing""" + response = self._put( + self._url("MULTI-001"), + self._payload(PROGRAM_1_REF, "MULTI-001", "exited"), + ) + + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(self.multi_p1.state, "enrolled") + self.assertEqual(self.multi_p2.state, "enrolled") + + def test_put_cannot_move_membership_to_another_program(self): + """Body program different from the addressed membership is 422, not a re-parent""" + response = self._put( + self._url("SINGLE-001"), + self._payload(PROGRAM_3_REF, "SINGLE-001", "enrolled"), + ) + + self.assertEqual(response.status_code, 422, response.text) + self.assertEqual(self.single_p1.program_id, self.program_1) + self.assertFalse( + self.env["spp.program.membership"].search( + [("partner_id", "=", self.single.id), ("program_id", "=", self.program_3.id)] + ) + ) + + def test_put_body_program_must_match_query_program(self): + """?program=P1 with a body naming P2 is 422 and changes nothing""" + response = self._put( + self._url("MULTI-001", PROGRAM_1_REF), + self._payload(PROGRAM_2_REF, "MULTI-001", "exited"), + ) + + self.assertEqual(response.status_code, 422, response.text) + self.assertEqual(self.multi_p1.state, "enrolled") + self.assertEqual(self.multi_p2.state, "enrolled") + + def test_put_cannot_move_membership_to_another_beneficiary(self): + """Body beneficiary different from the addressed one is 422, not a reassignment""" + response = self._put( + self._url("SINGLE-001"), + self._payload(PROGRAM_1_REF, "OTHER-001", "enrolled"), + ) + + self.assertEqual(response.status_code, 422, response.text) + self.assertEqual(self.single_p1.partner_id, self.single) + self.assertFalse(self.env["spp.program.membership"].search([("partner_id", "=", self.other.id)])) + + # ------------------------------------------------------------------ + # POST Location + # ------------------------------------------------------------------ + + def test_post_location_addresses_the_created_membership(self): + """Following Location returns the membership just created, even with others present""" + response = self.url_open( + self.api_base_url, + data=json.dumps(self._payload(PROGRAM_3_REF, "MULTI-001", "enrolled")), + headers=self._headers(), + ) + self.assertEqual(response.status_code, 201, response.text) + location = response.headers["location"] + + followed = self.url_open(location, headers=self._headers()) + + self.assertEqual(followed.status_code, 200, followed.text) + self.assertEqual(followed.json()["program"]["reference"], PROGRAM_3_REF) + + # ------------------------------------------------------------------ + # If-Match (#554 B) + # ------------------------------------------------------------------ + + def test_put_accepts_its_own_etag_as_if_match(self): + """The ETag from GET is accepted by PUT (versionId format matches)""" + url = self._url("SINGLE-001") + get_response = self.url_open(url, headers=self._headers()) + self.assertEqual(get_response.status_code, 200, get_response.text) + etag = get_response.headers["etag"] + self.assertEqual(etag, f'"{get_response.json()["meta"]["versionId"]}"') + + response = self._put(url, self._payload(PROGRAM_1_REF, "SINGLE-001", "paused"), **{"If-Match": etag}) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.single_p1.state, "paused") + + +class TestProgramMembershipIdentityService(ApiV2TestCase): + """Service-level resolution and update guards""" + + def setUp(self): + super().setUp() + self.service = ProgramMembershipService(self.env) + self.program_1 = self.create_test_program(name="First Identity Program") + self.program_2 = self.create_test_program(name="Second Identity Program") + self.program_3 = self.create_test_program(name="Third Identity Program") + self.multi = self.create_test_individual(identifier_value="SVC-MULTI-001") + self.multi_p1 = self.create_test_membership(partner=self.multi, program=self.program_1) + self.multi_p2 = self.create_test_membership(partner=self.multi, program=self.program_2) + self.other = self.create_test_individual(identifier_value="SVC-OTHER-001") + + def test_find_by_identifier_with_program(self): + """The program narrows resolution to that program's membership""" + found = self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001", program=self.program_1) + + self.assertEqual(found, self.multi_p1) + + def test_find_by_identifier_with_program_without_membership(self): + """A program the beneficiary is not in resolves to nothing""" + found = self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001", program=self.program_3) + + self.assertFalse(found) + + def test_find_by_identifier_ambiguous_raises(self): + """Several memberships and no program: refuse, and report how many""" + with self.assertRaises(program_membership_service.AmbiguousMembershipError) as ctx: + self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001") + + self.assertEqual(ctx.exception.count, 2) + + def _schema(self, program_ref, beneficiary_value, status="exited"): + return ProgramMembership( + program=Reference(reference=program_ref), + beneficiary=Reference(reference=_beneficiary_ref(beneficiary_value)), + status=status, + ) + + def test_update_refuses_program_change(self): + """update() never re-parents a membership onto another program""" + with self.assertRaises(ValidationError): + self.service.update(self.multi_p1, self._schema(PROGRAM_3_REF, "SVC-MULTI-001"), source="test") + + self.assertEqual(self.multi_p1.program_id, self.program_1) + self.assertEqual(self.multi_p1.state, "enrolled") + + def test_update_refuses_beneficiary_change(self): + """update() never reassigns a membership to another registrant""" + with self.assertRaises(ValidationError): + self.service.update(self.multi_p1, self._schema(PROGRAM_1_REF, "SVC-OTHER-001"), source="test") + + self.assertEqual(self.multi_p1.partner_id, self.multi) + self.assertEqual(self.multi_p1.state, "enrolled") + + def test_update_matching_identity_writes_status(self): + """A body matching the membership's program and beneficiary updates it""" + self.service.update(self.multi_p1, self._schema(PROGRAM_1_REF, "SVC-MULTI-001"), source="test") + + self.assertEqual(self.multi_p1.state, "exited") + self.assertEqual(self.multi_p2.state, "enrolled") From fc9d4198098121998d066b205cd97fccc127ce47 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:08:09 +0800 Subject: [PATCH 02/53] fix(spp_api_v2_programs): address program memberships unambiguously (#554) A beneficiary enrolled in several programs made GET/PUT /ProgramMembership/{identifier} act on whichever membership came first, and PUT wrote the body's program and beneficiary onto it, moving the membership to another program or registrant. - optional ?program= selects the membership; several memberships without it return 409, after the consent check so enrollment is not revealed - PUT refuses (422) a body naming another program or beneficiary - POST Location is URL-encoded and carries ?program= - If-Match compares against the same microsecond versionId as the ETag --- spp_api_v2_programs/__manifest__.py | 2 +- spp_api_v2_programs/readme/HISTORY.md | 7 ++ .../routers/program_membership.py | 119 +++++++++++++++--- .../services/program_membership_service.py | 108 ++++++++++------ .../tests/test_program_membership_identity.py | 9 ++ 5 files changed, 195 insertions(+), 50 deletions(-) diff --git a/spp_api_v2_programs/__manifest__.py b/spp_api_v2_programs/__manifest__.py index 5becc04d0..01a4a05a1 100644 --- a/spp_api_v2_programs/__manifest__.py +++ b/spp_api_v2_programs/__manifest__.py @@ -2,7 +2,7 @@ { # pylint: disable=pointless-statement "name": "OpenSPP API V2 - Programs", "category": "OpenSPP/Integration", - "version": "19.0.1.0.0", + "version": "19.0.1.1.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index a87d1ffe4..438dcad2b 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -1,3 +1,10 @@ +### 19.0.1.1.0 + +- fix: `GET`/`PUT /ProgramMembership/{identifier}` no longer resolve to an arbitrary program's membership when the beneficiary is enrolled in several programs (#554). A new optional `program` query parameter (`Program/{system}|{value}`) selects the membership; without it, a beneficiary with more than one membership returns `409`. For a client without consent, the refusal is `403` before any `409`/`404`, so enrollment is not revealed. +- fix: `PUT /ProgramMembership` can no longer move a membership to another program or beneficiary: a body whose `program` or `beneficiary` differs from the addressed membership returns `422` (#554). +- fix: the `Location` header of `POST /ProgramMembership` is URL-encoded and carries `?program=`, so following it returns the created membership (#554). +- fix: `PUT /ProgramMembership` accepts its own `ETag`/`meta.versionId` in `If-Match`; it previously compared against a different timestamp format and always returned `409` (#554). + ### 19.0.1.0.0 - Split program and program-membership REST endpoints out of `spp_api_v2` into this companion module (#1081), so `spp_api_v2` no longer depends on `spp_programs`. diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index 0a3b34e6c..38cfb38d2 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -3,8 +3,10 @@ import logging from typing import Annotated +from urllib.parse import quote from odoo.api import Environment +from odoo.exceptions import ValidationError from odoo.addons.fastapi.dependencies import odoo_env from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client @@ -25,24 +27,71 @@ ) from ..schemas.program_membership import ProgramMembership -from ..services.program_membership_service import ProgramMembershipService +from ..services.program_membership_service import AmbiguousMembershipError, ProgramMembershipService _logger = logging.getLogger(__name__) program_membership_router = APIRouter(tags=["ProgramMembership"], prefix="/ProgramMembership") +PROGRAM_PARAM_DESCRIPTION = ( + "Program of the membership (format: Program/{system}|{value}). " + "Required when the beneficiary is enrolled in more than one program." +) + + +def _resolve_program_param(service, program): + """Resolve the ``program`` query parameter: 400 if malformed, 404 if unknown.""" + if not program: + return None + try: + program_record = service._parse_program_reference(program) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=e.args[0], + ) from e + if not program_record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Program not found", + ) + return program_record + + +def _consent_denied(consent_service, partner, api_client) -> bool: + """Whether the client lacks consent for this beneficiary's membership data. + + Uses the same check as a successful read, without logging an access, + so a lookup that resolves to no single membership can refuse *before* + saying why (ambiguous vs not enrolled) to a client without consent. + """ + filtered = consent_service.filter_response(partner.id, api_client, "program_membership", {}, log_access=False) + return filtered.get("_consent", {}).get("status") in ("no_consent", "scope_mismatch") + + +def _membership_location(data: dict) -> str | None: + """Location of a membership: beneficiary identifier plus program, URL-encoded.""" + if not data.get("identifier"): + return None + primary_id = data["identifier"][0] + path_id = f"{quote(primary_id['system'], safe=':')}|{quote(primary_id['value'], safe='')}" + program_ref = quote(data["program"]["reference"], safe=":/|") + return f"/api/v2/spp/ProgramMembership/{path_id}?program={program_ref}" + @program_membership_router.get("/{identifier}", response_model=ProgramMembership) async def read_program_membership( - identifier: Annotated[str, Path(description="Format: {system}|{value} (URL-encoded)")], + identifier: Annotated[str, Path(description="Beneficiary identifier. Format: {system}|{value} (URL-encoded)")], env: Annotated[Environment, Depends(odoo_env)], api_client: Annotated[dict, Depends(get_authenticated_client)], response: Response, + program: Annotated[str | None, Query(description=PROGRAM_PARAM_DESCRIPTION)] = None, ): """ - Read ProgramMembership by external identifier. + Read ProgramMembership by beneficiary identifier and, optionally, program. - Applies consent filtering for beneficiary data. + Returns 409 when the beneficiary has several memberships and no + ``program`` is given. Applies consent filtering for beneficiary data. """ # Check scope if not api_client.has_scope("program_membership", "read"): @@ -60,11 +109,37 @@ async def read_program_membership( system, value = identifier.split("|", 1) - # Find program membership by namespace_uri + value service = ProgramMembershipService(env) - membership = service.find_by_identifier(system, value) + consent_service = ConsentService(env) + program_record = _resolve_program_param(service, program) + + partner = service.find_beneficiary(system, value) + if not partner: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="ProgramMembership not found", + ) + + ambiguous = None + try: + membership = service.find_for_beneficiary(partner, program_record) + except AmbiguousMembershipError as e: + membership = env["spp.program.membership"] + ambiguous = e if not membership: + # Consent first: without it, neither "ambiguous" nor "not in this + # program" may be revealed about the beneficiary. + if _consent_denied(consent_service, partner, api_client): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + if ambiguous: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(ambiguous), + ) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="ProgramMembership not found", @@ -74,7 +149,6 @@ async def read_program_membership( data = service.to_api_schema(membership) # Apply consent filtering for the beneficiary - consent_service = ConsentService(env) filtered_data = consent_service.filter_response( membership.partner_id.id, api_client, @@ -273,9 +347,8 @@ async def create_program_membership( data = service.to_api_schema(membership) # Set Location header - if data.get("identifier"): - primary_id = data["identifier"][0] - location = f"/api/v2/spp/ProgramMembership/{primary_id['system']}|{primary_id['value']}" + location = _membership_location(data) + if location: response.headers["Location"] = location return data @@ -283,11 +356,12 @@ async def create_program_membership( @program_membership_router.put("/{identifier}", response_model=ProgramMembership) async def update_program_membership( - identifier: Annotated[str, Path()], + identifier: Annotated[str, Path(description="Beneficiary identifier. Format: {system}|{value} (URL-encoded)")], program_membership: ProgramMembership, env: Annotated[Environment, Depends(odoo_env)], api_client: Annotated[dict, Depends(get_authenticated_client)], if_match: Annotated[str | None, Header()] = None, + program: Annotated[str | None, Query(description=PROGRAM_PARAM_DESCRIPTION)] = None, ): """ Update ProgramMembership (full replacement). @@ -296,6 +370,10 @@ async def update_program_membership( - Requires If-Match header with version ID for optimistic locking - Updates with source tracking - Checks client scopes for update permission + - Returns 409 when the beneficiary has several memberships and no + ``program`` is given + - The body's program and beneficiary must match the addressed + membership (422 otherwise); a PUT never moves a membership """ # Check client has update scope if not api_client.has_scope("program_membership", "update"): @@ -315,7 +393,14 @@ async def update_program_membership( # Find program membership service = ProgramMembershipService(env) - membership = service.find_by_identifier(system, value) + program_record = _resolve_program_param(service, program) + try: + membership = service.find_by_identifier(system, value, program=program_record) + except AmbiguousMembershipError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(e), + ) from e if not membership: raise HTTPException( @@ -323,9 +408,9 @@ async def update_program_membership( detail="ProgramMembership not found", ) - # Check version for optimistic locking + # Check version for optimistic locking (same format as meta.versionId / ETag) if if_match: - current_version = str(membership.write_date.timestamp() if membership.write_date else 1) + current_version = str(int(membership.write_date.timestamp() * 1000000)) if membership.write_date else "1" if_match_clean = if_match.strip('"') if if_match_clean != current_version: raise HTTPException( @@ -338,6 +423,12 @@ async def update_program_membership( try: membership = service.update(membership, program_membership, source=source_system) + except ValidationError as e: + # Client error (unknown reference, identity mismatch): no traceback + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Failed to update program membership: {e.args[0]}", + ) from e except Exception as e: _logger.exception("Error updating program membership") raise HTTPException( diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 5625d3ee1..f226bef23 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -12,6 +12,14 @@ _logger = logging.getLogger(__name__) +class AmbiguousMembershipError(Exception): + """The beneficiary has several program memberships and no program was given.""" + + def __init__(self, count: int): + super().__init__(f"Beneficiary has {count} program memberships; specify the program") + self.count = count + + class ProgramMembershipService: """Service for ProgramMembership resource CRUD and mapping""" @@ -100,20 +108,38 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: return records, total - def find_by_identifier(self, system_uri: str, value: str): + def find_by_identifier(self, system_uri: str, value: str, program=None): """ - Lookup program membership by external identifier. + Lookup program membership by the beneficiary's external identifier. + + A membership has no identifier of its own: it is addressed by its + beneficiary's identifier plus, when the beneficiary is enrolled in + more than one program, the program. Args: system_uri: Full URI of identifier type (e.g., urn:openspp:vocab:id-type#national_id) value: Identifier value + program: Optional spp.program record narrowing the lookup Returns: spp.program.membership record or empty recordset + + Raises: + AmbiguousMembershipError: no program given and the beneficiary + has several memberships """ - # Program memberships might have identifiers via their partner_id - # or via a custom identifier system. Check partner's registry IDs. + partner = self.find_beneficiary(system_uri, value) + if not partner: + return self.env["spp.program.membership"] + return self.find_for_beneficiary(partner, program) + def find_beneficiary(self, system_uri: str, value: str): + """ + Lookup the beneficiary (Individual or Group) by external identifier. + + Returns: + res.partner record or empty recordset + """ reg_id = ( self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context .sudo() @@ -125,22 +151,32 @@ def find_by_identifier(self, system_uri: str, value: str): limit=1, ) ) - if reg_id and reg_id.partner_id: - # Find membership for this partner - # We might have multiple memberships, so we need program reference too - # For now, return the first membership found - membership = ( - self.env["spp.program.membership"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [("partner_id", "=", reg_id.partner_id.id)], - limit=1, - ) - ) - return membership + return reg_id.partner_id + return self.env["res.partner"] + + def find_for_beneficiary(self, partner, program=None): + """ + Find the beneficiary's membership, in ``program`` when given. - return self.env["spp.program.membership"] + Never picks one of several memberships: without a program, a + beneficiary enrolled in more than one program is ambiguous. + + Returns: + spp.program.membership record or empty recordset + + Raises: + AmbiguousMembershipError: no program given and the beneficiary + has several memberships + """ + domain = [("partner_id", "=", partner.id)] + if program: + domain.append(("program_id", "=", program.id)) + Membership = self.env["spp.program.membership"].sudo() # nosemgrep: odoo-sudo-without-context + memberships = Membership.search(domain, limit=2) + if len(memberships) > 1: + raise AmbiguousMembershipError(Membership.search_count(domain)) + return memberships def find_by_partner_and_program(self, partner_id: int, program_id: int): """ @@ -379,23 +415,7 @@ def _parse_beneficiary_reference(self, reference: str): system = unquote(system) - # Find partner by identifier - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system), - ("value", "=", value), - ], - limit=1, - ) - ) - - if reg_id and reg_id.partner_id: - return reg_id.partner_id - - return self.env["res.partner"] + return self.find_beneficiary(system, value) def create(self, schema: ProgramMembership, source: str) -> Any: """ @@ -432,9 +452,27 @@ def update(self, membership, schema: ProgramMembership, source: str) -> Any: Returns: Updated spp.program.membership record + + Raises: + ValidationError: the body names a different program or + beneficiary than the membership being updated """ vals = self.from_api_schema(schema) + # A membership's program and beneficiary are its identity. A PUT + # naming others must not move the record to another program or + # registrant; create a new membership instead. + if vals.pop("program_id") != membership.program_id.id: + raise ValidationError( + f"Program {schema.program.reference} does not match the membership being updated; " + "a membership cannot be moved to another program" + ) + if vals.pop("partner_id") != membership.partner_id.id: + raise ValidationError( + "Beneficiary does not match the membership being updated; " + "a membership cannot be moved to another beneficiary" + ) + # Update membership membership.sudo().write(vals) # nosemgrep: odoo-sudo-without-context diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 117120d7f..71bffe255 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -161,6 +161,15 @@ def test_ambiguity_is_not_revealed_without_consent(self): self.assertEqual(response.status_code, 403, response.text) + def test_enrollment_is_not_revealed_without_consent(self): + """?program= for a program the beneficiary is not in is 403, not 404, without consent""" + no_consent = self.create_test_individual(identifier_value="NOCONSENT-SINGLE-001") + self.create_test_membership(partner=no_consent, program=self.program_1) + + response = self.url_open(self._url("NOCONSENT-SINGLE-001", PROGRAM_3_REF), headers=self._headers()) + + self.assertEqual(response.status_code, 403, response.text) + # ------------------------------------------------------------------ # PUT # ------------------------------------------------------------------ From 9ca16c0ed3da042fa25c8a638d0af587c071d676 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:17:54 +0800 Subject: [PATCH 03/53] test(spp_api_v2): add failing tests for fail-closed search filters (#554) Covers item H: malformed filters are silently dropped and unknown group/role/gender/member filters return the whole registry; one2many filter conditions match across different related rows. --- spp_api_v2/tests/__init__.py | 1 + .../tests/test_search_filters_fail_closed.py | 225 ++++++++++++++++++ spp_api_v2/tests/test_search_service.py | 21 +- 3 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 spp_api_v2/tests/test_search_filters_fail_closed.py diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 0a9e53c0d..1c225f469 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -37,3 +37,4 @@ from . import test_scope_enforcement from . import test_schema_builder from . import test_search_service +from . import test_search_filters_fail_closed diff --git a/spp_api_v2/tests/test_search_filters_fail_closed.py b/spp_api_v2/tests/test_search_filters_fail_closed.py new file mode 100644 index 000000000..50cd0a97d --- /dev/null +++ b/spp_api_v2/tests/test_search_filters_fail_closed.py @@ -0,0 +1,225 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Search filters fail closed (#554 item H). + +A filter the API cannot apply must never widen the result: malformed input +is a 400, and a well-formed value naming nothing (unknown group, role, +gender, member) matches nothing. Multi-condition filters on one2many +relations must hold on the *same* related row. +""" + +import json +from datetime import timedelta + +from odoo import fields + +from ..services import search_service +from ..services.search_service import SearchService +from .common import ApiV2HttpTestCase, ApiV2TestCase + +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" +HOUSEHOLD_ID = "urn:openspp:vocab:id-type#test_household_id" + + +class TestSearchFiltersFailClosed(ApiV2TestCase): + """SearchService: malformed filters raise, unresolvable filters match nothing""" + + def setUp(self): + super().setUp() + self.service = SearchService(self.env) + self.ind1 = self.create_test_individual(identifier_value="FC-IND-001", gender_id=self.gender_female.id) + self.ind2 = self.create_test_individual(identifier_value="FC-IND-002", gender_id=self.gender_male.id) + self.group_g = self.create_test_group(name="Group G", identifier_value="FC-HH-G") + self.group_h = self.create_test_group(name="Group H", identifier_value="FC-HH-H") + + def _add_member(self, group, individual, ended=False, role=None): + vals = {"group": group.id, "individual": individual.id} + if ended: + vals["start_date"] = fields.Datetime.now() - timedelta(days=30) + vals["ended_date"] = fields.Datetime.now() - timedelta(days=1) + if role: + vals["membership_type_ids"] = [(4, role.id)] + return self.env["spp.group.membership"].create(vals) + + def _assert_invalid(self, params, search=None): + search = search or self.service.search_individuals + with self.assertRaises(search_service.InvalidSearchParam): + search(params) + + # --- malformed input raises ------------------------------------------- + + def test_malformed_identifier_raises(self): + self._assert_invalid({"identifier": "NO-SEPARATOR"}) + self._assert_invalid({"identifier": "NO-SEPARATOR"}, self.service.search_groups) + + def test_malformed_birthdate_raises(self): + self._assert_invalid({"birthdate": "not-a-date"}) + self._assert_invalid({"birthdate": "ge"}) + + def test_malformed_last_updated_raises(self): + self._assert_invalid({"_lastUpdated": "yesterday"}) + + def test_malformed_gender_raises(self): + self._assert_invalid({"gender": "female"}) + + def test_malformed_group_raises(self): + self._assert_invalid({"group": "NO-SEPARATOR"}) + + def test_malformed_member_raises(self): + self._assert_invalid({"member": f"{NATIONAL_ID}|FC-IND-001"}, self.service.search_groups) + self._assert_invalid({"member": "Individual/NO-SEPARATOR"}, self.service.search_groups) + + # --- unresolvable input matches nothing ------------------------------- + + def test_unknown_group_matches_nothing(self): + """The reported bug: an unknown group returned every individual""" + records, total = self.service.search_individuals({"group": f"{HOUSEHOLD_ID}|DOES-NOT-EXIST"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + def test_group_identifier_of_an_individual_matches_nothing(self): + """An identifier that resolves to an individual, not a group, is not a group""" + records, total = self.service.search_individuals({"group": f"{NATIONAL_ID}|FC-IND-001"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + def test_unknown_gender_matches_nothing(self): + records, total = self.service.search_individuals({"gender": "urn:iso:std:iso:5218|no-such-code"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + def test_unknown_membership_role_matches_nothing(self): + self._add_member(self.group_g, self.ind1, role=self.relationship_head) + + records, total = self.service.search_individuals({"membership-role": "no-such-role"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + def test_unknown_member_matches_nothing(self): + """An unknown member returned every group""" + records, total = self.service.search_groups({"member": f"Individual/{NATIONAL_ID}|DOES-NOT-EXIST"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + # --- multi-condition filters hold on the same row --------------------- + + def test_group_filter_ignores_ended_membership_when_active_elsewhere(self): + """Removed from G but active in H: not a member of G""" + self._add_member(self.group_g, self.ind1, ended=True) + self._add_member(self.group_h, self.ind1) + + records, _total = self.service.search_individuals({"group": f"{HOUSEHOLD_ID}|FC-HH-G"}) + + self.assertNotIn(self.ind1, records) + + def test_membership_role_must_be_on_an_active_membership(self): + """Head of G (ended) and plain member of H (active): not a current head""" + self._add_member(self.group_g, self.ind1, ended=True, role=self.relationship_head) + self._add_member(self.group_h, self.ind1) + + records, _total = self.service.search_individuals({"membership-role": "head"}) + + self.assertNotIn(self.ind1, records) + + def test_identifier_system_and_value_must_be_on_the_same_id(self): + """System from one ID and value from another is not a match""" + self.env["spp.registry.id"].create( + { + "partner_id": self.ind1.id, + "id_type_id": self.id_type_household.id, + "value": "FC-OTHER-VALUE", + "status": "valid", + } + ) + + records, total = self.service.search_individuals({"identifier": f"{NATIONAL_ID}|FC-OTHER-VALUE"}) + + self.assertEqual(total, 0) + self.assertFalse(records) + + def test_member_filter_ignores_ended_memberships(self): + """A group the individual has left is not listed for ?member=""" + self._add_member(self.group_g, self.ind2, ended=True) + self._add_member(self.group_h, self.ind2) + + records, _total = self.service.search_groups({"member": f"Individual/{NATIONAL_ID}|FC-IND-002"}) + + self.assertNotIn(self.group_g, records) + self.assertIn(self.group_h, records) + + +class TestSearchFiltersFailClosedAPI(ApiV2HttpTestCase): + """HTTP: malformed filters are 400, unresolvable filters return nothing""" + + def setUp(self): + super().setUp() + self.individual = self.create_test_individual(identifier_value="FC-API-IND-001") + self.group = self.create_test_group(name="FC API Group", identifier_value="FC-API-HH-001") + self.client = self.create_api_client( + name="Fail Closed Client", + scopes=[ + {"resource": "individual", "action": "read"}, + {"resource": "group", "action": "read"}, + ], + ) + self.create_consent( + registrant=self.individual, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + self.create_consent( + registrant=self.group, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + self.token = self.generate_jwt_token(self.client) + + def _get(self, url): + return self.url_open( + url, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"}, + ) + + def _assert_empty(self, response): + self.assertEqual(response.status_code, 200, response.text) + data = json.loads(response.content) + self.assertEqual(data["meta"]["total"], 0) + self.assertFalse(data.get("data")) + + def test_individual_unknown_group_returns_nothing(self): + self._assert_empty( + self._get("/api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23test_household_id%7CDOES-NOT-EXIST") + ) + + def test_individual_malformed_group_is_400(self): + response = self._get("/api/v2/spp/Individual?group=NO-SEPARATOR") + + self.assertEqual(response.status_code, 400, response.text) + + def test_individual_malformed_identifier_is_400(self): + response = self._get("/api/v2/spp/Individual?identifier=NO-SEPARATOR") + + self.assertEqual(response.status_code, 400, response.text) + + def test_individual_malformed_birthdate_is_400(self): + response = self._get("/api/v2/spp/Individual?birthdate=not-a-date") + + self.assertEqual(response.status_code, 400, response.text) + + def test_group_unknown_member_returns_nothing(self): + self._assert_empty( + self._get( + "/api/v2/spp/Group?member=Individual/urn:openspp:vocab:id-type%23test_national_id%7CDOES-NOT-EXIST" + ) + ) + + def test_group_malformed_identifier_is_400(self): + response = self._get("/api/v2/spp/Group?identifier=NO-SEPARATOR") + + self.assertEqual(response.status_code, 400, response.text) diff --git a/spp_api_v2/tests/test_search_service.py b/spp_api_v2/tests/test_search_service.py index e41e9ea89..dfa3e3176 100644 --- a/spp_api_v2/tests/test_search_service.py +++ b/spp_api_v2/tests/test_search_service.py @@ -61,19 +61,22 @@ def test_search_by_name(self): self.assertIn("Alice Brown", names) def test_parse_identifier_param(self): - """identifier=system|value creates proper domain""" + """identifier=system|value creates proper domain, both conditions on the same ID""" domain = self.service._parse_identifier_param("urn:openspp:vocab:id-type#test_national_id|IND-001") - self.assertEqual(len(domain), 2) - self.assertIn( - ( - "reg_ids.id_type_id.uri", - "=", - "urn:openspp:vocab:id-type#test_national_id", - ), + self.assertEqual( domain, + [ + ( + "reg_ids", + "any", + [ + ("id_type_id.uri", "=", "urn:openspp:vocab:id-type#test_national_id"), + ("value", "=", "IND-001"), + ], + ) + ], ) - self.assertIn(("reg_ids.value", "=", "IND-001"), domain) def test_search_by_identifier(self): """Search by identifier finds exact match""" From 370e07f1c013a4be6681f07260cb9bd2ff8c0e7d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:22:09 +0800 Subject: [PATCH 04/53] fix(spp_api_v2): make search filters fail closed (#554) An unknown group, gender, membership role or member made its parser return an empty domain, so the filter dropped out and the search returned the whole registry. Malformed values were dropped the same way. - malformed filters raise InvalidSearchParam, answered as 400 - well-formed filters naming nothing match nothing - group, membership-role, identifier and member conditions use 'any' so they hold on the same related row; ?member= excludes ended memberships --- spp_api_v2/__manifest__.py | 2 +- spp_api_v2/readme/HISTORY.md | 5 + spp_api_v2/routers/group.py | 7 +- spp_api_v2/routers/individual.py | 7 +- spp_api_v2/services/search_service.py | 139 ++++++++++++++++---------- 5 files changed, 104 insertions(+), 56 deletions(-) diff --git a/spp_api_v2/__manifest__.py b/spp_api_v2/__manifest__.py index 213b07c74..3d4ddb527 100644 --- a/spp_api_v2/__manifest__.py +++ b/spp_api_v2/__manifest__.py @@ -1,7 +1,7 @@ { "name": "OpenSPP API V2", "category": "OpenSPP/Integration", - "version": "19.0.2.1.1", + "version": "19.0.2.2.0", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 6fdfa4de3..4068b5efa 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -1,3 +1,8 @@ +### 19.0.2.2.0 + +- fix: search filters fail closed (#554). A filter that names nothing (an unknown `group`, `gender`, `membership-role` or `member`) now returns no results instead of silently dropping out and returning the whole registry. Malformed filters (`identifier`, `group`, `gender`, `birthdate`, `_lastUpdated` without the expected format; `member` not an `Individual/{system}|{value}` reference) return `400` on `GET /Individual` and `GET /Group`. +- fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. + ### 19.0.2.1.1 - chore(api_v2): the API V2 configuration menu moved from Registry > Configuration to Settings > Registry, alongside the other superuser configuration (#1009) diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 926159141..d5ecf1d30 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -41,7 +41,7 @@ from ..services.consent_service import ConsentService from ..services.field_filter import filter_fields, filter_list from ..services.group_service import GroupService -from ..services.search_service import SearchService +from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent from .dependencies import check_group_access, parse_identifier, parse_resource_reference @@ -199,7 +199,10 @@ async def search_groups( def search_function(offset, limit): search_params = {**params, "_count": limit, "_offset": offset} - return search_service.search_groups(search_params) + try: + return search_service.search_groups(search_params) + except InvalidSearchParam as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e def consent_filter_function(group): group_data = group_service.to_api_schema(group, extensions=extension_list) diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 9916edfef..64277244b 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -33,7 +33,7 @@ from ..services.consent_service import ConsentService from ..services.field_filter import filter_fields, filter_list from ..services.individual_service import IndividualService -from ..services.search_service import SearchService +from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent from .dependencies import check_individual_access, parse_identifier @@ -214,7 +214,10 @@ async def search_individuals( def search_function(offset, limit): search_params = {**params, "_count": limit, "_offset": offset} - return search_service.search_individuals(search_params) + try: + return search_service.search_individuals(search_params) + except InvalidSearchParam as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e def consent_filter_function(partner): data = individual_service.to_api_schema(partner, extensions=extension_list) diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index 371c8e8d6..f8538a735 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -10,6 +10,14 @@ _logger = logging.getLogger(__name__) +# A filter that names nothing (unknown group, role, gender, member) must +# narrow the result to nothing, never drop out and widen it. +MATCH_NOTHING = [("id", "=", 0)] + + +class InvalidSearchParam(ValueError): + """A search parameter is malformed; the API answers 400.""" + class SearchService: """Service for search parameter parsing and execution""" @@ -125,35 +133,7 @@ def search_groups(self, params: dict[str, Any]) -> tuple[list, int]: domain.append(("name", "ilike", params["name"])) if params.get("member"): - # Search for groups with specific member - member_ref = params["member"] - if member_ref.startswith("Individual/"): - ident_str = member_ref.replace("Individual/", "") - if "|" in ident_str: - system, value = ident_str.split("|", 1) - # Find individual first - # Use id_type_id.uri (full URI with code) instead of namespace_uri - # (which only contains the vocabulary namespace) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system), - ("value", "=", value), - ], - limit=1, - ) - ) - if reg_id: - # Search via group_membership_ids relationship - domain.append( - ( - "group_membership_ids.individual", - "=", - reg_id.partner_id.id, - ) - ) + domain.extend(self._parse_member_param(params["member"])) # Execute search with sudo() to access registry.id via domain Partner = self.env["res.partner"] @@ -184,16 +164,23 @@ def _parse_identifier_param(self, identifier: str) -> list: Returns Odoo domain for identifier search. """ if "|" not in identifier: - _logger.warning("Invalid identifier format: %s", identifier) - return [] + raise InvalidSearchParam("Invalid identifier format. Expected: {system}|{value}") system, value = identifier.split("|", 1) # Search via registry.id with id_type_id.uri (full code URI) # Format: urn:openspp:vocab:id-type#national_id|VALUE + # "any" keeps both conditions on the same registry ID; two dotted + # leaves would match a system from one ID and a value from another. return [ - ("reg_ids.id_type_id.uri", "=", system), - ("reg_ids.value", "=", value), + ( + "reg_ids", + "any", + [ + ("id_type_id.uri", "=", system), + ("value", "=", value), + ], + ) ] def _parse_date_param(self, field: str, value: str) -> list: @@ -229,9 +216,10 @@ def _parse_date_param(self, field: str, value: str) -> list: # Try to parse as date parsed_date = datetime.strptime(date_str, "%Y-%m-%d").date() return [(field, operator, parsed_date)] - except ValueError: - _logger.warning("Invalid date format: %s", date_str) - return [] + except ValueError as e: + raise InvalidSearchParam( + f"Invalid date for {field}: expected an optional prefix (eq, ne, gt, ge, lt, le) and YYYY-MM-DD" + ) from e def _parse_gender_param(self, gender: str) -> list: """ @@ -240,7 +228,7 @@ def _parse_gender_param(self, gender: str) -> list: Returns domain for gender_id search. """ if "|" not in gender: - return [] + raise InvalidSearchParam("Invalid gender format. Expected: {system}|{code}") system, code = gender.split("|", 1) @@ -260,7 +248,7 @@ def _parse_gender_param(self, gender: str) -> list: if gender_code: return [("gender_id", "=", gender_code.id)] - return [] + return MATCH_NOTHING def _parse_group_param(self, group: str) -> list: """ @@ -281,8 +269,7 @@ def _parse_group_param(self, group: str) -> list: # Standard case: filter by specific group if "|" not in group: - _logger.warning("Invalid group identifier format: %s", group) - return [] + raise InvalidSearchParam('Invalid group format. Expected: {system}|{value} or "none"') system, value = group.split("|", 1) @@ -302,14 +289,19 @@ def _parse_group_param(self, group: str) -> list: ) if not reg_id or not reg_id.partner_id.is_group: - _logger.warning("Group not found for identifier: %s", group) - return [] + return MATCH_NOTHING - # Find individuals who are members of this group - # Use individual_membership_ids relationship + # Find individuals with an active membership in this group; "any" + # keeps both conditions on the same membership row return [ - ("individual_membership_ids.group", "=", reg_id.partner_id.id), - ("individual_membership_ids.is_ended", "=", False), + ( + "individual_membership_ids", + "any", + [ + ("group", "=", reg_id.partner_id.id), + ("is_ended", "=", False), + ], + ) ] def _parse_membership_role_param(self, role: str) -> list: @@ -335,13 +327,58 @@ def _parse_membership_role_param(self, role: str) -> list: ) if not role_code: - _logger.warning("Membership role not found: %s", role) - return [] + return MATCH_NOTHING - # Find individuals with this membership role in any active group + # Find individuals holding this role on an active membership; "any" + # keeps both conditions on the same membership row return [ - ("individual_membership_ids.membership_type_ids", "in", role_code.id), - ("individual_membership_ids.is_ended", "=", False), + ( + "individual_membership_ids", + "any", + [ + ("membership_type_ids", "in", role_code.id), + ("is_ended", "=", False), + ], + ) + ] + + def _parse_member_param(self, member: str) -> list: + """ + Parse the groups ``member`` parameter (format: Individual/{system}|{value}). + + Returns domain for groups with an active membership of that individual. + """ + ident_str = member.removeprefix("Individual/") + if ident_str == member or "|" not in ident_str: + raise InvalidSearchParam("Invalid member format. Expected: Individual/{system}|{value}") + + system, value = ident_str.split("|", 1) + + # Use id_type_id.uri (full URI with code) instead of namespace_uri + # (which only contains the vocabulary namespace) + reg_id = ( + self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search( + [ + ("id_type_id.uri", "=", system), + ("value", "=", value), + ], + limit=1, + ) + ) + if not reg_id: + return MATCH_NOTHING + + return [ + ( + "group_membership_ids", + "any", + [ + ("individual", "=", reg_id.partner_id.id), + ("is_ended", "=", False), + ], + ) ] def _parse_sort_param(self, sort: str | None) -> str: From d6d0b9dd0a3bd10bb7fa0ff846bf0ef2a561aba8 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:27:17 +0800 Subject: [PATCH 05/53] test(spp_api_v2): add failing tests that returned references can be followed (#554) Covers item F: Group members, $add-member/$remove-member responses, membership history and Individual groupMembership build references from the vocabulary namespace instead of the identifier type's code URI. --- spp_api_v2/tests/__init__.py | 1 + .../tests/test_references_resolvable.py | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 spp_api_v2/tests/test_references_resolvable.py diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 1c225f469..7736351fe 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -38,3 +38,4 @@ from . import test_schema_builder from . import test_search_service from . import test_search_filters_fail_closed +from . import test_references_resolvable diff --git a/spp_api_v2/tests/test_references_resolvable.py b/spp_api_v2/tests/test_references_resolvable.py new file mode 100644 index 000000000..9755bb885 --- /dev/null +++ b/spp_api_v2/tests/test_references_resolvable.py @@ -0,0 +1,115 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Every reference the API returns can be followed back (#554 item F). + +References must carry the identifier type's full code URI +(``id_type_id.uri``, e.g. ``urn:openspp:vocab:id-type#test_national_id``), +the value lookups match on — not the vocabulary namespace alone. +""" + +import json +from urllib.parse import quote + +from .common import ApiV2HttpTestCase + +GROUP_PATH = "/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|REF-HH-001" + + +class TestReferencesResolvable(ApiV2HttpTestCase): + """Follow Individual/Group references from each response that returns them""" + + def setUp(self): + super().setUp() + self.member = self.create_test_individual(name="Ref Member", identifier_value="REF-IND-001") + self.newcomer = self.create_test_individual(name="Ref Newcomer", identifier_value="REF-IND-002") + self.group = self.create_test_group( + name="Ref Household", + identifier_value="REF-HH-001", + members=[(self.member, self.relationship_head)], + ) + self.client = self.create_api_client( + name="Reference Client", + scopes=[ + {"resource": "individual", "action": "read"}, + {"resource": "group", "action": "read"}, + {"resource": "group", "action": "update"}, + ], + ) + for registrant in (self.member, self.newcomer, self.group): + self.create_consent( + registrant=registrant, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self): + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"} + + def _get(self, path): + return self.url_open(path, headers=self._headers()) + + def _post(self, path, payload): + return self.url_open(path, data=json.dumps(payload), headers=self._headers()) + + def _assert_followable(self, reference, expected_value): + """GET the referenced resource and check it is the one named""" + resource_type, identifier = reference.split("/", 1) + response = self._get(f"/api/v2/spp/{resource_type}/{quote(identifier, safe=':|')}") + self.assertEqual(response.status_code, 200, f"{reference} → {response.status_code} {response.text}") + values = {ident["value"] for ident in response.json()["identifier"]} + self.assertIn(expected_value, values) + + def test_group_member_entity_reference(self): + """GET /Group/{id}: member[].entity.reference""" + response = self._get(GROUP_PATH) + self.assertEqual(response.status_code, 200, response.text) + + members = response.json()["member"] + self.assertTrue(members) + for member in members: + self._assert_followable(member["entity"]["reference"], "REF-IND-001") + + def test_add_member_response_references(self): + """POST $add-member: group and entity references in the response""" + response = self._post( + f"{GROUP_PATH}/$add-member", + {"entity": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|REF-IND-002"}}, + ) + self.assertIn(response.status_code, (200, 201), response.text) + data = response.json() + + self._assert_followable(data["group"]["reference"], "REF-HH-001") + self._assert_followable(data["entity"]["reference"], "REF-IND-002") + + def test_remove_member_response_references(self): + """POST $remove-member: group and entity references in the response""" + response = self._post( + f"{GROUP_PATH}/$remove-member", + {"entity": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|REF-IND-001"}}, + ) + self.assertEqual(response.status_code, 200, response.text) + data = response.json() + + self._assert_followable(data["group"]["reference"], "REF-HH-001") + self._assert_followable(data["entity"]["reference"], "REF-IND-001") + + def test_membership_history_member_reference(self): + """GET membership-history: data[].member.reference""" + response = self._get(f"{GROUP_PATH}/membership-history") + self.assertEqual(response.status_code, 200, response.text) + + entries = response.json()["data"] + self.assertTrue(entries) + for entry in entries: + self._assert_followable(entry["member"]["reference"], "REF-IND-001") + + def test_individual_group_membership_reference(self): + """GET /Individual/{id}: groupMembership[].group.reference""" + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|REF-IND-001") + self.assertEqual(response.status_code, 200, response.text) + + memberships = response.json()["groupMembership"] + self.assertTrue(memberships) + for membership in memberships: + self._assert_followable(membership["group"]["reference"], "REF-HH-001") From cc4b2f6a646ef35622155816e13eea2918ab870f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:30:03 +0800 Subject: [PATCH 06/53] fix(spp_api_v2): build followable references from the id type's code URI (#554) Group members, $add-member/$remove-member responses, membership history and Individual groupMembership built references from the vocabulary namespace (urn:openspp:vocab:id-type|...), which no lookup matches, so following them failed. Use id_type_id.uri, as identifier[].system does. --- spp_api_v2/readme/HISTORY.md | 1 + spp_api_v2/services/group_service.py | 6 ++++-- spp_api_v2/services/individual_service.py | 3 ++- spp_api_v2/services/membership_utils.py | 6 ++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 4068b5efa..f1ac5c593 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -2,6 +2,7 @@ - fix: search filters fail closed (#554). A filter that names nothing (an unknown `group`, `gender`, `membership-role` or `member`) now returns no results instead of silently dropping out and returning the whole registry. Malformed filters (`identifier`, `group`, `gender`, `birthdate`, `_lastUpdated` without the expected format; `member` not an `Individual/{system}|{value}` reference) return `400` on `GET /Individual` and `GET /Group`. - fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. +- fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. ### 19.0.2.1.1 diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index d9982c5a1..7bcbf7e43 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -206,7 +206,8 @@ def _build_member(self, membership) -> dict[str, Any]: # Build reference to individual primary_id = individual.reg_ids[0] entity_ref = { - "reference": f"Individual/{primary_id.namespace_uri}|{primary_id.value}", + # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves + "reference": f"Individual/{primary_id.id_type_id.uri}|{primary_id.value}", "display": individual.name, } @@ -1207,7 +1208,8 @@ def get_membership_history(self, group, limit=100, offset=0, since=None) -> list # Build individual reference primary_id = individual.reg_ids[0] member_ref = Reference( - reference=f"Individual/{primary_id.namespace_uri}|{primary_id.value}", + # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves + reference=f"Individual/{primary_id.id_type_id.uri}|{primary_id.value}", display=individual.name, ) diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index b50479c35..aa5679237 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -283,7 +283,8 @@ def _build_group_reference(self, group) -> dict: # Get primary identifier for group if group.reg_ids: primary_id = group.reg_ids[0] - ref = f"Group/{primary_id.namespace_uri}|{primary_id.value}" + # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves + ref = f"Group/{primary_id.id_type_id.uri}|{primary_id.value}" else: # No identifier - this should not happen in a properly configured system _logger.error( diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index fba7ff8ad..d3167aa79 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -28,8 +28,10 @@ def membership_to_response(membership) -> dict[str, Any]: if not group_id: raise ValidationError(f"Group {group.name} has no valid external identifiers") + # id_type_id.uri (full code URI), NOT namespace_uri: the reference must + # resolve through the identifier lookups, which match on the code URI group_ref = { - "reference": f"Group/{group_id.namespace_uri}|{group_id.value}", + "reference": f"Group/{group_id.id_type_id.uri}|{group_id.value}", "display": group.name, } @@ -40,7 +42,7 @@ def membership_to_response(membership) -> dict[str, Any]: raise ValidationError(f"Individual {individual.name} has no valid external identifiers") individual_ref = { - "reference": f"Individual/{individual_id.namespace_uri}|{individual_id.value}", + "reference": f"Individual/{individual_id.id_type_id.uri}|{individual_id.value}", "display": individual.name, } From d19e5d8ba525aee51fbd1dca143a1a5c4eefffc5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:34:12 +0800 Subject: [PATCH 07/53] test(spp_api_v2): add failing tests that ending a membership now is immediate (#554) Covers item G: $remove-member, merge and split end memberships with a microsecond datetime.now(), so the stored is_ended/status stay active until the repair cron runs. --- spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_membership_end_now.py | 150 ++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 spp_api_v2/tests/test_membership_end_now.py diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 7736351fe..d4ba41108 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -39,3 +39,4 @@ from . import test_search_service from . import test_search_filters_fail_closed from . import test_references_resolvable +from . import test_membership_end_now diff --git a/spp_api_v2/tests/test_membership_end_now.py b/spp_api_v2/tests/test_membership_end_now.py new file mode 100644 index 000000000..5ab704dd0 --- /dev/null +++ b/spp_api_v2/tests/test_membership_end_now.py @@ -0,0 +1,150 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Ending a membership "now" takes effect immediately (#554 item G). + +The stored ``is_ended``/``status`` computes compare ``ended_date`` with +``fields.Datetime.now()``, which has whole-second precision. An end date +written with microseconds (``datetime.now()``) is a fraction of a second +in the future at compute time, so the row was stored as still active and +only corrected later by the repair cron. +""" + +import json + +from ..services.group_service import GroupService +from .common import ApiV2HttpTestCase, ApiV2TestCase + +HOUSEHOLD_ID = "urn:openspp:vocab:id-type#test_household_id" + + +def _stored_end_state(env, membership): + """ended_date, is_ended, status as stored in the database (not the cache)""" + env.flush_all() + env.cr.execute( + "SELECT ended_date, is_ended, status FROM spp_group_membership WHERE id = %s", + (membership.id,), + ) + return env.cr.fetchone() + + +class TestMembershipEndNow(ApiV2TestCase): + """GroupService operations that end memberships at the current time""" + + def setUp(self): + super().setUp() + self.service = GroupService(self.env) + self.head = self.create_test_individual(name="End Head", identifier_value="END-IND-HEAD") + self.member_a = self.create_test_individual(name="End A", identifier_value="END-IND-A") + self.member_b = self.create_test_individual(name="End B", identifier_value="END-IND-B") + + def _membership(self, group, individual): + return self.env["spp.group.membership"].search( + [("group", "=", group.id), ("individual", "=", individual.id)], limit=1 + ) + + def _assert_ended_now(self, membership): + ended_date, is_ended, status = _stored_end_state(self.env, membership) + self.assertTrue(ended_date) + self.assertEqual(ended_date.microsecond, 0, "ended_date must use the ORM's second precision") + self.assertTrue(is_ended, "stored is_ended must be true without waiting for the repair cron") + self.assertEqual(status, "inactive") + + def test_remove_member_ends_membership_immediately(self): + group = self.create_test_group( + name="End Remove", + identifier_value="END-HH-REMOVE", + members=[(self.head, self.relationship_head), (self.member_a, None)], + ) + + response = self.service.remove_member(group, self.member_a) + + self.assertEqual(response["status"], "inactive") + self._assert_ended_now(self._membership(group, self.member_a)) + + def test_merge_ends_source_memberships_immediately(self): + source = self.create_test_group( + name="End Merge Source", + identifier_value="END-HH-MERGE-SRC", + members=[(self.member_a, None), (self.member_b, None)], + ) + target = self.create_test_group( + name="End Merge Target", + identifier_value="END-HH-MERGE-TGT", + members=[(self.head, self.relationship_head)], + ) + source_memberships = source.group_membership_ids + + self.service.merge_groups(source, target) + + for membership in source_memberships: + self._assert_ended_now(membership) + + def test_split_ends_moved_memberships_immediately(self): + source = self.create_test_group( + name="End Split Source", + identifier_value="END-HH-SPLIT-SRC", + members=[(self.head, self.relationship_head), (self.member_a, None), (self.member_b, None)], + ) + moved = self._membership(source, self.member_a) + + self.service.split_group( + source, + new_identifiers=[{"system": HOUSEHOLD_ID, "value": "END-HH-SPLIT-NEW"}], + members_to_move=[self.member_a], + ) + + self._assert_ended_now(moved) + self.assertFalse(_stored_end_state(self.env, self._membership(source, self.member_b))[1]) + + +class TestRemoveMemberEndNowAPI(ApiV2HttpTestCase): + """$remove-member without endedDate: the response and searches agree at once""" + + def setUp(self): + super().setUp() + self.head = self.create_test_individual(name="API End Head", identifier_value="END-API-HEAD") + self.leaver = self.create_test_individual(name="API End Leaver", identifier_value="END-API-LEAVER") + self.group = self.create_test_group( + name="API End Household", + identifier_value="END-API-HH", + members=[(self.head, self.relationship_head), (self.leaver, None)], + ) + self.client = self.create_api_client( + name="End Now Client", + scopes=[ + {"resource": "individual", "action": "read"}, + {"resource": "group", "action": "read"}, + {"resource": "group", "action": "update"}, + ], + ) + for registrant in (self.head, self.leaver, self.group): + self.create_consent( + registrant=registrant, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self): + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"} + + def test_remove_member_is_effective_immediately(self): + response = self.url_open( + "/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|END-API-HH/$remove-member", + data=json.dumps( + {"entity": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|END-API-LEAVER"}} + ), + headers=self._headers(), + ) + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["status"], "inactive") + self.assertIn("endedDate", response.json()) + + search = self.url_open( + "/api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23test_household_id%7CEND-API-HH", + headers=self._headers(), + ) + self.assertEqual(search.status_code, 200, search.text) + values = {resource["identifier"][0]["value"] for resource in search.json().get("data") or []} + self.assertIn("END-API-HEAD", values) + self.assertNotIn("END-API-LEAVER", values) From 91d20fc9ecdf48dd999ca471de62183f7319069e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 10:36:52 +0800 Subject: [PATCH 08/53] fix(spp_api_v2): end memberships on the ORM clock so removal is immediate (#554) $remove-member without endedDate, and the member moves in merge and split, wrote ended_date with datetime.now(). Its microseconds put the end a fraction of a second after the second-precision fields.Datetime.now() the is_ended/status computes use, so the row was stored as active until the repair cron ran. Use fields.Datetime.now(), also in GET /Group's member filter so both agree. --- spp_api_v2/readme/HISTORY.md | 1 + spp_api_v2/services/group_service.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index f1ac5c593..d905ac54f 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -3,6 +3,7 @@ - fix: search filters fail closed (#554). A filter that names nothing (an unknown `group`, `gender`, `membership-role` or `member`) now returns no results instead of silently dropping out and returning the whole registry. Malformed filters (`identifier`, `group`, `gender`, `birthdate`, `_lastUpdated` without the expected format; `member` not an `Individual/{system}|{value}` reference) return `400` on `GET /Individual` and `GET /Group`. - fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. - fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. +- fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). ### 19.0.2.1.1 diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 7bcbf7e43..bff53165d 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -5,7 +5,7 @@ from datetime import datetime, time from typing import Any -from odoo import Command, _ +from odoo import Command, _, fields from odoo.api import Environment from odoo.exceptions import AccessError, ValidationError @@ -139,7 +139,8 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: # Members - filter by ended_date directly for reliability # (is_ended computed field may have timing issues in tests) - now = datetime.now() + # fields.Datetime.now(): the same second-precision clock as the is_ended compute + now = fields.Datetime.now() members = [] for membership in group.group_membership_ids.filtered(lambda m: not m.ended_date or m.ended_date > now): if membership.individual: @@ -746,7 +747,10 @@ def remove_member(self, group, individual, ended_date=None, reason=None) -> dict ended_date = datetime.fromisoformat(ended_date.replace("Z", "+00:00")).date() ended_datetime = datetime.combine(ended_date, time.min) else: - ended_datetime = datetime.now() + # fields.Datetime.now(), not datetime.now(): the is_ended/status + # computes compare against whole seconds, so a microsecond end + # time would be "in the future" and the row stored as active + ended_datetime = fields.Datetime.now() membership.sudo().write({"ended_date": ended_datetime}) # nosemgrep: odoo-sudo-without-context @@ -884,7 +888,8 @@ def merge_groups(self, source_group, target_group, role_mapping=None) -> dict: ) # Process each source member - now = datetime.now() + # fields.Datetime.now(): second precision, so ended memberships are ended at once + now = fields.Datetime.now() for source_membership in source_members: individual = source_membership.individual @@ -1098,7 +1103,8 @@ def split_group(self, source_group, new_identifiers, members_to_move, new_head=N new_group = self.env["res.partner"].sudo().create(new_group_vals) # Move members to new group - now = datetime.now() + # fields.Datetime.now(): second precision, so ended memberships are ended at once + now = fields.Datetime.now() for individual in members_to_move: membership = membership_by_individual[individual.id] From 10b6cfc20fe50feb6bdf737fa506b922c0b0e873 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 11:40:21 +0800 Subject: [PATCH 09/53] test(spp_api_v2): add failing tests for ambiguous and in-use identifiers (#554) Covers item I: POST /Individual and /Group accept an identifier already live on another registrant, and every lookup then silently picks one of them (PATCH deactivated the wrong record). Soft-removed IDs still resolve. Adds the registrant_resolver module skeleton the tests import. --- spp_api_v2/services/registrant_resolver.py | 20 ++ spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_identifier_ambiguity.py | 281 ++++++++++++++++++ .../tests/test_program_membership_identity.py | 54 ++++ 4 files changed, 356 insertions(+) create mode 100644 spp_api_v2/services/registrant_resolver.py create mode 100644 spp_api_v2/tests/test_identifier_ambiguity.py diff --git a/spp_api_v2/services/registrant_resolver.py b/spp_api_v2/services/registrant_resolver.py new file mode 100644 index 000000000..8aa9d5d11 --- /dev/null +++ b/spp_api_v2/services/registrant_resolver.py @@ -0,0 +1,20 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Resolve an external identifier to exactly one registrant.""" + +from odoo.exceptions import ValidationError + + +class AmbiguousIdentifierError(Exception): + """The identifier is live on more than one registrant.""" + + def __init__(self, partners): + super().__init__("Identifier matches more than one registrant") + self.partners = partners + + +class IdentifierInUseError(ValidationError): + """The identifier is already live on another registrant.""" + + +def resolve_registrant(env, system_uri, value, is_group=None): + raise NotImplementedError diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index d4ba41108..56effab7a 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -40,3 +40,4 @@ from . import test_search_filters_fail_closed from . import test_references_resolvable from . import test_membership_end_now +from . import test_identifier_ambiguity diff --git a/spp_api_v2/tests/test_identifier_ambiguity.py b/spp_api_v2/tests/test_identifier_ambiguity.py new file mode 100644 index 000000000..47791436e --- /dev/null +++ b/spp_api_v2/tests/test_identifier_ambiguity.py @@ -0,0 +1,281 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Identifiers the API cannot resolve to one registrant (#554 item I). + +The registry allows two registrants to share an ID type and value (the +ID-document deduplication manager exists to find them), but every API +lookup addresses one record. So the API: + +- refuses to create an identifier already live on another registrant (409); +- refuses to guess when a lookup matches several registrants: 409, or, + for a client that needs consent and lacks it for any of them, the same + 403 as "not found"; +- ignores soft-removed (``invalid``) IDs when resolving. +""" + +import json + +from ..schemas.base import HumanName, Identifier +from ..schemas.individual import Individual +from ..services import registrant_resolver +from ..services.group_service import GroupService +from ..services.individual_service import IndividualService +from .common import ApiV2HttpTestCase, ApiV2TestCase + +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" +HOUSEHOLD_ID = "urn:openspp:vocab:id-type#test_household_id" + + +class TestRegistrantResolver(ApiV2TestCase): + """Shared resolution of an identifier to one registrant""" + + def setUp(self): + super().setUp() + self.individual_service = IndividualService(self.env) + self.group_service = GroupService(self.env) + + def _invalidate_id(self, partner, value): + partner.reg_ids.filtered(lambda r: r.value == value).write({"status": "invalid"}) + + def test_single_match(self): + partner = self.create_test_individual(identifier_value="RES-ONE") + + found = registrant_resolver.resolve_registrant(self.env, NATIONAL_ID, "RES-ONE") + + self.assertEqual(found, partner) + + def test_shared_live_identifier_is_ambiguous(self): + first = self.create_test_individual(name="First", identifier_value="RES-DUP") + second = self.create_test_individual(name="Second", identifier_value="RES-DUP") + + with self.assertRaises(registrant_resolver.AmbiguousIdentifierError) as ctx: + registrant_resolver.resolve_registrant(self.env, NATIONAL_ID, "RES-DUP") + + self.assertEqual(ctx.exception.partners, first | second) + + def test_invalid_id_does_not_resolve(self): + partner = self.create_test_individual(identifier_value="RES-INVALID") + self._invalidate_id(partner, "RES-INVALID") + + self.assertFalse(registrant_resolver.resolve_registrant(self.env, NATIONAL_ID, "RES-INVALID")) + self.assertFalse(self.individual_service.find_by_identifier(NATIONAL_ID, "RES-INVALID")) + + def test_invalid_id_does_not_make_a_live_one_ambiguous(self): + removed = self.create_test_individual(name="Removed", identifier_value="RES-MIXED") + live = self.create_test_individual(name="Live", identifier_value="RES-MIXED") + self._invalidate_id(removed, "RES-MIXED") + + self.assertEqual(self.individual_service.find_by_identifier(NATIONAL_ID, "RES-MIXED"), live) + + def test_individual_lookup_raises_on_ambiguity(self): + self.create_test_individual(name="First", identifier_value="RES-IND-DUP") + self.create_test_individual(name="Second", identifier_value="RES-IND-DUP") + + with self.assertRaises(registrant_resolver.AmbiguousIdentifierError): + self.individual_service.find_by_identifier(NATIONAL_ID, "RES-IND-DUP") + + def test_group_lookup_raises_on_ambiguity(self): + self.create_test_group(name="First", identifier_value="RES-HH-DUP") + self.create_test_group(name="Second", identifier_value="RES-HH-DUP") + + with self.assertRaises(registrant_resolver.AmbiguousIdentifierError): + self.group_service.find_by_identifier(HOUSEHOLD_ID, "RES-HH-DUP") + + def _individual_schema(self, value, given="Copy"): + return Individual( + identifier=[Identifier(system=NATIONAL_ID, value=value)], + name=HumanName(given=given, family="Test"), + ) + + def test_create_individual_with_identifier_in_use_is_refused(self): + self.create_test_individual(identifier_value="RES-TAKEN") + + with self.assertRaises(registrant_resolver.IdentifierInUseError): + self.individual_service.create(self._individual_schema("RES-TAKEN"), source="test", api_authorized=True) + + self.assertEqual(self.env["spp.registry.id"].search_count([("value", "=", "RES-TAKEN")]), 1) + + def test_create_individual_may_reuse_an_invalid_identifier(self): + removed = self.create_test_individual(identifier_value="RES-FREED") + self._invalidate_id(removed, "RES-FREED") + + partner = self.individual_service.create( + self._individual_schema("RES-FREED"), source="test", api_authorized=True + ) + + self.assertEqual(self.individual_service.find_by_identifier(NATIONAL_ID, "RES-FREED"), partner) + + +class TestIdentifierAmbiguityAPI(ApiV2HttpTestCase): + """HTTP contract, for a client with a legal basis (no consent needed)""" + + SCOPES = [ + {"resource": "individual", "action": "read"}, + {"resource": "individual", "action": "create"}, + {"resource": "individual", "action": "update"}, + {"resource": "group", "action": "read"}, + {"resource": "group", "action": "create"}, + {"resource": "group", "action": "update"}, + ] + + def setUp(self): + super().setUp() + self.first = self.create_test_individual(name="Original", identifier_value="AMB-DUP") + self.second = self.create_test_individual(name="Copy", identifier_value="AMB-DUP") + self.group = self.create_test_group(name="Amb Household", identifier_value="AMB-HH") + self.client = self.create_api_client( + name="Legal Basis Client", + scopes=self.SCOPES, + require_consent=False, + legal_basis="public_task", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self, token=None): + return {"Content-Type": "application/json", "Authorization": f"Bearer {token or self.token}"} + + def _get(self, path, token=None): + return self.url_open(path, headers=self._headers(token)) + + def _post(self, path, payload, token=None): + return self.url_open(path, data=json.dumps(payload), headers=self._headers(token)) + + def test_post_individual_with_identifier_in_use_is_409(self): + response = self._post( + "/api/v2/spp/Individual", + { + "type": "Individual", + "identifier": [{"system": NATIONAL_ID, "value": "AMB-TAKEN"}], + "name": {"given": "Original"}, + }, + ) + self.assertEqual(response.status_code, 201, response.text) + + response = self._post( + "/api/v2/spp/Individual", + { + "type": "Individual", + "identifier": [{"system": NATIONAL_ID, "value": "AMB-TAKEN"}], + "name": {"given": "Copy"}, + }, + ) + + self.assertEqual(response.status_code, 409, response.text) + self.env.invalidate_all() + self.assertEqual(self.env["spp.registry.id"].search_count([("value", "=", "AMB-TAKEN")]), 1) + + def test_post_group_with_identifier_in_use_is_409(self): + response = self._post( + "/api/v2/spp/Group", + { + "type": "Group", + "identifier": [{"system": HOUSEHOLD_ID, "value": "AMB-HH"}], + "name": "Copy Household", + }, + ) + + self.assertEqual(response.status_code, 409, response.text) + self.env.invalidate_all() + self.assertEqual(self.env["spp.registry.id"].search_count([("value", "=", "AMB-HH")]), 1) + + def test_get_ambiguous_individual_is_409(self): + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-DUP") + + self.assertEqual(response.status_code, 409, response.text) + + def test_patch_ambiguous_individual_is_409_and_changes_nothing(self): + """The reported symptom: PATCH deactivated one of the two, arbitrarily""" + self.env.cr.flush() + response = self.url_patch( + "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-DUP", + data=json.dumps({"active": False}), + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 409, response.text) + self.env.invalidate_all() + self.assertTrue(self.first.active) + self.assertTrue(self.second.active) + + def test_search_by_ambiguous_identifier_lists_both(self): + """A search is not a lookup: it lists every match""" + response = self._get("/api/v2/spp/Individual?identifier=urn:openspp:vocab:id-type%23test_national_id%7CAMB-DUP") + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["meta"]["total"], 2) + + def test_invalid_identifier_is_not_found(self): + partner = self.create_test_individual(identifier_value="AMB-REMOVED") + partner.reg_ids.write({"status": "invalid"}) + + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-REMOVED") + + self.assertEqual(response.status_code, 404, response.text) + + def test_add_ambiguous_member_is_409(self): + response = self._post( + "/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|AMB-HH/$add-member", + {"entity": {"reference": f"Individual/{NATIONAL_ID}|AMB-DUP"}}, + ) + + self.assertEqual(response.status_code, 409, response.text) + self.env.invalidate_all() + self.assertFalse(self.group.group_membership_ids) + + def test_get_ambiguous_group_is_409(self): + self.create_test_group(name="Amb Copy Household", identifier_value="AMB-HH") + + response = self._get("/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|AMB-HH") + + self.assertEqual(response.status_code, 409, response.text) + + def test_bulk_export_reports_ambiguous_identifier(self): + response = self._post( + "/api/v2/spp/$bulk/export", + {"type": "Individual", "identifiers": [f"{NATIONAL_ID}|AMB-DUP"]}, + ) + + self.assertEqual(response.status_code, 200, response.text) + item = response.json()["items"][0] + self.assertEqual(item["status"], "error") + self.assertIn("more than one", item["error"]) + + def test_batch_read_of_ambiguous_identifier_is_409(self): + response = self._post( + "/api/v2/spp/$batch", + { + "resourceType": "Bundle", + "type": "batch", + "entry": [{"request": {"method": "GET", "url": f"Individual/{NATIONAL_ID}|AMB-DUP"}}], + }, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["entry"][0]["response"]["status"], "409 Conflict") + + # --- clients that need consent ---------------------------------------- + + def _consent_client_token(self, consented): + client = self.create_api_client(name="Consent Client", scopes=self.SCOPES) + for registrant in consented: + self.create_consent( + registrant=registrant, + grantee_partner=client.partner_id, + resource_type="all", + field_access="all", + ) + return self.generate_jwt_token(client) + + def test_consent_client_without_consent_for_every_match_gets_403(self): + """Ambiguity is not revealed unless the client may see every match""" + token = self._consent_client_token(consented=[self.first]) + + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-DUP", token) + + self.assertEqual(response.status_code, 403, response.text) + + def test_consent_client_with_consent_for_every_match_gets_409(self): + token = self._consent_client_token(consented=[self.first, self.second]) + + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-DUP", token) + + self.assertEqual(response.status_code, 409, response.text) diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 71bffe255..1ce77e09b 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -335,3 +335,57 @@ def test_update_matching_identity_writes_status(self): self.assertEqual(self.multi_p1.state, "exited") self.assertEqual(self.multi_p2.state, "enrolled") + + +class TestProgramMembershipAmbiguousBeneficiary(ApiV2HttpTestCase): + """A beneficiary identifier shared by two registrants addresses nothing (#554 I)""" + + def setUp(self): + super().setUp() + self.api_base_url = "/api/v2/spp/ProgramMembership" + self.program = self.create_test_program(name="First Identity Program") + self.first = self.create_test_individual(name="Original", identifier_value="PM-DUP") + self.second = self.create_test_individual(name="Copy", identifier_value="PM-DUP") + self.membership = self.create_test_membership(partner=self.first, program=self.program) + self.client = self.create_api_client( + name="Legal Basis Membership Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "create"}, + {"resource": "program_membership", "action": "update"}, + ], + require_consent=False, + legal_basis="public_task", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self): + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"} + + def test_get_with_ambiguous_beneficiary_is_409(self): + response = self.url_open( + f"{self.api_base_url}/{quote(NATIONAL_ID, safe=':')}|PM-DUP", + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 409, response.text) + + def test_post_with_ambiguous_beneficiary_is_409(self): + response = self.url_open( + self.api_base_url, + data=json.dumps( + { + "type": "ProgramMembership", + "program": {"reference": PROGRAM_1_REF}, + "beneficiary": {"reference": _beneficiary_ref("PM-DUP")}, + "status": "enrolled", + } + ), + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 409, response.text) + self.env.invalidate_all() + self.assertFalse( + self.env["spp.program.membership"].search([("partner_id", "=", self.second.id)]), + ) From a0ef75f7fff64fba5204814796387b947af83d8e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 11:59:23 +0800 Subject: [PATCH 10/53] fix(spp_api_v2): resolve identifiers to exactly one registrant (#554) The registry lets two registrants hold the same ID type and value (the ID-document deduplication manager exists to find them), but every API lookup took the first match, so reads and writes could hit either one. - POST /Individual and /Group refuse an identifier already live on another registrant (409) - a shared resolver (services/registrant_resolver) never picks one of several matches; routers answer 409, or the 'not found' 403 with jitter for a consent-requiring client lacking consent for any match - covers reads, updates, member operations, merge/split, search filters, bulk export, batch bundles and ProgramMembership beneficiaries - soft-removed (invalid) IDs no longer resolve; references, membership identifiers and Location use a live ID --- spp_api_v2/readme/HISTORY.md | 1 + spp_api_v2/routers/bulk.py | 13 ++ spp_api_v2/routers/group.py | 46 ++++--- spp_api_v2/routers/individual.py | 26 +++- spp_api_v2/services/bundle_service.py | 8 +- spp_api_v2/services/group_service.py | 109 +++++---------- spp_api_v2/services/individual_service.py | 70 +++------- spp_api_v2/services/membership_utils.py | 6 +- spp_api_v2/services/registrant_resolver.py | 125 +++++++++++++++++- spp_api_v2/services/search_service.py | 43 ++---- .../tests/test_references_resolvable.py | 38 ++++++ spp_api_v2/utils/registrant_lookup.py | 66 +++++++++ spp_api_v2_programs/readme/HISTORY.md | 1 + .../routers/program_membership.py | 21 ++- .../services/program_membership_service.py | 54 +++----- .../tests/test_program_membership_identity.py | 22 +++ 16 files changed, 429 insertions(+), 220 deletions(-) create mode 100644 spp_api_v2/utils/registrant_lookup.py diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index d905ac54f..de5e14c13 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -4,6 +4,7 @@ - fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. - fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). +- fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual` and `POST /Group` return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart. Lookups (read, update, patch, member operations, merge/split, search filters, bulk export, batch/transaction bundles) no longer pick one of several registrants holding an identifier: they return `409`, or, for a client that requires consent and lacks it for any of the matches, the same `403` as a registrant that does not exist. Soft-removed (`invalid`) IDs no longer resolve, and references are built from a live ID. Searching by `identifier=` still lists every match. ### 19.0.2.1.1 diff --git a/spp_api_v2/routers/bulk.py b/spp_api_v2/routers/bulk.py index e701480da..adbe6f8ca 100644 --- a/spp_api_v2/routers/bulk.py +++ b/spp_api_v2/routers/bulk.py @@ -18,6 +18,8 @@ from ..services.field_filter import filter_fields from ..services.group_service import GroupService from ..services.individual_service import IndividualService +from ..services.registrant_resolver import AmbiguousIdentifierError +from ..utils.registrant_lookup import AMBIGUOUS_DETAIL, ambiguous_identifier_status _logger = logging.getLogger(__name__) @@ -94,6 +96,17 @@ async def bulk_export( for identifier, _system, _value in parsed: record = records_map.get(identifier) + if isinstance(record, AmbiguousIdentifierError): + # Several registrants hold the identifier: refuse to pick one, + # without revealing it to a client that may not see them all + if ambiguous_identifier_status(env, api_client, record) == status.HTTP_403_FORBIDDEN: + await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # same jitter as "not found" + items.append(BulkExportItem(identifier=identifier, status="access_denied", error="Access denied")) + else: + items.append(BulkExportItem(identifier=identifier, status="error", error=AMBIGUOUS_DETAIL)) + failed += 1 + continue + if not record: if api_client.is_require_consent: # SECURITY: Add timing jitter to prevent enumeration via timing diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index d5ecf1d30..48a2f291b 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -41,8 +41,14 @@ from ..services.consent_service import ConsentService from ..services.field_filter import filter_fields, filter_list from ..services.group_service import GroupService +from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent +from ..utils.registrant_lookup import ( + ambiguous_identifier_exception, + lookup_registrant, + raise_ambiguous_identifier, +) from .dependencies import check_group_access, parse_identifier, parse_resource_reference _logger = logging.getLogger(__name__) @@ -82,7 +88,7 @@ async def read_group( # Find group service = GroupService(env) - group = service.find_by_identifier(system, value) + group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # SECURITY: Prevent user enumeration # For clients requiring consent, return same error for "not found" and "no consent" @@ -203,6 +209,9 @@ def search_function(offset, limit): return search_service.search_groups(search_params) except InvalidSearchParam as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + except AmbiguousIdentifierError as e: + # A filter reference (member=) matches more than one registrant + raise ambiguous_identifier_exception(env, api_client, e) from e def consent_filter_function(group): group_data = group_service.to_api_schema(group, extensions=extension_list) @@ -285,6 +294,13 @@ async def create_group( # api_authorized=True tells service to skip user group check since # API client scope (verified above) is the authorization for API calls group_record = service.create(group, source=source_system, api_authorized=True) + except IdentifierInUseError as e: + # Another registrant holds one of the identifiers: creating a second + # would leave neither addressable unambiguously + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=e.args[0]) from e + except AmbiguousIdentifierError as e: + # A member reference matches more than one individual + await raise_ambiguous_identifier(env, api_client, e) except ValidationError as ve: _logger.warning("Validation error creating group: %s", ve) raise HTTPException( @@ -343,7 +359,7 @@ async def update_group( # Find group service = GroupService(env) - group_record = service.find_by_identifier(system, value) + group_record = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # SECURITY: Prevent user enumeration if not group_record: @@ -438,7 +454,7 @@ async def patch_group( # Parse identifier and find group system, value = parse_identifier(identifier) service = GroupService(env) - group_record = service.find_by_identifier(system, value) + group_record = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(group_record, api_client, env, "update") @@ -509,7 +525,7 @@ async def add_member( # Parse identifier and find group system, value = parse_identifier(identifier) service = GroupService(env) - group = service.find_by_identifier(system, value) + group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(group, api_client, env, "update") @@ -518,7 +534,7 @@ async def add_member( ind_system, ind_value = parse_resource_reference(request.entity.reference, "Individual") # Find individual - individual = service._find_individual(ind_system, ind_value) + individual = await lookup_registrant(env, api_client, service._find_individual, ind_system, ind_value) if not individual: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -580,7 +596,7 @@ async def remove_member( # Parse identifier and find group system, value = parse_identifier(identifier) service = GroupService(env) - group = service.find_by_identifier(system, value) + group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(group, api_client, env, "update") @@ -589,7 +605,7 @@ async def remove_member( ind_system, ind_value = parse_resource_reference(request.entity.reference, "Individual") # Find individual - individual = service._find_individual(ind_system, ind_value) + individual = await lookup_registrant(env, api_client, service._find_individual, ind_system, ind_value) if not individual: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -646,13 +662,13 @@ async def update_member( # Find group service = GroupService(env) - group = service.find_by_identifier(system, value) + group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(group, api_client, env, "update") # Find individual - individual = service._find_individual(ind_system, ind_value) + individual = await lookup_registrant(env, api_client, service._find_individual, ind_system, ind_value) if not individual: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -722,8 +738,8 @@ async def merge_groups( # Find groups service = GroupService(env) - source_group = service.find_by_identifier(source_system, source_value) - target_group = service.find_by_identifier(target_system, target_value) + source_group = await lookup_registrant(env, api_client, service.find_by_identifier, source_system, source_value) + target_group = await lookup_registrant(env, api_client, service.find_by_identifier, target_system, target_value) # Security checks for both groups (with specific error messages) if not source_group: @@ -807,7 +823,7 @@ async def split_group( # Parse identifier and find source group system, value = parse_identifier(identifier) service = GroupService(env) - source_group = service.find_by_identifier(system, value) + source_group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(source_group, api_client, env, "update") @@ -825,7 +841,7 @@ async def split_group( ind_system, ind_value = parse_resource_reference(member_ref.reference, "Individual") # Find individual - individual = service._find_individual(ind_system, ind_value) + individual = await lookup_registrant(env, api_client, service._find_individual, ind_system, ind_value) if not individual: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -853,7 +869,7 @@ async def split_group( head_system, head_value = head_ident_str.split("|", 1) # Find new head individual - new_head = service._find_individual(head_system, head_value) + new_head = await lookup_registrant(env, api_client, service._find_individual, head_system, head_value) if not new_head: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -939,7 +955,7 @@ async def get_membership_history( # Parse identifier and find group system, value = parse_identifier(identifier) service = GroupService(env) - group = service.find_by_identifier(system, value) + group = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_group_access(group, api_client, env, "read") diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 64277244b..15989addc 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -33,8 +33,10 @@ from ..services.consent_service import ConsentService from ..services.field_filter import filter_fields, filter_list from ..services.individual_service import IndividualService +from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent +from ..utils.registrant_lookup import ambiguous_identifier_exception, lookup_registrant from .dependencies import check_individual_access, parse_identifier _logger = logging.getLogger(__name__) @@ -76,7 +78,7 @@ async def read_individual( # Find individual by namespace_uri + value service = IndividualService(env) - partner = service.find_by_identifier(system, value) + partner = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # SECURITY: Prevent user enumeration # For clients requiring consent, return same error for "not found" and "no consent" @@ -218,6 +220,9 @@ def search_function(offset, limit): return search_service.search_individuals(search_params) except InvalidSearchParam as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + except AmbiguousIdentifierError as e: + # A filter reference (group=) matches more than one registrant + raise ambiguous_identifier_exception(env, api_client, e) from e def consent_filter_function(partner): data = individual_service.to_api_schema(partner, extensions=extension_list) @@ -309,6 +314,19 @@ async def create_individual( # api_authorized=True tells service to skip user group check since # API client scope (verified above) is the authorization for API calls partner = service.create(individual, source=source_system, api_authorized=True) + except IdentifierInUseError as e: + # Another registrant holds one of the identifiers: creating a second + # would leave neither addressable unambiguously + audit_service.log_create( + "individual", + individual.identifier[0].system + "|" + individual.identifier[0].value + if individual.identifier + else "unknown", + None, + status="validation_error", + error_detail="Identifier in use", + ) + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=e.args[0]) from e except ValidationError as ve: _logger.warning("Validation error creating individual: %s", ve) # Log failed create attempt @@ -388,7 +406,7 @@ async def update_individual( ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) - partner = service.find_by_identifier(system, value) + partner = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_individual_access(partner, api_client, env, "update") @@ -464,7 +482,7 @@ async def patch_individual( ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) - partner = service.find_by_identifier(system, value) + partner = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_individual_access(partner, api_client, env, "update") @@ -547,7 +565,7 @@ async def get_individual_groups( # Parse identifier and find individual system, value = parse_identifier(identifier) service = IndividualService(env) - partner = service.find_by_identifier(system, value) + partner = await lookup_registrant(env, api_client, service.find_by_identifier, system, value) # Security checks (enumeration prevention + consent) await check_individual_access(partner, api_client, env, "read") diff --git a/spp_api_v2/services/bundle_service.py b/spp_api_v2/services/bundle_service.py index a3e5ed06b..16f2649c8 100644 --- a/spp_api_v2/services/bundle_service.py +++ b/spp_api_v2/services/bundle_service.py @@ -13,6 +13,7 @@ from ..schemas.individual import Individual from .group_service import GroupService from .individual_service import IndividualService +from .registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError _logger = logging.getLogger(__name__) @@ -580,7 +581,12 @@ def _create_error_response(self, exception: Exception) -> dict[str, Any]: Dict with status and OperationOutcome """ # Determine status code based on exception type - if isinstance(exception, ValidationError): + if isinstance(exception, AmbiguousIdentifierError | IdentifierInUseError): + # The identifier matches, or would match, more than one registrant + status_code = "409 Conflict" + severity = "error" + code = "conflict" + elif isinstance(exception, ValidationError): status_code = "422 Unprocessable Entity" severity = "error" code = "invalid" diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index bff53165d..a62a137f5 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -14,6 +14,12 @@ from ..schemas.membership import MembershipHistoryEntry from ..schemas.patch import GroupPatch from .membership_utils import membership_to_response +from .registrant_resolver import ( + assert_new_identifiers_free, + primary_registry_id, + resolve_registrant, + resolve_registrants, +) _logger = logging.getLogger(__name__) @@ -33,25 +39,13 @@ def find_by_identifier(self, system_uri: str, value: str): value: Identifier value Returns: - res.partner record (group) or empty recordset (in sudo context) + res.partner record (group) or empty recordset (in sudo context - + API handlers run as Public user) + + Raises: + AmbiguousIdentifierError: several groups hold the identifier """ - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system_uri), - ("value", "=", value), - ("partner_id.is_group", "=", True), - ], - limit=1, - ) - ) - if reg_id and reg_id.partner_id: - # Return sudo partner - API handlers run as Public user - # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models - return self.env["res.partner"].sudo().browse(reg_id.partner_id.id) - return self.env["res.partner"].sudo() # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context + return resolve_registrant(self.env, system_uri, value, is_group=True) def find_by_identifiers(self, identifiers: list[tuple[str, str]]): """ @@ -61,38 +55,11 @@ def find_by_identifiers(self, identifiers: list[tuple[str, str]]): identifiers: List of (system_uri, value) tuples Returns: - Dict mapping "system_uri|value" to res.partner record (or None) + Dict mapping "system_uri|value" to the res.partner record (sudo), + or to an AmbiguousIdentifierError when several groups hold it; + identifiers with no match are absent """ - if not identifiers: - return {} - - # Build OR domain for batch search - or_domains = [] - for system_uri, value in identifiers: - or_domains.append("&") - or_domains.append(("id_type_id.uri", "=", system_uri)) - or_domains.append(("value", "=", value)) - - # Combine with OR - if len(identifiers) > 1: - domain = ["|"] * (len(identifiers) - 1) + or_domains - else: - domain = or_domains - - # Add is_group filter - domain = ["&", ("partner_id.is_group", "=", True)] + domain - - reg_ids = self.env["spp.registry.id"].sudo().search(domain) # nosemgrep: odoo-sudo-without-context - - # Build result map - result = {} - for reg_id in reg_ids: - key = f"{reg_id.id_type_id.uri}|{reg_id.value}" - if reg_id.partner_id: - # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models - result[key] = self.env["res.partner"].sudo().browse(reg_id.partner_id.id) - - return result + return resolve_registrants(self.env, identifiers, is_group=True) def to_api_schema(self, group, extensions=None) -> dict[str, Any]: """ @@ -201,11 +168,12 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: def _build_member(self, membership) -> dict[str, Any]: """Build GroupMember from spp.group.membership record""" individual = membership.individual - if not individual or not individual.reg_ids: + # A live ID: removed IDs don't resolve + primary_id = primary_registry_id(individual) if individual else None + if not primary_id: return None # Build reference to individual - primary_id = individual.reg_ids[0] entity_ref = { # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves "reference": f"Individual/{primary_id.id_type_id.uri}|{primary_id.value}", @@ -368,6 +336,7 @@ def create(self, schema: Group, source: str, api_authorized: bool = False) -> An ) vals = self.from_api_schema(schema) + assert_new_identifiers_free(self.env, vals.get("reg_ids", [])) # Add source tracking (if field exists) if hasattr(self.env["res.partner"], "_fields") and "source_system" in self.env["res.partner"]._fields: @@ -481,35 +450,16 @@ def _find_individual(self, system_uri: str, value: str): system_uri: Full URI (e.g., urn:openspp:vocab:id-type#national_id) or namespace URI (e.g., urn:openspp:vocab:id-type) value: Identifier value + + Raises: + AmbiguousIdentifierError: several individuals hold the identifier """ # Try full URI first (id_type_id.uri = namespace#code) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system_uri), - ("value", "=", value), - ("partner_id.is_group", "=", False), - ], - limit=1, - ) - ) - if not reg_id and "#" not in system_uri: + partner = resolve_registrant(self.env, system_uri, value, is_group=False) + if not partner and "#" not in system_uri: # Fallback: try namespace_uri (without #code suffix) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("namespace_uri", "=", system_uri), - ("value", "=", value), - ("partner_id.is_group", "=", False), - ], - limit=1, - ) - ) - return reg_id.partner_id if reg_id else self.env["res.partner"] + partner = resolve_registrant(self.env, system_uri, value, is_group=False, system_field="namespace_uri") + return partner def update(self, group, schema: Group, source: str) -> Any: """ @@ -1208,11 +1158,12 @@ def get_membership_history(self, group, limit=100, offset=0, since=None) -> list # Build history entries for membership in memberships: individual = membership.individual - if not individual or not individual.reg_ids: + # A live ID: removed IDs don't resolve + primary_id = primary_registry_id(individual) if individual else None + if not primary_id: continue # Build individual reference - primary_id = individual.reg_ids[0] member_ref = Reference( # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves reference=f"Individual/{primary_id.id_type_id.uri}|{primary_id.value}", diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index aa5679237..9f27ed365 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -14,6 +14,12 @@ from ..schemas.individual import Individual from ..schemas.patch import IndividualPatch from .membership_utils import membership_to_response +from .registrant_resolver import ( + assert_new_identifiers_free, + primary_registry_id, + resolve_registrant, + resolve_registrants, +) _logger = logging.getLogger(__name__) @@ -35,25 +41,13 @@ def find_by_identifier(self, system_uri: str, value: str): value: Identifier value Returns: - res.partner record or empty recordset (in sudo context) + res.partner record or empty recordset (in sudo context - API + handlers run as Public user) + + Raises: + AmbiguousIdentifierError: several registrants hold the identifier """ - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context — API auth - .sudo() - .search( - [ - ("id_type_id.uri", "=", system_uri), - ("value", "=", value), - ], - limit=1, - ) - ) - if reg_id and reg_id.partner_id: - # Return sudo partner - API handlers run as Public user - # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context — API auth - return self.env["res.partner"].sudo().browse(reg_id.partner_id.id) - # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context - return self.env["res.partner"].sudo() + return resolve_registrant(self.env, system_uri, value) def find_by_identifiers(self, identifiers: list[tuple[str, str]]): """ @@ -63,36 +57,11 @@ def find_by_identifiers(self, identifiers: list[tuple[str, str]]): identifiers: List of (system_uri, value) tuples Returns: - Dict mapping "system_uri|value" to res.partner record (or None) + Dict mapping "system_uri|value" to the res.partner record (sudo), + or to an AmbiguousIdentifierError when several registrants hold + it; identifiers with no match are absent """ - if not identifiers: - return {} - - # Build OR domain for batch search - or_domains = [] - for system_uri, value in identifiers: - or_domains.append("&") - or_domains.append(("id_type_id.uri", "=", system_uri)) - or_domains.append(("value", "=", value)) - - # Combine with OR: need (n-1) | operators for n terms - if len(identifiers) > 1: - domain = ["|"] * (len(identifiers) - 1) + or_domains - else: - domain = or_domains - - # nosemgrep: odoo-sudo-without-context — API auth; batch identifier lookup - reg_ids = self.env["spp.registry.id"].sudo().search(domain) - - # Build result map - result = {} - for reg_id in reg_ids: - key = f"{reg_id.id_type_id.uri}|{reg_id.value}" - if reg_id.partner_id: - # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context — API auth - result[key] = self.env["res.partner"].sudo().browse(reg_id.partner_id.id) - - return result + return resolve_registrants(self.env, identifiers) def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: """ @@ -280,9 +249,9 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: def _build_group_reference(self, group) -> dict: """Build Reference to a Group""" - # Get primary identifier for group - if group.reg_ids: - primary_id = group.reg_ids[0] + # Get primary identifier for group (a live one: removed IDs don't resolve) + primary_id = primary_registry_id(group) + if primary_id: # id_type_id.uri (full code URI), NOT namespace_uri, so the reference resolves ref = f"Group/{primary_id.id_type_id.uri}|{primary_id.value}" else: @@ -498,6 +467,7 @@ def create(self, schema: Individual, source: str, api_authorized: bool = False) ) vals = self.from_api_schema(schema) + assert_new_identifiers_free(self.env, vals.get("reg_ids", [])) # Add source tracking (ADR-008) - if fields exist if hasattr(self.env["res.partner"], "_fields") and "source_system" in self.env["res.partner"]._fields: diff --git a/spp_api_v2/services/membership_utils.py b/spp_api_v2/services/membership_utils.py index d3167aa79..b9cdb88d5 100644 --- a/spp_api_v2/services/membership_utils.py +++ b/spp_api_v2/services/membership_utils.py @@ -5,6 +5,8 @@ from odoo.exceptions import ValidationError +from .registrant_resolver import primary_registry_id + def membership_to_response(membership) -> dict[str, Any]: """ @@ -24,7 +26,7 @@ def membership_to_response(membership) -> dict[str, Any]: """ # Build group reference group = membership.group - group_id = group.reg_ids[0] if group.reg_ids else None + group_id = primary_registry_id(group) if not group_id: raise ValidationError(f"Group {group.name} has no valid external identifiers") @@ -37,7 +39,7 @@ def membership_to_response(membership) -> dict[str, Any]: # Build individual reference individual = membership.individual - individual_id = individual.reg_ids[0] if individual.reg_ids else None + individual_id = primary_registry_id(individual) if not individual_id: raise ValidationError(f"Individual {individual.name} has no valid external identifiers") diff --git a/spp_api_v2/services/registrant_resolver.py b/spp_api_v2/services/registrant_resolver.py index 8aa9d5d11..88d21b5e6 100644 --- a/spp_api_v2/services/registrant_resolver.py +++ b/spp_api_v2/services/registrant_resolver.py @@ -1,8 +1,21 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -"""Resolve an external identifier to exactly one registrant.""" +"""Resolve an external identifier to exactly one registrant. + +The registry allows two registrants to hold the same ID type and value +(imported data awaiting deduplication; see the ID-document deduplication +manager in spp_programs), but an API lookup addresses one record. These +helpers never pick one of several matches: they raise, and the API refuses. + +Only *live* IDs count. A soft-removed ID (``status = 'invalid'``) neither +resolves nor blocks reuse; a NULL status is an ID added straight through +the registry and is live, the same rule as ``spp.registry.id``'s own +uniqueness index. +""" from odoo.exceptions import ValidationError +LIVE_ID = ("status", "!=", "invalid") + class AmbiguousIdentifierError(Exception): """The identifier is live on more than one registrant.""" @@ -16,5 +29,111 @@ class IdentifierInUseError(ValidationError): """The identifier is already live on another registrant.""" -def resolve_registrant(env, system_uri, value, is_group=None): - raise NotImplementedError +def live_registry_ids(partner): + """The registrant's IDs that resolve (not soft-removed), in ``reg_ids`` order.""" + return partner.reg_ids.filtered(lambda reg_id: reg_id.status != "invalid") + + +def primary_registry_id(partner): + """The ID references to this registrant are built from: its first live one (or empty).""" + return live_registry_ids(partner)[:1] + + +def _registry_ids(env): + # sudo: resolution is about the registry's data, not the API user's + # visibility; consent and scope are enforced by the caller + return env["spp.registry.id"].sudo() # nosemgrep: odoo-sudo-without-context + + +def resolve_registrant(env, system_uri, value, is_group=None, system_field="id_type_id.uri"): + """ + Return the one registrant holding a live ID ``system_uri|value``. + + Args: + env: Odoo environment + system_uri: Full code URI of the ID type (e.g. urn:openspp:vocab:id-type#national_id) + value: Identifier value + is_group: True/False to restrict to groups/individuals, None for either + system_field: registry ID field ``system_uri`` is matched on + + Returns: + res.partner record (sudo), or an empty recordset + + Raises: + AmbiguousIdentifierError: several registrants hold the identifier + """ + domain = [(system_field, "=", system_uri), ("value", "=", value), LIVE_ID] + if is_group is not None: + domain.append(("partner_id.is_group", "=", is_group)) + partners = _registry_ids(env).search(domain).partner_id + if len(partners) > 1: + raise AmbiguousIdentifierError(partners) + return partners + + +def resolve_registrants(env, identifiers, is_group=None): + """ + Batch form of :func:`resolve_registrant`. + + Args: + identifiers: list of (system_uri, value) tuples + + Returns: + dict mapping "system_uri|value" to the res.partner record, or to an + AmbiguousIdentifierError when several registrants hold it; keys with + no match are absent + """ + if not identifiers: + return {} + or_domains = [] + for system_uri, value in identifiers: + or_domains += ["&", ("id_type_id.uri", "=", system_uri), ("value", "=", value)] + domain = ["|"] * (len(identifiers) - 1) + or_domains + domain = ["&", LIVE_ID] + domain + if is_group is not None: + domain = ["&", ("partner_id.is_group", "=", is_group)] + domain + + matches = {} + for reg_id in _registry_ids(env).search(domain): + key = f"{reg_id.id_type_id.uri}|{reg_id.value}" + # reg_id is sudo, so its partner_id is too: the union stays in sudo + matches[key] = matches[key] | reg_id.partner_id if key in matches else reg_id.partner_id + return { + key: partners if len(partners) == 1 else AmbiguousIdentifierError(partners) for key, partners in matches.items() + } + + +def assert_identifier_free(env, id_type, value, exclude_partner=None): + """ + Raise unless no other registrant holds a live ID of ``id_type`` with ``value``. + + Args: + id_type: spp.vocabulary.code record of the ID type + value: Identifier value + exclude_partner: registrant allowed to hold it (the one being written) + + Raises: + IdentifierInUseError: another registrant holds the identifier + """ + domain = [("id_type_id", "=", id_type.id), ("value", "=", value), LIVE_ID] + if exclude_partner: + domain.append(("partner_id", "!=", exclude_partner.id)) + if _registry_ids(env).search_count(domain, limit=1): + raise IdentifierInUseError(f"Identifier {id_type.uri}|{value} is already in use by another registrant") + + +def assert_new_identifiers_free(env, reg_id_commands): + """ + Check the ``reg_ids`` create commands of a new registrant. + + A second registrant with an identifier someone else holds could never be + addressed unambiguously afterwards, so creation is refused instead. + + Raises: + IdentifierInUseError: another registrant holds one of the identifiers + """ + # nosemgrep: odoo-sudo-without-context + id_types = env["spp.vocabulary.code"].sudo() + for command in reg_id_commands: + reg_vals = command[2] + assert_identifier_free(env, id_types.browse(reg_vals["id_type_id"]), reg_vals["value"]) diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index f8538a735..b21745e74 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -8,6 +8,8 @@ from odoo.api import Environment from odoo.osv import expression +from .registrant_resolver import resolve_registrant + _logger = logging.getLogger(__name__) # A filter that names nothing (unknown group, role, gender, member) must @@ -273,22 +275,10 @@ def _parse_group_param(self, group: str) -> list: system, value = group.split("|", 1) - # Find group by identifier - # Use id_type_id.uri (full URI with code) instead of namespace_uri - # (which only contains the vocabulary namespace) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system), - ("value", "=", value), - ], - limit=1, - ) - ) - - if not reg_id or not reg_id.partner_id.is_group: + # Find group by identifier (raises AmbiguousIdentifierError when + # several groups hold it) + group_partner = resolve_registrant(self.env, system, value, is_group=True) + if not group_partner: return MATCH_NOTHING # Find individuals with an active membership in this group; "any" @@ -298,7 +288,7 @@ def _parse_group_param(self, group: str) -> list: "individual_membership_ids", "any", [ - ("group", "=", reg_id.partner_id.id), + ("group", "=", group_partner.id), ("is_ended", "=", False), ], ) @@ -354,20 +344,9 @@ def _parse_member_param(self, member: str) -> list: system, value = ident_str.split("|", 1) - # Use id_type_id.uri (full URI with code) instead of namespace_uri - # (which only contains the vocabulary namespace) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system), - ("value", "=", value), - ], - limit=1, - ) - ) - if not reg_id: + # Raises AmbiguousIdentifierError when several registrants hold it + member_partner = resolve_registrant(self.env, system, value) + if not member_partner: return MATCH_NOTHING return [ @@ -375,7 +354,7 @@ def _parse_member_param(self, member: str) -> list: "group_membership_ids", "any", [ - ("individual", "=", reg_id.partner_id.id), + ("individual", "=", member_partner.id), ("is_ended", "=", False), ], ) diff --git a/spp_api_v2/tests/test_references_resolvable.py b/spp_api_v2/tests/test_references_resolvable.py index 9755bb885..76d2ba8fd 100644 --- a/spp_api_v2/tests/test_references_resolvable.py +++ b/spp_api_v2/tests/test_references_resolvable.py @@ -113,3 +113,41 @@ def test_individual_group_membership_reference(self): self.assertTrue(memberships) for membership in memberships: self._assert_followable(membership["group"]["reference"], "REF-HH-001") + + def _add_removed_id(self, partner, value): + """Give the registrant a newer, soft-removed ID (it sorts first in reg_ids)""" + self.env["spp.registry.id"].create( + { + "partner_id": partner.id, + "id_type_id": self.id_type_household.id, + "value": value, + "status": "invalid", + } + ) + + def test_references_use_a_live_id_not_a_removed_one(self): + """A soft-removed ID no longer resolves, so references must not be built from it""" + self._add_removed_id(self.member, "REF-REMOVED-IND") + removed_group_type = self.env["spp.vocabulary.code"].search( + [("uri", "=", "urn:openspp:vocab:id-type#test_national_id")], limit=1 + ) + self.env["spp.registry.id"].create( + { + "partner_id": self.group.id, + "id_type_id": removed_group_type.id, + "value": "REF-REMOVED-HH", + "status": "invalid", + } + ) + + group_response = self._get(GROUP_PATH) + self.assertEqual(group_response.status_code, 200, group_response.text) + for member in group_response.json()["member"]: + self._assert_followable(member["entity"]["reference"], "REF-IND-001") + + individual_response = self._get( + "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|REF-IND-001" + ) + self.assertEqual(individual_response.status_code, 200, individual_response.text) + for membership in individual_response.json()["groupMembership"]: + self._assert_followable(membership["group"]["reference"], "REF-HH-001") diff --git a/spp_api_v2/utils/registrant_lookup.py b/spp_api_v2/utils/registrant_lookup.py new file mode 100644 index 000000000..86116fc0f --- /dev/null +++ b/spp_api_v2/utils/registrant_lookup.py @@ -0,0 +1,66 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""HTTP answer for an identifier that matches more than one registrant. + +The API refuses to guess (409). But saying "this identifier is ambiguous" +also says "it exists", so a client that needs consent only learns it when +it has consent for every matching registrant; otherwise it gets the same +403 as a registrant that does not exist (api-error-responses.md, +anti-enumeration). +""" + +import asyncio +import random + +from fastapi import HTTPException, status + +from ..services.consent_service import ConsentService +from ..services.registrant_resolver import AmbiguousIdentifierError + +AMBIGUOUS_DETAIL = "Identifier matches more than one registrant" + + +def ambiguous_identifier_status(env, api_client, error: AmbiguousIdentifierError, resource_type=None) -> int: + """ + 409 if the client may know the identifier is ambiguous, else 403. + + Args: + resource_type: consent resource to check; by default each match's own + kind ("group" or "individual") + """ + if api_client.is_require_consent: + consent_service = ConsentService(env) + for partner in error.partners: + kind = resource_type or ("group" if partner.is_group else "individual") + if not consent_service.check_access(partner.id, api_client, kind, "read"): + return status.HTTP_403_FORBIDDEN + return status.HTTP_409_CONFLICT + + +def ambiguous_identifier_exception(env, api_client, error, resource_type=None) -> HTTPException: + """HTTPException for an ambiguous identifier, without timing jitter (sync callers).""" + if ambiguous_identifier_status(env, api_client, error, resource_type) == status.HTTP_403_FORBIDDEN: + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=AMBIGUOUS_DETAIL) + + +async def raise_ambiguous_identifier(env, api_client, error, resource_type=None): + """Raise the 409/403 for an ambiguous identifier; a 403 gets the "not found" timing jitter.""" + exc = ambiguous_identifier_exception(env, api_client, error, resource_type) + if exc.status_code == status.HTTP_403_FORBIDDEN: + # SECURITY: same 50-70ms delay as the "not found" path, so timing + # does not tell "ambiguous" from "missing" + await asyncio.sleep(0.05 + random.uniform(0, 0.02)) + raise exc + + +async def lookup_registrant(env, api_client, lookup, system, value, resource_type=None): + """ + Call ``lookup(system, value)``, answering an ambiguous identifier with 409/403. + + Returns: + whatever ``lookup`` returns (a registrant or an empty recordset) + """ + try: + return lookup(system, value) + except AmbiguousIdentifierError as e: + await raise_ambiguous_identifier(env, api_client, e, resource_type) diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index 438dcad2b..c6ae6ed13 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -4,6 +4,7 @@ - fix: `PUT /ProgramMembership` can no longer move a membership to another program or beneficiary: a body whose `program` or `beneficiary` differs from the addressed membership returns `422` (#554). - fix: the `Location` header of `POST /ProgramMembership` is URL-encoded and carries `?program=`, so following it returns the created membership (#554). - fix: `PUT /ProgramMembership` accepts its own `ETag`/`meta.versionId` in `If-Match`; it previously compared against a different timestamp format and always returned `409` (#554). +- fix: a beneficiary identifier held by more than one registrant is refused (`409`, or `403` for a client that requires consent and lacks it for any of them) on `GET`/`PUT`/`POST /ProgramMembership` and the `beneficiary` search filter, instead of acting on an arbitrary one; membership identifiers, beneficiary references and `Location` use only live (not soft-removed) IDs (#554). ### 19.0.1.0.0 diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index 38cfb38d2..d51a302bf 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -12,7 +12,13 @@ from odoo.addons.spp_api_v2.middleware.auth import get_authenticated_client from odoo.addons.spp_api_v2.schemas.search_result import SearchResult, create_search_result from odoo.addons.spp_api_v2.services.consent_service import ConsentService +from odoo.addons.spp_api_v2.services.registrant_resolver import AmbiguousIdentifierError from odoo.addons.spp_api_v2.utils.pagination import fetch_with_consent +from odoo.addons.spp_api_v2.utils.registrant_lookup import ( + ambiguous_identifier_exception, + lookup_registrant, + raise_ambiguous_identifier, +) from fastapi import ( APIRouter, @@ -113,7 +119,9 @@ async def read_program_membership( consent_service = ConsentService(env) program_record = _resolve_program_param(service, program) - partner = service.find_beneficiary(system, value) + partner = await lookup_registrant( + env, api_client, service.find_beneficiary, system, value, resource_type="program_membership" + ) if not partner: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -228,6 +236,9 @@ def search_function(offset, limit): search_params = {**params, "_count": limit, "_offset": offset} try: return service.search(search_params) + except AmbiguousIdentifierError as e: + # The beneficiary filter matches more than one registrant + raise ambiguous_identifier_exception(env, api_client, e, "program_membership") from e except Exception as e: _logger.warning("Error in program membership search: %s", e) raise HTTPException( @@ -336,6 +347,9 @@ async def create_program_membership( try: membership = service.create(program_membership, source=source_system) + except AmbiguousIdentifierError as e: + # The beneficiary reference matches more than one registrant + await raise_ambiguous_identifier(env, api_client, e, "program_membership") except Exception as e: _logger.exception("Error creating program membership") raise HTTPException( @@ -401,6 +415,8 @@ async def update_program_membership( status_code=status.HTTP_409_CONFLICT, detail=str(e), ) from e + except AmbiguousIdentifierError as e: + await raise_ambiguous_identifier(env, api_client, e, "program_membership") if not membership: raise HTTPException( @@ -423,6 +439,9 @@ async def update_program_membership( try: membership = service.update(membership, program_membership, source=source_system) + except AmbiguousIdentifierError as e: + # The body's beneficiary reference matches more than one registrant + await raise_ambiguous_identifier(env, api_client, e, "program_membership") except ValidationError as e: # Client error (unknown reference, identity mismatch): no traceback raise HTTPException( diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index f226bef23..87ad8fb68 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -7,6 +7,12 @@ from odoo.api import Environment from odoo.exceptions import ValidationError +from odoo.addons.spp_api_v2.services.registrant_resolver import ( + live_registry_ids, + primary_registry_id, + resolve_registrant, +) + from ..schemas.program_membership import ProgramMembership _logger = logging.getLogger(__name__) @@ -54,19 +60,10 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: if "|" in identifier_str: system, value = identifier_str.split("|", 1) - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system), - ("value", "=", value), - ], - limit=1, - ) - ) - if reg_id and reg_id.partner_id: - domain.append(("partner_id", "=", reg_id.partner_id.id)) + # Raises AmbiguousIdentifierError when several registrants hold it + partner = self.find_beneficiary(system, value) + if partner: + domain.append(("partner_id", "=", partner.id)) else: # No matching partner found, return empty result domain.append(("id", "=", -1)) @@ -139,21 +136,11 @@ def find_beneficiary(self, system_uri: str, value: str): Returns: res.partner record or empty recordset + + Raises: + AmbiguousIdentifierError: several registrants hold the identifier """ - reg_id = ( - self.env["spp.registry.id"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("id_type_id.uri", "=", system_uri), - ("value", "=", value), - ], - limit=1, - ) - ) - if reg_id and reg_id.partner_id: - return reg_id.partner_id - return self.env["res.partner"] + return resolve_registrant(self.env, system_uri, value) def find_for_beneficiary(self, partner, program=None): """ @@ -218,9 +205,10 @@ def to_api_schema(self, membership, extensions=None) -> dict[str, Any]: # Build identifier list (optional for memberships) identifiers = [] - # Use partner's identifiers as the membership identifiers - if membership.partner_id and membership.partner_id.reg_ids: - for reg_id in membership.partner_id.reg_ids: + # Use partner's live identifiers as the membership identifiers: they + # address the membership, and a removed ID no longer resolves + if membership.partner_id: + for reg_id in live_registry_ids(membership.partner_id): # Use id_type_id.uri for full code URI # NOT namespace_uri which only returns vocabulary namespace if reg_id.id_type_id and reg_id.id_type_id.uri and reg_id.value: @@ -302,9 +290,9 @@ def _build_beneficiary_reference(self, partner) -> dict: # Determine resource type resource_type = "Group" if partner.is_group else "Individual" - # Get primary identifier - if partner.reg_ids: - primary_id = partner.reg_ids[0] + # Get primary identifier (a live one: removed IDs don't resolve) + primary_id = primary_registry_id(partner) + if primary_id: ref = f"{resource_type}/{primary_id.id_type_id.uri}|{primary_id.value}" else: # No identifier - this should not happen in a properly configured system diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 1ce77e09b..4cb652a63 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -255,6 +255,28 @@ def test_post_location_addresses_the_created_membership(self): self.assertEqual(followed.status_code, 200, followed.text) self.assertEqual(followed.json()["program"]["reference"], PROGRAM_3_REF) + def test_post_location_skips_a_removed_beneficiary_id(self): + """A soft-removed ID no longer resolves, so Location must use a live one""" + self.env["spp.registry.id"].create( + { + "partner_id": self.other.id, + "id_type_id": self.id_type_household.id, + "value": "OTHER-REMOVED-ID", + "status": "invalid", + } + ) + response = self.url_open( + self.api_base_url, + data=json.dumps(self._payload(PROGRAM_1_REF, "OTHER-001", "enrolled")), + headers=self._headers(), + ) + self.assertEqual(response.status_code, 201, response.text) + + followed = self.url_open(response.headers["location"], headers=self._headers()) + + self.assertEqual(followed.status_code, 200, followed.text) + self.assertEqual(followed.json()["beneficiary"]["reference"], _beneficiary_ref("OTHER-001")) + # ------------------------------------------------------------------ # If-Match (#554 B) # ------------------------------------------------------------------ From 55d8933e6a05a4754eebdf9679cf9028939a0c9b Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 12:17:46 +0800 Subject: [PATCH 11/53] test(spp_api_v2): add failing tests for staff-review findings on #554 Batch group create orphaned by an ambiguous member, $split creating a duplicate identifier, existence oracles (unknown beneficiary 404, search filter 403 vs empty 200), membership count disclosure and missing PUT consent check, consent predicate mismatch, soft-removed IDs still listed, transaction bundles answering 422, individual/group kind collisions. Also: positive controls for two filter tests, and the consent-denied ambiguity test now fails on the old pick-newest behaviour. --- spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_identifier_ambiguity.py | 4 +- .../tests/test_identifier_ambiguity_paths.py | 271 ++++++++++++++++++ .../tests/test_search_filters_fail_closed.py | 19 ++ .../tests/test_program_membership_identity.py | 75 +++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 spp_api_v2/tests/test_identifier_ambiguity_paths.py diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 56effab7a..ffbf00969 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -41,3 +41,4 @@ from . import test_references_resolvable from . import test_membership_end_now from . import test_identifier_ambiguity +from . import test_identifier_ambiguity_paths diff --git a/spp_api_v2/tests/test_identifier_ambiguity.py b/spp_api_v2/tests/test_identifier_ambiguity.py index 47791436e..3a3f96b7a 100644 --- a/spp_api_v2/tests/test_identifier_ambiguity.py +++ b/spp_api_v2/tests/test_identifier_ambiguity.py @@ -267,7 +267,9 @@ def _consent_client_token(self, consented): def test_consent_client_without_consent_for_every_match_gets_403(self): """Ambiguity is not revealed unless the client may see every match""" - token = self._consent_client_token(consented=[self.first]) + # Consent to the newest match only: a lookup that picks one (newest + # first) would find it and answer 200, so this fails without the fix + token = self._consent_client_token(consented=[self.second]) response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|AMB-DUP", token) diff --git a/spp_api_v2/tests/test_identifier_ambiguity_paths.py b/spp_api_v2/tests/test_identifier_ambiguity_paths.py new file mode 100644 index 000000000..8fb776fa1 --- /dev/null +++ b/spp_api_v2/tests/test_identifier_ambiguity_paths.py @@ -0,0 +1,271 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Every API path that creates or resolves identifiers handles ambiguity (#554 item I). + +Complements test_identifier_ambiguity.py with the paths found in review: +batch/transaction bundles, group create/split, search filters, bulk export, +soft-removed IDs in responses, and individual/group kind filtering. +""" + +import json + +from .common import ApiV2HttpTestCase + +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" +HOUSEHOLD_ID = "urn:openspp:vocab:id-type#test_household_id" +GROUP_PATH = "/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|PATH-HH" + +SCOPES = [ + {"resource": "individual", "action": "read"}, + {"resource": "individual", "action": "create"}, + {"resource": "individual", "action": "update"}, + {"resource": "group", "action": "read"}, + {"resource": "group", "action": "create"}, + {"resource": "group", "action": "update"}, +] + + +class TestIdentifierAmbiguityPaths(ApiV2HttpTestCase): + def setUp(self): + super().setUp() + self.first = self.create_test_individual(name="Original", identifier_value="PATH-DUP") + self.second = self.create_test_individual(name="Copy", identifier_value="PATH-DUP") + self.head = self.create_test_individual(name="Path Head", identifier_value="PATH-HEAD") + self.mover = self.create_test_individual(name="Path Mover", identifier_value="PATH-MOVER") + self.group = self.create_test_group( + name="Path Household", + identifier_value="PATH-HH", + members=[(self.head, self.relationship_head), (self.mover, None)], + ) + self.dup_group_a = self.create_test_group(name="Dup Household A", identifier_value="PATH-HH-DUP") + self.dup_group_b = self.create_test_group(name="Dup Household B", identifier_value="PATH-HH-DUP") + self.client = self.create_api_client( + name="Legal Basis Client", + scopes=SCOPES, + require_consent=False, + legal_basis="public_task", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self, token=None): + return {"Content-Type": "application/json", "Authorization": f"Bearer {token or self.token}"} + + def _get(self, path, token=None): + return self.url_open(path, headers=self._headers(token)) + + def _post(self, path, payload, token=None): + return self.url_open(path, data=json.dumps(payload), headers=self._headers(token)) + + def _count_ids(self, value): + self.env.invalidate_all() + return self.env["spp.registry.id"].search_count([("value", "=", value)]) + + def _consent_client_token(self, consented=(), **kwargs): + client = self.create_api_client(name="Consent Client", scopes=SCOPES, **kwargs) + for registrant in consented: + self.create_consent( + registrant=registrant, + grantee_partner=client.partner_id, + resource_type="all", + field_access="all", + ) + return self.generate_jwt_token(client) + + # --- creates ----------------------------------------------------------- + + def test_http_post_group_with_ambiguous_member_is_409_and_creates_nothing(self): + response = self._post( + "/api/v2/spp/Group", + { + "type": "Group", + "identifier": [{"system": HOUSEHOLD_ID, "value": "PATH-NEW-HH"}], + "name": "New Household", + "member": [{"entity": {"reference": f"Individual/{NATIONAL_ID}|PATH-DUP"}}], + }, + ) + + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(self._count_ids("PATH-NEW-HH"), 0) + + def test_batch_post_group_with_ambiguous_member_creates_nothing(self): + """The batch entry must not leave the group behind while reporting 409""" + response = self._post( + "/api/v2/spp/$batch", + { + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "request": {"method": "POST", "url": "Group"}, + "resource": { + "type": "Group", + "identifier": [{"system": HOUSEHOLD_ID, "value": "PATH-BATCH-HH"}], + "name": "Batch Household", + "member": [{"entity": {"reference": f"Individual/{NATIONAL_ID}|PATH-DUP"}}], + }, + } + ], + }, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["entry"][0]["response"]["status"], "409 Conflict") + self.assertEqual(self._count_ids("PATH-BATCH-HH"), 0) + + def test_batch_post_individual_with_identifier_in_use_is_409(self): + response = self._post( + "/api/v2/spp/$batch", + { + "resourceType": "Bundle", + "type": "batch", + "entry": [ + { + "request": {"method": "POST", "url": "Individual"}, + "resource": { + "type": "Individual", + "identifier": [{"system": NATIONAL_ID, "value": "PATH-HEAD"}], + "name": {"given": "Clash"}, + }, + } + ], + }, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["entry"][0]["response"]["status"], "409 Conflict") + self.assertEqual(self._count_ids("PATH-HEAD"), 1) + + def test_transaction_bundle_with_ambiguous_identifier_is_409(self): + response = self._post( + "/api/v2/spp/$batch", + { + "resourceType": "Bundle", + "type": "transaction", + "entry": [{"request": {"method": "GET", "url": f"Individual/{NATIONAL_ID}|PATH-DUP"}}], + }, + ) + + self.assertEqual(response.status_code, 409, response.text) + + def test_split_to_an_identifier_in_use_is_409_and_creates_nothing(self): + response = self._post( + f"{GROUP_PATH}/$split", + { + "newGroupIdentifier": [{"system": HOUSEHOLD_ID, "value": "PATH-HH-DUP"}], + "membersToMove": [{"reference": f"Individual/{NATIONAL_ID}|PATH-MOVER"}], + }, + ) + + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(self._count_ids("PATH-HH-DUP"), 2) + self.env.invalidate_all() + self.assertFalse(self.group.group_membership_ids.filtered(lambda m: m.individual == self.mover).is_ended) + + # --- search filters ---------------------------------------------------- + + def test_group_filter_with_ambiguous_group_is_409(self): + response = self._get("/api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23test_household_id%7CPATH-HH-DUP") + + self.assertEqual(response.status_code, 409, response.text) + + def test_member_filter_with_ambiguous_member_is_409(self): + response = self._get( + "/api/v2/spp/Group?member=Individual/urn:openspp:vocab:id-type%23test_national_id%7CPATH-DUP" + ) + + self.assertEqual(response.status_code, 409, response.text) + + def test_ambiguous_filter_looks_like_not_found_to_a_client_without_consent(self): + """In a search, "not found" is an empty 200, so ambiguity must be too""" + token = self._consent_client_token(consented=[self.dup_group_a]) + + response = self._get( + "/api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23test_household_id%7CPATH-HH-DUP", token + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["meta"]["total"], 0) + + # --- consent decision -------------------------------------------------- + + def test_legal_basis_client_that_requires_consent_flag_gets_409(self): + """The 409/403 decision uses the read path's rule: a legal basis reads without consent""" + token = self._consent_client_token(require_consent=True, legal_basis="public_task") + + response = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|PATH-DUP", token) + + self.assertEqual(response.status_code, 409, response.text) + + def test_add_ambiguous_member_as_client_without_consent_is_403(self): + token = self._consent_client_token(consented=[self.group, self.first]) + + response = self._post( + f"{GROUP_PATH}/$add-member", + {"entity": {"reference": f"Individual/{NATIONAL_ID}|PATH-DUP"}}, + token, + ) + + self.assertEqual(response.status_code, 403, response.text) + + def test_bulk_export_ambiguous_identifier_for_client_without_consent_is_access_denied(self): + token = self._consent_client_token(consented=[self.first]) + + response = self._post( + "/api/v2/spp/$bulk/export", + {"type": "Individual", "identifiers": [f"{NATIONAL_ID}|PATH-DUP"]}, + token, + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["items"][0]["status"], "access_denied") + + # --- soft-removed IDs -------------------------------------------------- + + def test_removed_ids_are_not_listed_or_searchable(self): + self.env["spp.registry.id"].create( + { + "partner_id": self.head.id, + "id_type_id": self.id_type_household.id, + "value": "PATH-REMOVED", + "status": "invalid", + } + ) + + read = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|PATH-HEAD") + self.assertEqual(read.status_code, 200, read.text) + self.assertEqual([ident["value"] for ident in read.json()["identifier"]], ["PATH-HEAD"]) + + search = self._get( + "/api/v2/spp/Individual?identifier=urn:openspp:vocab:id-type%23test_household_id%7CPATH-REMOVED" + ) + self.assertEqual(search.status_code, 200, search.text) + self.assertEqual(search.json()["meta"]["total"], 0) + + def test_removed_group_ids_are_not_listed(self): + self.env["spp.registry.id"].create( + { + "partner_id": self.group.id, + "id_type_id": self.id_type_national.id, + "value": "PATH-HH-REMOVED", + "status": "invalid", + } + ) + + read = self._get(GROUP_PATH) + + self.assertEqual(read.status_code, 200, read.text) + self.assertEqual([ident["value"] for ident in read.json()["identifier"]], ["PATH-HH"]) + + # --- individual vs group ------------------------------------------------ + + def test_individual_and_group_sharing_a_value_are_resolved_by_kind(self): + """A value held by one individual and one group is not ambiguous per kind""" + self.create_test_individual(name="Kind Person", identifier_value="PATH-KIND") + self.create_test_group(name="Kind Household", identifier_value="PATH-KIND", id_type=self.id_type_national) + + individual = self._get("/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|PATH-KIND") + group = self._get("/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_national_id|PATH-KIND") + + self.assertEqual(individual.status_code, 200, individual.text) + self.assertEqual(individual.json()["type"], "Individual") + self.assertEqual(group.status_code, 200, group.text) + self.assertEqual(group.json()["type"], "Group") diff --git a/spp_api_v2/tests/test_search_filters_fail_closed.py b/spp_api_v2/tests/test_search_filters_fail_closed.py index 50cd0a97d..bdc046b44 100644 --- a/spp_api_v2/tests/test_search_filters_fail_closed.py +++ b/spp_api_v2/tests/test_search_filters_fail_closed.py @@ -111,19 +111,23 @@ def test_group_filter_ignores_ended_membership_when_active_elsewhere(self): """Removed from G but active in H: not a member of G""" self._add_member(self.group_g, self.ind1, ended=True) self._add_member(self.group_h, self.ind1) + self._add_member(self.group_g, self.ind2) # positive control: a current member of G records, _total = self.service.search_individuals({"group": f"{HOUSEHOLD_ID}|FC-HH-G"}) self.assertNotIn(self.ind1, records) + self.assertIn(self.ind2, records) def test_membership_role_must_be_on_an_active_membership(self): """Head of G (ended) and plain member of H (active): not a current head""" self._add_member(self.group_g, self.ind1, ended=True, role=self.relationship_head) self._add_member(self.group_h, self.ind1) + self._add_member(self.group_h, self.ind2, role=self.relationship_head) # positive control: a current head records, _total = self.service.search_individuals({"membership-role": "head"}) self.assertNotIn(self.ind1, records) + self.assertIn(self.ind2, records) def test_identifier_system_and_value_must_be_on_the_same_id(self): """System from one ID and value from another is not a match""" @@ -212,6 +216,21 @@ def test_individual_malformed_birthdate_is_400(self): self.assertEqual(response.status_code, 400, response.text) + def test_individual_malformed_gender_is_400(self): + response = self._get("/api/v2/spp/Individual?gender=female") + + self.assertEqual(response.status_code, 400, response.text) + + def test_individual_malformed_last_updated_is_400(self): + response = self._get("/api/v2/spp/Individual?_lastUpdated=yesterday") + + self.assertEqual(response.status_code, 400, response.text) + + def test_group_malformed_member_is_400(self): + response = self._get("/api/v2/spp/Group?member=NO-SEPARATOR") + + self.assertEqual(response.status_code, 400, response.text) + def test_group_unknown_member_returns_nothing(self): self._assert_empty( self._get( diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 4cb652a63..a93291544 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -115,6 +115,39 @@ def test_get_without_program_is_409_when_beneficiary_has_several_memberships(sel self.assertEqual(response.status_code, 409, response.text) self.assertIn("program", response.json()["detail"]) + # The refusal does not disclose how many programs the beneficiary is in + self.assertNotIn("2", response.json()["detail"]) + + def _no_consent_headers(self): + """A client that requires consent and has none for any beneficiary here""" + client = self.create_api_client( + name="No Consent Membership Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "update"}, + ], + ) + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.generate_jwt_token(client)}"} + + def test_unknown_beneficiary_is_403_for_a_consent_client(self): + """Same answer as "exists, no consent": the registry's contents are not revealed""" + response = self.url_open(self._url("NO-SUCH-BENEFICIARY"), headers=self._no_consent_headers()) + + self.assertEqual(response.status_code, 403, response.text) + + def test_put_without_program_and_without_consent_is_403(self): + """PUT does not reveal that the beneficiary has several memberships""" + headers = self._no_consent_headers() + self.env.cr.flush() + response = self.url_put( + self._url("MULTI-001"), + data=json.dumps(self._payload(PROGRAM_1_REF, "MULTI-001", "exited")), + headers=headers, + ) + self.env.invalidate_all() + + self.assertEqual(response.status_code, 403, response.text) + self.assertEqual(self.multi_p1.state, "enrolled") def test_get_with_program_returns_that_programs_membership(self): """?program= selects the membership in that program, not the newest one""" @@ -294,6 +327,18 @@ def test_put_accepts_its_own_etag_as_if_match(self): self.assertEqual(response.status_code, 200, response.text) self.assertEqual(self.single_p1.state, "paused") + def test_put_rejects_a_stale_etag(self): + """An ETag from before a later write is refused (409), so the comparison is real""" + url = self._url("SINGLE-001") + stale_etag = self.url_open(url, headers=self._headers()).headers["etag"] + first = self._put(url, self._payload(PROGRAM_1_REF, "SINGLE-001", "paused"), **{"If-Match": stale_etag}) + self.assertEqual(first.status_code, 200, first.text) + + second = self._put(url, self._payload(PROGRAM_1_REF, "SINGLE-001", "exited"), **{"If-Match": stale_etag}) + + self.assertEqual(second.status_code, 409, second.text) + self.assertEqual(self.single_p1.state, "paused") + class TestProgramMembershipIdentityService(ApiV2TestCase): """Service-level resolution and update guards""" @@ -411,3 +456,33 @@ def test_post_with_ambiguous_beneficiary_is_409(self): self.assertFalse( self.env["spp.program.membership"].search([("partner_id", "=", self.second.id)]), ) + + def test_search_by_ambiguous_beneficiary_is_409(self): + response = self.url_open( + f"{self.api_base_url}?beneficiary={quote(_beneficiary_ref('PM-DUP'), safe=':/|')}", + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 409, response.text) + + def test_put_with_ambiguous_body_beneficiary_is_409(self): + """The addressed membership resolves, but the body names an ambiguous beneficiary""" + single = self.create_test_individual(name="Single", identifier_value="PM-SINGLE") + membership = self.create_test_membership(partner=single, program=self.program) + self.env.cr.flush() + response = self.url_put( + f"{self.api_base_url}/{quote(NATIONAL_ID, safe=':')}|PM-SINGLE", + data=json.dumps( + { + "type": "ProgramMembership", + "program": {"reference": PROGRAM_1_REF}, + "beneficiary": {"reference": _beneficiary_ref("PM-DUP")}, + "status": "exited", + } + ), + headers=self._headers(), + ) + self.env.invalidate_all() + + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(membership.state, "enrolled") From d01e21881f6a16620484f7a86ba05d092e1ade73 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 12:30:40 +0800 Subject: [PATCH 12/53] fix(spp_api_v2): address staff-review findings on #554 - group create runs in a savepoint so an ambiguous member cannot leave the group behind in a batch bundle; $split refuses an identifier in use - anti-enumeration: unknown beneficiary is the jittered 403 for consent clients on ProgramMembership GET/PUT; ambiguous search filters give an empty page, not 403, to a client that may not know; the membership count is no longer disclosed and PUT checks consent before 409/404 - the 409-vs-403 decision uses the read path's consent rule (filter_response), shared with ProgramMembership - soft-removed IDs are no longer listed in identifier[] or matched by identifier=; lookups resolve by kind (individual/group) - transaction bundles answer 409 for identifier conflicts; group create audit-logs the 409; HISTORY describes the actual behaviour Two existing not-found tests now use a legal-basis client for the 404 (Edwin-approved), as test_read_individual_not_found does; consent clients get 403, covered by a new test. --- spp_api_v2/readme/HISTORY.md | 2 +- spp_api_v2/routers/batch.py | 11 ++ spp_api_v2/routers/group.py | 14 ++- spp_api_v2/routers/individual.py | 4 +- spp_api_v2/services/group_service.py | 35 +++--- spp_api_v2/services/individual_service.py | 8 +- spp_api_v2/services/search_service.py | 7 +- spp_api_v2/tests/test_search_service.py | 1 + spp_api_v2/utils/registrant_lookup.py | 65 ++++++++---- spp_api_v2_programs/readme/HISTORY.md | 4 +- .../routers/program_membership.py | 100 ++++++++---------- .../services/program_membership_service.py | 15 ++- .../tests/test_program_membership_api.py | 26 ++++- 13 files changed, 177 insertions(+), 115 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index de5e14c13..8c77c5c69 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -4,7 +4,7 @@ - fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. - fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). -- fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual` and `POST /Group` return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart. Lookups (read, update, patch, member operations, merge/split, search filters, bulk export, batch/transaction bundles) no longer pick one of several registrants holding an identifier: they return `409`, or, for a client that requires consent and lacks it for any of the matches, the same `403` as a registrant that does not exist. Soft-removed (`invalid`) IDs no longer resolve, and references are built from a live ID. Searching by `identifier=` still lists every match. +- fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. ### 19.0.2.1.1 diff --git a/spp_api_v2/routers/batch.py b/spp_api_v2/routers/batch.py index 9a6de1a4b..dff79dff0 100644 --- a/spp_api_v2/routers/batch.py +++ b/spp_api_v2/routers/batch.py @@ -14,6 +14,7 @@ from ..middleware.auth import get_authenticated_client from ..schemas.bundle import RegistrantBundle from ..services.bundle_service import BundleProcessor +from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError _logger = logging.getLogger(__name__) @@ -128,6 +129,16 @@ async def process_bundle( return result except ValidationError as e: + if isinstance(e.__cause__, AmbiguousIdentifierError | IdentifierInUseError): + # A transaction entry's identifier matches, or would match, more + # than one registrant: a conflict, as for the single-resource endpoints + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "resourceType": "OperationOutcome", + "issue": [{"severity": "error", "code": "conflict", "diagnostics": str(e)}], + }, + ) from e _logger.error(f"Bundle processing failed for client {api_client.client_id}: {str(e)}") raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 48a2f291b..eda337605 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -45,7 +45,7 @@ from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent from ..utils.registrant_lookup import ( - ambiguous_identifier_exception, + ambiguous_filter_result, lookup_registrant, raise_ambiguous_identifier, ) @@ -211,7 +211,7 @@ def search_function(offset, limit): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e except AmbiguousIdentifierError as e: # A filter reference (member=) matches more than one registrant - raise ambiguous_identifier_exception(env, api_client, e) from e + return ambiguous_filter_result(env, api_client, e, env["res.partner"]) def consent_filter_function(group): group_data = group_service.to_api_schema(group, extensions=extension_list) @@ -297,6 +297,13 @@ async def create_group( except IdentifierInUseError as e: # Another registrant holds one of the identifiers: creating a second # would leave neither addressable unambiguously + audit_service.log_create( + "group", + group.identifier[0].system + "|" + group.identifier[0].value if group.identifier else "unknown", + None, + status="validation_error", + error_detail="Identifier in use", + ) raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=e.args[0]) from e except AmbiguousIdentifierError as e: # A member reference matches more than one individual @@ -889,6 +896,9 @@ async def split_group( new_head=new_head, source=source_system, ) + except IdentifierInUseError as e: + # The new group's identifier is already held by another registrant + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=e.args[0]) from e except Exception as e: _logger.exception("Error splitting group") # Check for specific validation errors diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 15989addc..c3222ce2a 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -36,7 +36,7 @@ from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import fetch_with_consent -from ..utils.registrant_lookup import ambiguous_identifier_exception, lookup_registrant +from ..utils.registrant_lookup import ambiguous_filter_result, lookup_registrant from .dependencies import check_individual_access, parse_identifier _logger = logging.getLogger(__name__) @@ -222,7 +222,7 @@ def search_function(offset, limit): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e except AmbiguousIdentifierError as e: # A filter reference (group=) matches more than one registrant - raise ambiguous_identifier_exception(env, api_client, e) from e + return ambiguous_filter_result(env, api_client, e, env["res.partner"]) def consent_filter_function(partner): data = individual_service.to_api_schema(partner, extensions=extension_list) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index a62a137f5..d61411cf8 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -16,6 +16,7 @@ from .membership_utils import membership_to_response from .registrant_resolver import ( assert_new_identifiers_free, + live_registry_ids, primary_registry_id, resolve_registrant, resolve_registrants, @@ -77,7 +78,8 @@ def to_api_schema(self, group, extensions=None) -> dict[str, Any]: # Build identifier list identifiers = [] - for reg_id in group.reg_ids: + # Live IDs only: a soft-removed ID no longer resolves, so it is not offered as a key + for reg_id in live_registry_ids(group): # Use id_type_id.uri for full code URI (e.g., urn:openspp:vocab:id-type#household_id) # NOT namespace_uri which only returns vocabulary namespace if reg_id.id_type_id and reg_id.id_type_id.uri and reg_id.value: @@ -344,19 +346,24 @@ def create(self, schema: Group, source: str, api_authorized: bool = False) -> An if hasattr(self.env["res.partner"], "_fields") and "collection_method" in self.env["res.partner"]._fields: vals["collection_method"] = "api" - # Use sudo() for cross-program group creation while enforcing authorization above - group = ( - self.env["res.partner"] # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context - # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models - # Group creation is restricted by API scope verification - .sudo() - .with_context(source_system=source) - .create(vals) - ) + # The group and its members are created together or not at all: a + # member reference that fails to resolve (e.g. an ambiguous identifier) + # must not leave the group behind for callers that keep the + # transaction, such as batch bundles + with self.env.cr.savepoint(): + # Use sudo() for cross-program group creation while enforcing authorization above + group = ( + self.env["res.partner"] # nosemgrep: odoo-sudo-on-sensitive-models, odoo-sudo-without-context + # nosemgrep: odoo-sudo-without-context, odoo-sudo-on-sensitive-models + # Group creation is restricted by API scope verification + .sudo() + .with_context(source_system=source) + .create(vals) + ) - # Handle members separately - if schema.member: - self._create_members(group, schema.member, source) + # Handle members separately + if schema.member: + self._create_members(group, schema.member, source) # Log using external identifier, not database ID primary_id = group.reg_ids[0] if group.reg_ids else None @@ -1041,6 +1048,8 @@ def split_group(self, source_group, new_identifiers, members_to_move, new_head=N # Use existing create method's vals generation new_group_vals = self.from_api_schema(new_group_schema) + # Same rule as create(): no second registrant with someone else's identifier + assert_new_identifiers_free(self.env, new_group_vals.get("reg_ids", [])) # Add source tracking source_system = source or "urn:openspp:api-v2:split-operation" diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 9f27ed365..579f499db 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -16,6 +16,7 @@ from .membership_utils import membership_to_response from .registrant_resolver import ( assert_new_identifiers_free, + live_registry_ids, primary_registry_id, resolve_registrant, resolve_registrants, @@ -47,7 +48,7 @@ def find_by_identifier(self, system_uri: str, value: str): Raises: AmbiguousIdentifierError: several registrants hold the identifier """ - return resolve_registrant(self.env, system_uri, value) + return resolve_registrant(self.env, system_uri, value, is_group=False) def find_by_identifiers(self, identifiers: list[tuple[str, str]]): """ @@ -61,7 +62,7 @@ def find_by_identifiers(self, identifiers: list[tuple[str, str]]): or to an AmbiguousIdentifierError when several registrants hold it; identifiers with no match are absent """ - return resolve_registrants(self.env, identifiers) + return resolve_registrants(self.env, identifiers, is_group=False) def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: """ @@ -85,7 +86,8 @@ def to_api_schema(self, partner, extensions=None) -> dict[str, Any]: # Build identifier list (REQUIRED, at least one) identifiers = [] - for reg_id in partner.reg_ids: + # Live IDs only: a soft-removed ID no longer resolves, so it is not offered as a key + for reg_id in live_registry_ids(partner): # Use id_type_id.uri for full code URI (e.g., urn:openspp:vocab:id-type#national_id) # NOT namespace_uri which only returns vocabulary namespace if reg_id.id_type_id and reg_id.id_type_id.uri and reg_id.value: diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index b21745e74..a8f4aa458 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -8,7 +8,7 @@ from odoo.api import Environment from odoo.osv import expression -from .registrant_resolver import resolve_registrant +from .registrant_resolver import LIVE_ID, resolve_registrant _logger = logging.getLogger(__name__) @@ -181,6 +181,7 @@ def _parse_identifier_param(self, identifier: str) -> list: [ ("id_type_id.uri", "=", system), ("value", "=", value), + LIVE_ID, ], ) ] @@ -344,8 +345,8 @@ def _parse_member_param(self, member: str) -> list: system, value = ident_str.split("|", 1) - # Raises AmbiguousIdentifierError when several registrants hold it - member_partner = resolve_registrant(self.env, system, value) + # Raises AmbiguousIdentifierError when several individuals hold it + member_partner = resolve_registrant(self.env, system, value, is_group=False) if not member_partner: return MATCH_NOTHING diff --git a/spp_api_v2/tests/test_search_service.py b/spp_api_v2/tests/test_search_service.py index dfa3e3176..aedc7b934 100644 --- a/spp_api_v2/tests/test_search_service.py +++ b/spp_api_v2/tests/test_search_service.py @@ -73,6 +73,7 @@ def test_parse_identifier_param(self): [ ("id_type_id.uri", "=", "urn:openspp:vocab:id-type#test_national_id"), ("value", "=", "IND-001"), + ("status", "!=", "invalid"), ], ) ], diff --git a/spp_api_v2/utils/registrant_lookup.py b/spp_api_v2/utils/registrant_lookup.py index 86116fc0f..37d5979f1 100644 --- a/spp_api_v2/utils/registrant_lookup.py +++ b/spp_api_v2/utils/registrant_lookup.py @@ -2,10 +2,10 @@ """HTTP answer for an identifier that matches more than one registrant. The API refuses to guess (409). But saying "this identifier is ambiguous" -also says "it exists", so a client that needs consent only learns it when -it has consent for every matching registrant; otherwise it gets the same -403 as a registrant that does not exist (api-error-responses.md, -anti-enumeration). +also says "it exists", so a client only learns it when it may read every +matching registrant; otherwise it gets exactly what it would get for a +registrant that does not exist (api-error-responses.md, anti-enumeration): +the 403 of a read, or the empty page of a search. """ import asyncio @@ -19,38 +19,57 @@ AMBIGUOUS_DETAIL = "Identifier matches more than one registrant" +def consent_denied(env, api_client, partner, resource_type) -> bool: + """Whether the client may not read ``partner``'s ``resource_type`` data. + + The same rule as a successful read (legal basis, consent, consent scope), + evaluated without logging an access. + """ + filtered = ConsentService(env).filter_response(partner.id, api_client, resource_type, {}, log_access=False) + return filtered.get("_consent", {}).get("status") in ("no_consent", "scope_mismatch") + + +async def deny_access(): + """Raise the anti-enumeration 403, with the 50-70ms delay of the "not found" path.""" + # SECURITY: the same delay whether the record is missing, ambiguous or + # not consented, so timing does not tell them apart + await asyncio.sleep(0.05 + random.uniform(0, 0.02)) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + def ambiguous_identifier_status(env, api_client, error: AmbiguousIdentifierError, resource_type=None) -> int: """ - 409 if the client may know the identifier is ambiguous, else 403. + 409 if the client may read every match, else 403. Args: resource_type: consent resource to check; by default each match's own kind ("group" or "individual") """ - if api_client.is_require_consent: - consent_service = ConsentService(env) - for partner in error.partners: - kind = resource_type or ("group" if partner.is_group else "individual") - if not consent_service.check_access(partner.id, api_client, kind, "read"): - return status.HTTP_403_FORBIDDEN + for partner in error.partners: + kind = resource_type or ("group" if partner.is_group else "individual") + if consent_denied(env, api_client, partner, kind): + return status.HTTP_403_FORBIDDEN return status.HTTP_409_CONFLICT -def ambiguous_identifier_exception(env, api_client, error, resource_type=None) -> HTTPException: - """HTTPException for an ambiguous identifier, without timing jitter (sync callers).""" +async def raise_ambiguous_identifier(env, api_client, error, resource_type=None): + """Raise the 409, or the jittered "not found" 403, for an ambiguous identifier.""" if ambiguous_identifier_status(env, api_client, error, resource_type) == status.HTTP_403_FORBIDDEN: - return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") - return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=AMBIGUOUS_DETAIL) + await deny_access() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=AMBIGUOUS_DETAIL) -async def raise_ambiguous_identifier(env, api_client, error, resource_type=None): - """Raise the 409/403 for an ambiguous identifier; a 403 gets the "not found" timing jitter.""" - exc = ambiguous_identifier_exception(env, api_client, error, resource_type) - if exc.status_code == status.HTTP_403_FORBIDDEN: - # SECURITY: same 50-70ms delay as the "not found" path, so timing - # does not tell "ambiguous" from "missing" - await asyncio.sleep(0.05 + random.uniform(0, 0.02)) - raise exc +def ambiguous_filter_result(env, api_client, error, empty_records, resource_type=None): + """ + Search-filter form: an empty page (what "not found" looks like in a search) + for a client that may not know, else 409. + + Returns: + (empty_records, 0), the (records, total) shape of a search function + """ + if ambiguous_identifier_status(env, api_client, error, resource_type) == status.HTTP_403_FORBIDDEN: + return empty_records, 0 + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=AMBIGUOUS_DETAIL) async def lookup_registrant(env, api_client, lookup, system, value, resource_type=None): diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index c6ae6ed13..08505d267 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -1,10 +1,10 @@ ### 19.0.1.1.0 -- fix: `GET`/`PUT /ProgramMembership/{identifier}` no longer resolve to an arbitrary program's membership when the beneficiary is enrolled in several programs (#554). A new optional `program` query parameter (`Program/{system}|{value}`) selects the membership; without it, a beneficiary with more than one membership returns `409`. For a client without consent, the refusal is `403` before any `409`/`404`, so enrollment is not revealed. +- fix: `GET`/`PUT /ProgramMembership/{identifier}` no longer resolve to an arbitrary program's membership when the beneficiary is enrolled in several programs (#554). A new optional `program` query parameter (`Program/{system}|{value}`) selects the membership (a malformed value returns `400`, an unknown program `404`); without it, a beneficiary with more than one membership returns `409`. When no single membership resolves, a client that requires consent and may not read the beneficiary gets `403` whatever the reason (unknown beneficiary, not enrolled in that program, several memberships), so neither the registry's contents nor enrollments are revealed; the `409` message does not give the number of memberships. - fix: `PUT /ProgramMembership` can no longer move a membership to another program or beneficiary: a body whose `program` or `beneficiary` differs from the addressed membership returns `422` (#554). - fix: the `Location` header of `POST /ProgramMembership` is URL-encoded and carries `?program=`, so following it returns the created membership (#554). - fix: `PUT /ProgramMembership` accepts its own `ETag`/`meta.versionId` in `If-Match`; it previously compared against a different timestamp format and always returned `409` (#554). -- fix: a beneficiary identifier held by more than one registrant is refused (`409`, or `403` for a client that requires consent and lacks it for any of them) on `GET`/`PUT`/`POST /ProgramMembership` and the `beneficiary` search filter, instead of acting on an arbitrary one; membership identifiers, beneficiary references and `Location` use only live (not soft-removed) IDs (#554). +- fix: a beneficiary identifier held by more than one registrant is refused on `GET`/`PUT`/`POST /ProgramMembership` (`409`, or `403` for a client that requires consent and may not read every match) and in the `beneficiary` search filter (`409`, or an empty page for such a client), instead of acting on an arbitrary one; `Individual/` and `Group/` beneficiary references resolve only that kind. Membership identifiers, beneficiary references and `Location` use only live (not soft-removed) IDs (#554). ### 19.0.1.0.0 diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index d51a302bf..faff36562 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -15,7 +15,9 @@ from odoo.addons.spp_api_v2.services.registrant_resolver import AmbiguousIdentifierError from odoo.addons.spp_api_v2.utils.pagination import fetch_with_consent from odoo.addons.spp_api_v2.utils.registrant_lookup import ( - ambiguous_identifier_exception, + ambiguous_filter_result, + consent_denied, + deny_access, lookup_registrant, raise_ambiguous_identifier, ) @@ -64,15 +66,45 @@ def _resolve_program_param(service, program): return program_record -def _consent_denied(consent_service, partner, api_client) -> bool: - """Whether the client lacks consent for this beneficiary's membership data. +async def _resolve_membership(env, api_client, service, system, value, program_record): + """Resolve the addressed membership, or raise what the client may be told. - Uses the same check as a successful read, without logging an access, - so a lookup that resolves to no single membership can refuse *before* - saying why (ambiguous vs not enrolled) to a client without consent. + When no single membership resolves, a client that may not read the + beneficiary gets the same jittered 403 whatever the reason (unknown + beneficiary, not enrolled in this program, several memberships), so + neither the registry's contents nor enrollments are revealed. """ - filtered = consent_service.filter_response(partner.id, api_client, "program_membership", {}, log_access=False) - return filtered.get("_consent", {}).get("status") in ("no_consent", "scope_mismatch") + partner = await lookup_registrant( + env, api_client, service.find_beneficiary, system, value, resource_type="program_membership" + ) + if not partner: + if api_client.is_require_consent: + await deny_access() + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="ProgramMembership not found", + ) + + ambiguous = None + try: + membership = service.find_for_beneficiary(partner, program_record) + except AmbiguousMembershipError as e: + membership = env["spp.program.membership"] + ambiguous = e + + if not membership: + if consent_denied(env, api_client, partner, "program_membership"): + await deny_access() + if ambiguous: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(ambiguous), + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="ProgramMembership not found", + ) + return membership def _membership_location(data: dict) -> str | None: @@ -119,39 +151,7 @@ async def read_program_membership( consent_service = ConsentService(env) program_record = _resolve_program_param(service, program) - partner = await lookup_registrant( - env, api_client, service.find_beneficiary, system, value, resource_type="program_membership" - ) - if not partner: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="ProgramMembership not found", - ) - - ambiguous = None - try: - membership = service.find_for_beneficiary(partner, program_record) - except AmbiguousMembershipError as e: - membership = env["spp.program.membership"] - ambiguous = e - - if not membership: - # Consent first: without it, neither "ambiguous" nor "not in this - # program" may be revealed about the beneficiary. - if _consent_denied(consent_service, partner, api_client): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied", - ) - if ambiguous: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(ambiguous), - ) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="ProgramMembership not found", - ) + membership = await _resolve_membership(env, api_client, service, system, value, program_record) # Convert to API schema data = service.to_api_schema(membership) @@ -238,7 +238,7 @@ def search_function(offset, limit): return service.search(search_params) except AmbiguousIdentifierError as e: # The beneficiary filter matches more than one registrant - raise ambiguous_identifier_exception(env, api_client, e, "program_membership") from e + return ambiguous_filter_result(env, api_client, e, env["spp.program.membership"], "program_membership") except Exception as e: _logger.warning("Error in program membership search: %s", e) raise HTTPException( @@ -408,21 +408,7 @@ async def update_program_membership( # Find program membership service = ProgramMembershipService(env) program_record = _resolve_program_param(service, program) - try: - membership = service.find_by_identifier(system, value, program=program_record) - except AmbiguousMembershipError as e: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(e), - ) from e - except AmbiguousIdentifierError as e: - await raise_ambiguous_identifier(env, api_client, e, "program_membership") - - if not membership: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="ProgramMembership not found", - ) + membership = await _resolve_membership(env, api_client, service, system, value, program_record) # Check version for optimistic locking (same format as meta.versionId / ETag) if if_match: diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 87ad8fb68..824af8e24 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -22,7 +22,8 @@ class AmbiguousMembershipError(Exception): """The beneficiary has several program memberships and no program was given.""" def __init__(self, count: int): - super().__init__(f"Beneficiary has {count} program memberships; specify the program") + # The message leaves the count out: it is returned to API clients + super().__init__("Beneficiary has several program memberships; specify the program") self.count = count @@ -61,7 +62,7 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: if "|" in identifier_str: system, value = identifier_str.split("|", 1) # Raises AmbiguousIdentifierError when several registrants hold it - partner = self.find_beneficiary(system, value) + partner = self.find_beneficiary(system, value, is_group=beneficiary.startswith("Group/")) if partner: domain.append(("partner_id", "=", partner.id)) else: @@ -130,17 +131,21 @@ def find_by_identifier(self, system_uri: str, value: str, program=None): return self.env["spp.program.membership"] return self.find_for_beneficiary(partner, program) - def find_beneficiary(self, system_uri: str, value: str): + def find_beneficiary(self, system_uri: str, value: str, is_group=None): """ Lookup the beneficiary (Individual or Group) by external identifier. + Args: + is_group: True/False when the reference names the kind + (``Group/`` or ``Individual/``), None when it does not + Returns: res.partner record or empty recordset Raises: AmbiguousIdentifierError: several registrants hold the identifier """ - return resolve_registrant(self.env, system_uri, value) + return resolve_registrant(self.env, system_uri, value, is_group=is_group) def find_for_beneficiary(self, partner, program=None): """ @@ -403,7 +408,7 @@ def _parse_beneficiary_reference(self, reference: str): system = unquote(system) - return self.find_beneficiary(system, value) + return self.find_beneficiary(system, value, is_group=reference.startswith("Group/")) def create(self, schema: ProgramMembership, source: str) -> Any: """ diff --git a/spp_api_v2_programs/tests/test_program_membership_api.py b/spp_api_v2_programs/tests/test_program_membership_api.py index f06c3231a..4d4da06d3 100644 --- a/spp_api_v2_programs/tests/test_program_membership_api.py +++ b/spp_api_v2_programs/tests/test_program_membership_api.py @@ -74,11 +74,28 @@ def test_read_program_membership_success(self): self.assertIn("program", data) self.assertIn("beneficiary", data) + def _no_consent_required_token(self): + """Token for a client with a legal basis (no consent needed), to test 404 behaviour + + Consent-requiring clients get 403 for an unknown beneficiary to prevent + enumeration, as on the Individual endpoints. + """ + client = self.create_api_client( + name="No Consent Required Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "update"}, + ], + require_consent=False, + legal_basis="public_interest", + ) + return self.generate_jwt_token(client) + def test_read_program_membership_not_found(self): - """GET with non-existent ID returns 404""" + """GET with non-existent ID returns 404 (for non-consent-requiring clients)""" url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NONEXISTENT" - response = self.url_open(url, headers=self._get_headers()) + response = self.url_open(url, headers=self._get_headers(token=self._no_consent_required_token())) self.assertEqual(response.status_code, 404) @@ -336,8 +353,9 @@ def test_update_program_membership_wrong_if_match_returns_409(self): self.assertEqual(response.status_code, 409) def test_update_program_membership_not_found_returns_404(self): - """PUT to a non-existent membership returns 404""" + """PUT to a non-existent membership returns 404 (for non-consent-requiring clients)""" url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|NONEXISTENT-PUT" + token = self._no_consent_required_token() payload = { "type": "ProgramMembership", @@ -348,7 +366,7 @@ def test_update_program_membership_not_found_returns_404(self): "status": "paused", } - response = self.url_put(url, data=json.dumps(payload), headers=self._get_headers()) + response = self.url_put(url, data=json.dumps(payload), headers=self._get_headers(token=token)) self.assertEqual(response.status_code, 404) def test_update_program_membership_no_scope(self): From 2193218c8d721c80702c60649a7e693daa535bee Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 25 Sep 2026 12:40:46 +0800 Subject: [PATCH 13/53] docs(spp_api_v2, spp_api_v2_programs): regenerate README from CI (#554) --- spp_api_v2/README.rst | 56 ++++++++++++ spp_api_v2/static/description/index.html | 88 +++++++++++++++---- spp_api_v2_programs/README.rst | 34 +++++++ .../static/description/index.html | 35 ++++++++ 4 files changed, 198 insertions(+), 15 deletions(-) diff --git a/spp_api_v2/README.rst b/spp_api_v2/README.rst index 26a40cefc..85e70bc04 100644 --- a/spp_api_v2/README.rst +++ b/spp_api_v2/README.rst @@ -147,6 +147,62 @@ Dependencies Changelog ========= +19.0.2.2.0 +~~~~~~~~~~ + +- fix: search filters fail closed (#554). A filter that names nothing + (an unknown ``group``, ``gender``, ``membership-role`` or ``member``) + now returns no results instead of silently dropping out and returning + the whole registry. Malformed filters (``identifier``, ``group``, + ``gender``, ``birthdate``, ``_lastUpdated`` without the expected + format; ``member`` not an ``Individual/{system}|{value}`` reference) + return ``400`` on ``GET /Individual`` and ``GET /Group``. +- fix: multi-condition search filters apply to one related row (#554). + ``GET /Individual?group=`` no longer lists someone who left the group + but is active in another; ``membership-role=`` requires the role on an + active membership; ``identifier=`` requires system and value on the + same ID. Behaviour change: ``GET /Group?member=`` now lists only + groups the individual is an active member of. +- fix: returned references can be followed (#554). + ``Group.member[].entity``, the ``group``/``entity`` references in + ``$add-member``, ``$remove-member`` and member-update responses, + membership-history ``member``, and + ``Individual.groupMembership[].group`` were built from the identifier + type's vocabulary namespace (``urn:openspp:vocab:id-type|…``) and + could not be resolved; they now use the full code URI + (``urn:openspp:vocab:id-type#|…``), the same value as + ``identifier[].system``. +- fix: ending a membership "now" takes effect immediately (#554). + ``$remove-member`` without ``endedDate``, and the member moves in + group merge and split, wrote the end time with microseconds, which the + ``is_ended``/``status`` computes (second precision) treated as a + moment in the future: the membership stayed active until the repair + cron ran. They now use the ORM's clock (``fields.Datetime.now()``). +- fix: identifiers are resolved to exactly one registrant (#554). + ``POST /Individual``, ``POST /Group``, ``$split`` and create entries + in ``$batch`` bundles return ``409`` when an identifier (type + value) + is already live on another registrant, instead of creating a second + record no lookup could tell apart; an archived registrant still holds + its identifiers (mark the ID invalid to free it). Lookups no longer + pick one of several registrants holding an identifier: read, update, + patch, member operations, merge/split and ``$batch``/transaction + bundles return ``409``; a client that requires consent and may not + read every match gets the same ``403`` as for a registrant that does + not exist (bundles, which do no consent filtering, always answer + ``409``). Search filters naming an ambiguous identifier (``group=``, + ``member=``) return ``409``, or an empty page for such a client; bulk + export reports the identifier with item status ``error`` (or + ``access_denied``). Individual endpoints and the ``member=`` filter + resolve individuals only, and Group endpoints groups only, so a value + held by one individual and one group is not ambiguous, and + ``GET /Individual/{id}`` no longer returns a group. Soft-removed + (``invalid``) IDs no longer resolve, are no longer listed in + ``identifier[]`` and are not matched by ``identifier=``; references + are built from a live ID, and a registrant with no live ID is left out + of ``Group.member[]``, membership history and + ``/Individual/{id}/groups``. Searching by ``identifier=`` still lists + every registrant holding a live match. + 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_api_v2/static/description/index.html b/spp_api_v2/static/description/index.html index 433b44e60..5a307a8bc 100644 --- a/spp_api_v2/static/description/index.html +++ b/spp_api_v2/static/description/index.html @@ -517,28 +517,86 @@

Dependencies

Changelog

-

19.0.2.1.1

+

19.0.2.2.0

+
    +
  • fix: search filters fail closed (#554). A filter that names nothing +(an unknown group, gender, membership-role or member) +now returns no results instead of silently dropping out and returning +the whole registry. Malformed filters (identifier, group, +gender, birthdate, _lastUpdated without the expected +format; member not an Individual/{system}|{value} reference) +return 400 on GET /Individual and GET /Group.
  • +
  • fix: multi-condition search filters apply to one related row (#554). +GET /Individual?group= no longer lists someone who left the group +but is active in another; membership-role= requires the role on an +active membership; identifier= requires system and value on the +same ID. Behaviour change: GET /Group?member= now lists only +groups the individual is an active member of.
  • +
  • fix: returned references can be followed (#554). +Group.member[].entity, the group/entity references in +$add-member, $remove-member and member-update responses, +membership-history member, and +Individual.groupMembership[].group were built from the identifier +type’s vocabulary namespace (urn:openspp:vocab:id-type|…) and +could not be resolved; they now use the full code URI +(urn:openspp:vocab:id-type#<code>|…), the same value as +identifier[].system.
  • +
  • fix: ending a membership “now” takes effect immediately (#554). +$remove-member without endedDate, and the member moves in +group merge and split, wrote the end time with microseconds, which the +is_ended/status computes (second precision) treated as a +moment in the future: the membership stayed active until the repair +cron ran. They now use the ORM’s clock (fields.Datetime.now()).
  • +
  • fix: identifiers are resolved to exactly one registrant (#554). +POST /Individual, POST /Group, $split and create entries +in $batch bundles return 409 when an identifier (type + value) +is already live on another registrant, instead of creating a second +record no lookup could tell apart; an archived registrant still holds +its identifiers (mark the ID invalid to free it). Lookups no longer +pick one of several registrants holding an identifier: read, update, +patch, member operations, merge/split and $batch/transaction +bundles return 409; a client that requires consent and may not +read every match gets the same 403 as for a registrant that does +not exist (bundles, which do no consent filtering, always answer +409). Search filters naming an ambiguous identifier (group=, +member=) return 409, or an empty page for such a client; bulk +export reports the identifier with item status error (or +access_denied). Individual endpoints and the member= filter +resolve individuals only, and Group endpoints groups only, so a value +held by one individual and one group is not ambiguous, and +GET /Individual/{id} no longer returns a group. Soft-removed +(invalid) IDs no longer resolve, are no longer listed in +identifier[] and are not matched by identifier=; references +are built from a live ID, and a registrant with no live ID is left out +of Group.member[], membership history and +/Individual/{id}/groups. Searching by identifier= still lists +every registrant holding a live match.
  • +
+
+
+

19.0.2.1.1

  • chore(api_v2): the API V2 configuration menu moved from Registry > Configuration to Settings > Registry, alongside the other superuser configuration (#1009)
-
-

19.0.2.1.0

+
+

19.0.2.1.0

  • Add OpenAPI polymorphic schema utilities (utils/openapi_polymorphic.py): polymorphic_body() for @@ -563,8 +621,8 @@

    19.0.2.1.0

    polymorphic utilities, and the overall OpenAPI document contract
-
-

19.0.2.0.1

+
+

19.0.2.0.1

  • Fix SerializationFailure race when multiple Odoo workers rebuild their routing map simultaneously (e.g. after -u all) and all try @@ -579,15 +637,15 @@

    19.0.2.0.1

    regressions are diagnosable without raising the global log level
-
-

19.0.2.0.0

+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -595,7 +653,7 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

diff --git a/spp_api_v2_programs/README.rst b/spp_api_v2_programs/README.rst index c0a4f9cae..12a5b8e9d 100644 --- a/spp_api_v2_programs/README.rst +++ b/spp_api_v2_programs/README.rst @@ -66,6 +66,40 @@ Dependencies Changelog ========= +19.0.1.1.0 +~~~~~~~~~~ + +- fix: ``GET``/``PUT /ProgramMembership/{identifier}`` no longer resolve + to an arbitrary program's membership when the beneficiary is enrolled + in several programs (#554). A new optional ``program`` query parameter + (``Program/{system}|{value}``) selects the membership (a malformed + value returns ``400``, an unknown program ``404``); without it, a + beneficiary with more than one membership returns ``409``. When no + single membership resolves, a client that requires consent and may not + read the beneficiary gets ``403`` whatever the reason (unknown + beneficiary, not enrolled in that program, several memberships), so + neither the registry's contents nor enrollments are revealed; the + ``409`` message does not give the number of memberships. +- fix: ``PUT /ProgramMembership`` can no longer move a membership to + another program or beneficiary: a body whose ``program`` or + ``beneficiary`` differs from the addressed membership returns ``422`` + (#554). +- fix: the ``Location`` header of ``POST /ProgramMembership`` is + URL-encoded and carries ``?program=``, so following it returns the + created membership (#554). +- fix: ``PUT /ProgramMembership`` accepts its own + ``ETag``/``meta.versionId`` in ``If-Match``; it previously compared + against a different timestamp format and always returned ``409`` + (#554). +- fix: a beneficiary identifier held by more than one registrant is + refused on ``GET``/``PUT``/``POST /ProgramMembership`` (``409``, or + ``403`` for a client that requires consent and may not read every + match) and in the ``beneficiary`` search filter (``409``, or an empty + page for such a client), instead of acting on an arbitrary one; + ``Individual/`` and ``Group/`` beneficiary references resolve only + that kind. Membership identifiers, beneficiary references and + ``Location`` use only live (not soft-removed) IDs (#554). + 19.0.1.0.0 ~~~~~~~~~~ diff --git a/spp_api_v2_programs/static/description/index.html b/spp_api_v2_programs/static/description/index.html index 27b961dcd..cd6228347 100644 --- a/spp_api_v2_programs/static/description/index.html +++ b/spp_api_v2_programs/static/description/index.html @@ -414,6 +414,41 @@

Changelog

+

19.0.1.1.0

+
    +
  • fix: GET/PUT /ProgramMembership/{identifier} no longer resolve +to an arbitrary program’s membership when the beneficiary is enrolled +in several programs (#554). A new optional program query parameter +(Program/{system}|{value}) selects the membership (a malformed +value returns 400, an unknown program 404); without it, a +beneficiary with more than one membership returns 409. When no +single membership resolves, a client that requires consent and may not +read the beneficiary gets 403 whatever the reason (unknown +beneficiary, not enrolled in that program, several memberships), so +neither the registry’s contents nor enrollments are revealed; the +409 message does not give the number of memberships.
  • +
  • fix: PUT /ProgramMembership can no longer move a membership to +another program or beneficiary: a body whose program or +beneficiary differs from the addressed membership returns 422 +(#554).
  • +
  • fix: the Location header of POST /ProgramMembership is +URL-encoded and carries ?program=, so following it returns the +created membership (#554).
  • +
  • fix: PUT /ProgramMembership accepts its own +ETag/meta.versionId in If-Match; it previously compared +against a different timestamp format and always returned 409 +(#554).
  • +
  • fix: a beneficiary identifier held by more than one registrant is +refused on GET/PUT/POST /ProgramMembership (409, or +403 for a client that requires consent and may not read every +match) and in the beneficiary search filter (409, or an empty +page for such a client), instead of acting on an arbitrary one; +Individual/ and Group/ beneficiary references resolve only +that kind. Membership identifiers, beneficiary references and +Location use only live (not soft-removed) IDs (#554).
  • +
+
+

19.0.1.0.0

  • Split program and program-membership REST endpoints out of From 24bbf263bd1c745282fca2681fe6db48be177e85 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 12:09:38 +0800 Subject: [PATCH 14/53] fix(spp_api_v2): apply _offset in GET /Group search (#554) search_groups never read _offset, so every page and next link returned the first page, and consent over-fetch refilled pages from the start of the results (empty or repeated pages). --- spp_api_v2/readme/HISTORY.md | 1 + spp_api_v2/services/search_service.py | 5 ++++- spp_api_v2/tests/test_group_api.py | 28 +++++++++++++++++++++++++ spp_api_v2/tests/test_search_service.py | 9 ++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 8c77c5c69..45da1d004 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -5,6 +5,7 @@ - fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). - fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. +- fix: `GET /Group` applies `_offset` (#554). The group search ignored it, so every page (and every `next` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. ### 19.0.2.1.1 diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index a8f4aa458..af2abc3fd 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -121,6 +121,7 @@ def search_groups(self, params: dict[str, Any]) -> tuple[list, int]: - type: household/family/organization/other - member: Individual reference - _count: int + - _offset: int (skip records) - _lastId: int (cursor-based pagination) Returns: @@ -151,11 +152,13 @@ def search_groups(self, params: dict[str, Any]) -> tuple[list, int]: _logger.warning("Invalid _lastId parameter: %s, skipping cursor pagination", last_id) limit = min(int(params.get("_count", 20)), 100) + offset = int(params.get("_offset", 0)) order = self._parse_sort_param(params.get("_sort")) if "id" not in order.lower(): order = f"{order}, id" - records = Partner.sudo().search(domain, limit=limit, order=order) # nosemgrep: odoo-sudo-without-context + # nosemgrep: odoo-sudo-without-context + records = Partner.sudo().search(domain, limit=limit, offset=offset, order=order) return records, total diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index 2d05ef62a..1fc286d1a 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -227,6 +227,34 @@ def test_search_pagination(self): self.assertLessEqual(len(data.get("data", [])), 1) self.assertIn("links", data) + def _search_values(self, query): + """GET /Group?{query} and return the identifier values of the page""" + response = self.url_open(f"{self.api_base_url}?{query}", headers=self._get_headers()) + self.assertEqual(response.status_code, 200) + return [group["identifier"][0]["value"] for group in json.loads(response.content)["data"]] + + def test_search_offset_pages_through_results(self): + """_offset moves to the next page instead of repeating the first one""" + second_group = self.create_test_group(name="Smith Household Two", identifier_value="HH-002") + self.create_consent( + registrant=second_group, + grantee_partner=self.client.partner_id, + resource_type="group", + field_access="all", + ) + + self.assertEqual(self._search_values("name=Smith+Household&_count=1&_offset=0"), ["HH-001"]) + self.assertEqual(self._search_values("name=Smith+Household&_count=1&_offset=1"), ["HH-002"]) + self.assertEqual(self._search_values("name=Smith+Household&_count=1&_offset=2"), []) + + def test_search_page_filled_past_consent_denied_groups(self): + """Groups without consent are skipped and the page is filled from the records after them""" + # Sorted by name these come before "Smith Household", and the client has no consent for them + self.create_test_group(name="Smith Aaa", identifier_value="HH-NC-1") + self.create_test_group(name="Smith Bbb", identifier_value="HH-NC-2") + + self.assertEqual(self._search_values("name=Smith&_count=1"), ["HH-001"]) + def test_create_group_success(self): """POST /Group creates new group""" payload = { diff --git a/spp_api_v2/tests/test_search_service.py b/spp_api_v2/tests/test_search_service.py index aedc7b934..9978a20c9 100644 --- a/spp_api_v2/tests/test_search_service.py +++ b/spp_api_v2/tests/test_search_service.py @@ -286,3 +286,12 @@ def test_search_groups_pagination(self): self.assertGreaterEqual(total, 2) self.assertEqual(len(records), 1) + + def test_search_groups_offset(self): + """_offset skips records: page 2 is the second group, not page 1 again""" + all_records, _total = self.service.search_groups({"_count": 100}) + first_page, _total = self.service.search_groups({"_count": 1, "_offset": 0}) + second_page, _total = self.service.search_groups({"_count": 1, "_offset": 1}) + + self.assertEqual(first_page, all_records[0]) + self.assertEqual(second_page, all_records[1]) From b1895c1c5059f5bb1d3c0cb6a0b7988e7d951d77 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 12:16:45 +0800 Subject: [PATCH 15/53] docs(spp_api_v2): regenerate README from CI (#554) --- spp_api_v2/README.rst | 5 +++++ spp_api_v2/static/description/index.html | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/spp_api_v2/README.rst b/spp_api_v2/README.rst index 85e70bc04..6370a1e3b 100644 --- a/spp_api_v2/README.rst +++ b/spp_api_v2/README.rst @@ -202,6 +202,11 @@ Changelog of ``Group.member[]``, membership history and ``/Individual/{id}/groups``. Searching by ``identifier=`` still lists every registrant holding a live match. +- fix: ``GET /Group`` applies ``_offset`` (#554). The group search + ignored it, so every page (and every ``next`` link) returned the first + page again, and when consent filtering skipped records the page was + refilled from the start of the results, returning no records or + repeating groups. 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_api_v2/static/description/index.html b/spp_api_v2/static/description/index.html index 5a307a8bc..8797988c1 100644 --- a/spp_api_v2/static/description/index.html +++ b/spp_api_v2/static/description/index.html @@ -585,6 +585,11 @@

    19.0.2.2.0

    of Group.member[], membership history and /Individual/{id}/groups. Searching by identifier= still lists every registrant holding a live match.
  • +
  • fix: GET /Group applies _offset (#554). The group search +ignored it, so every page (and every next link) returned the first +page again, and when consent filtering skipped records the page was +refilled from the start of the results, returning no records or +repeating groups.
From 2a7b61d6cb1de4751907326ab9903d350feb9cdc Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 16/53] test(spp_api_v2_programs): GET /Program without scope must be 403, not 500 (#554 Q) --- .../tests/test_scope_enforcement_program.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spp_api_v2_programs/tests/test_scope_enforcement_program.py b/spp_api_v2_programs/tests/test_scope_enforcement_program.py index 0feced987..67c956aa8 100644 --- a/spp_api_v2_programs/tests/test_scope_enforcement_program.py +++ b/spp_api_v2_programs/tests/test_scope_enforcement_program.py @@ -138,3 +138,25 @@ def test_individual_scope_does_not_grant_program_membership_access(self): data = json.loads(response.content) self.assertIn("Missing required scope", data["detail"]) self.assertIn("program_membership:create", data["detail"]) + + +class TestScopeEnforcementProgram(ApiV2HttpTestCase): + """Test scope enforcement on the Program search endpoint""" + + def test_program_search_requires_scope(self): + """GET /Program returns 403, not 500, without program:search or program:read scope""" + client = self.create_api_client( + name="No program scope", + scopes=[{"resource": "individual", "action": "all"}], + require_consent=False, + ) + token = self.generate_jwt_token(client) + + response = self.url_open( + "/api/v2/spp/Program", + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(response.status_code, 403) + data = json.loads(response.content) + self.assertIn("program:search", data["detail"]) From d70a43e0577acaf4a62fc035c253467a2c6d9cec Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 17/53] test(spp_api_v2): PATCH /Individual gender must update and reject unknown codes (#554 N) --- spp_api_v2/tests/test_patch_api.py | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/spp_api_v2/tests/test_patch_api.py b/spp_api_v2/tests/test_patch_api.py index 7327cb0bc..ef12cab59 100644 --- a/spp_api_v2/tests/test_patch_api.py +++ b/spp_api_v2/tests/test_patch_api.py @@ -4,6 +4,8 @@ import json from datetime import date +from odoo.tools import mute_logger + from .common import ApiV2HttpTestCase @@ -299,6 +301,43 @@ def test_patch_individual_updates_version(self): # Version should have changed self.assertNotEqual(old_version_id, new_version_id) + def test_patch_individual_gender(self): + """PATCH /Individual/{id} changes gender from a vocabulary coding""" + url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" + payload = {"gender": {"coding": [{"system": "urn:iso:std:iso:5218", "code": "1"}]}} + + response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 200, response.content) + data = json.loads(response.content) + self.assertEqual(data["gender"]["coding"][0]["code"], "1") + self.individual.invalidate_recordset() + self.assertEqual(self.individual.gender_id, self.gender_male) + + def test_patch_individual_unknown_gender_returns_422(self): + """PATCH /Individual/{id} with an unknown gender code returns 422 naming it, as create does""" + url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" + payload = {"gender": {"coding": [{"system": "urn:iso:std:iso:5218", "code": "7"}]}} + + with mute_logger("odoo.addons.spp_api_v2.routers.individual"): + response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertIn("Invalid gender code", json.loads(response.content)["detail"]) + self.individual.invalidate_recordset() + self.assertEqual(self.individual.gender_id, self.gender_female) + + def test_patch_individual_gender_null_clears(self): + """PATCH /Individual/{id} with gender null clears it""" + url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" + + response = self.url_patch(url, data=json.dumps({"gender": None}), headers=self._get_headers()) + + self.assertEqual(response.status_code, 200, response.content) + self.assertIsNone(json.loads(response.content)["gender"]) + self.individual.invalidate_recordset() + self.assertFalse(self.individual.gender_id) + # ============================================================================= # Group PATCH Tests # ============================================================================= From 7f484e4e7b439f00a422e3104edd8ffe38739805 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 18/53] test(spp_api_v2): unknown membership role must be rejected on add/update member (#554 P) --- spp_api_v2/tests/test_group_api.py | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index 1fc286d1a..b130e4be2 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -4,6 +4,7 @@ import json from odoo import fields +from odoo.tools import mute_logger from .common import ApiV2HttpTestCase @@ -690,6 +691,47 @@ def test_update_member_role_success(self): self.assertIn("role", data) self.assertEqual(data["role"]["coding"][0]["code"], "head") + def test_update_member_unknown_role_returns_422(self): + """PATCH /Group/{id}/member/{individual_id} with an unknown role code returns 422 and changes nothing""" + url = ( + f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_household_id|HH-001/member/" + "urn:openspp:vocab:id-type%23test_national_id|IND-001" + ) + payload = { + "role": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "not_a_role"}]}, + } + + with mute_logger("odoo.addons.spp_api_v2.routers.group"): + response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertIn("Unknown membership role", json.loads(response.content)["detail"]) + self.env.invalidate_all() + membership = self.group.group_membership_ids.filtered(lambda m: m.individual == self.individual1) + self.assertEqual(membership.membership_type_ids, self.relationship_head) + + def test_add_member_unknown_role_returns_422(self): + """POST /Group/{id}/$add-member with an unknown role code returns 422 and adds no member""" + self.create_test_individual(name="Role Typo", identifier_value="IND-ROLE-TYPO") + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_household_id|HH-001/$add-member" + payload = { + "entity": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|IND-ROLE-TYPO"}, + "role": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "not_a_role"}]}, + } + + with mute_logger("odoo.addons.spp_api_v2.routers.group"): + response = self.url_open(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + detail = json.loads(response.content)["detail"] + self.assertIn("Unknown membership role", detail) + self.assertIn("not_a_role", detail) + self.env.invalidate_all() + self.assertFalse( + self.group.group_membership_ids.filtered(lambda m: m.individual.name == "Role Typo"), + "No membership may be created for an unknown role", + ) + def test_update_member_dates(self): """PATCH /Group/{id}/member/{individual_id} updates membership dates""" url = ( From b30d1c337548b5b10ec62119208762398a142087 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 19/53] test(spp_api_v2_programs): membership search fails closed; duplicate enrollment is 409 without DB internals (#554 L, O) --- .../tests/test_program_membership_api.py | 117 +++++++++++++++++- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/spp_api_v2_programs/tests/test_program_membership_api.py b/spp_api_v2_programs/tests/test_program_membership_api.py index 4d4da06d3..f02e754f8 100644 --- a/spp_api_v2_programs/tests/test_program_membership_api.py +++ b/spp_api_v2_programs/tests/test_program_membership_api.py @@ -3,9 +3,14 @@ import json from datetime import date +from unittest.mock import patch + +from odoo.tools import mute_logger from odoo.addons.spp_api_v2.tests.common import ApiV2HttpTestCase +from ..services.program_membership_service import ProgramMembershipService + class TestProgramMembershipAPIEndpoints(ApiV2HttpTestCase): """Test ProgramMembership resource HTTP endpoints""" @@ -494,22 +499,51 @@ def test_consent_filtering_applied(self): self.assertEqual(response.status_code, 403) def test_search_with_invalid_beneficiary_format(self): - """Search with unrecognized beneficiary format ignores the filter""" + """Search with unrecognized beneficiary format returns 400 instead of every membership""" url = f"{self.api_base_url}?beneficiary=InvalidFormat" response = self.url_open(url, headers=self._get_headers()) - # Unrecognized format (not starting with Individual/ or Group/) is ignored - self.assertEqual(response.status_code, 200) + # Unrecognized format (not starting with Individual/ or Group/) is rejected + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid beneficiary", json.loads(response.content)["detail"]) def test_search_with_invalid_program_format(self): - """Search with unrecognized program format ignores the filter""" + """Search with unrecognized program format returns 400 instead of every membership""" url = f"{self.api_base_url}?program=InvalidFormat" response = self.url_open(url, headers=self._get_headers()) - # Unrecognized format (not starting with Program/) is ignored + # Unrecognized format (not starting with Program/) is rejected + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid program", json.loads(response.content)["detail"]) + + def test_search_with_beneficiary_missing_value_separator(self): + """A beneficiary reference without system|value returns 400""" + url = f"{self.api_base_url}?beneficiary=Individual/ENROLL-001" + + response = self.url_open(url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid beneficiary", json.loads(response.content)["detail"]) + + def test_search_with_program_missing_value_separator(self): + """A program reference without system|value returns 400""" + url = f"{self.api_base_url}?program=Program/test-enrollment-program" + + response = self.url_open(url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 400) + self.assertIn("Invalid program", json.loads(response.content)["detail"]) + + def test_search_with_unknown_beneficiary_is_empty(self): + """A well-formed reference that matches nobody still returns an empty page""" + url = f"{self.api_base_url}?beneficiary=Individual/urn:openspp:vocab:id-type%23test_national_id|NOBODY" + + response = self.url_open(url, headers=self._get_headers()) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content)["data"], []) def test_search_combined_filters(self): """Search supports combining multiple filters""" @@ -582,3 +616,76 @@ def test_no_token_returns_401(self): response = self.url_open(self.api_base_url, headers={"Content-Type": "application/json"}) self.assertEqual(response.status_code, 401) + + def _duplicate_payload(self): + return { + "type": "ProgramMembership", + "program": {"reference": "Program/urn:openspp:program|test-enrollment-program"}, + "beneficiary": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|ENROLL-001"}, + "status": "enrolled", + } + + def _assert_no_database_internals(self, detail): + for leaked in ("duplicate key", "Key (", "partner_id", "program_id", "constraint"): + self.assertNotIn(leaked, detail) + + def test_create_duplicate_membership_returns_409(self): + """POST for a beneficiary already in the program returns 409 without database internals""" + response = self.url_open( + self.api_base_url, + data=json.dumps(self._duplicate_payload()), + headers=self._get_headers(), + ) + + self.assertEqual(response.status_code, 409) + detail = json.loads(response.content)["detail"] + self.assertIn("already a member", detail) + self._assert_no_database_internals(detail) + + def test_create_duplicate_membership_race_returns_409(self): + """When the pre-check misses (concurrent create), the database constraint still answers 409""" + with ( + patch.object(ProgramMembershipService, "_find_existing_membership", return_value=None, create=True), + mute_logger("odoo.sql_db", "odoo.addons.spp_api_v2_programs.routers.program_membership"), + ): + response = self.url_open( + self.api_base_url, + data=json.dumps(self._duplicate_payload()), + headers=self._get_headers(), + ) + + self.assertEqual(response.status_code, 409) + detail = json.loads(response.content)["detail"] + self.assertIn("already a member", detail) + self._assert_no_database_internals(detail) + + def test_create_unexpected_error_hides_exception_text(self): + """An unexpected create error returns a generic 422, not the exception text""" + new_individual = self.create_test_individual(identifier_value="HIDE-ERR-001") + payload = self._duplicate_payload() + payload["beneficiary"]["reference"] = "Individual/urn:openspp:vocab:id-type#test_national_id|HIDE-ERR-001" + self.assertTrue(new_individual) + + with ( + patch.object(ProgramMembershipService, "create", side_effect=RuntimeError("internal detail 4242")), + mute_logger("odoo.addons.spp_api_v2_programs.routers.program_membership"), + ): + response = self.url_open(self.api_base_url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertNotIn("internal detail 4242", json.loads(response.content)["detail"]) + + def test_update_unexpected_error_hides_exception_text(self): + """An unexpected PUT error returns a generic 422, not the exception text""" + url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|ENROLL-001" + payload = json.loads(self.url_open(url, headers=self._get_headers()).content) + payload["status"] = "paused" + + with ( + patch.object(ProgramMembershipService, "update", side_effect=RuntimeError("internal detail 4343")), + mute_logger("odoo.addons.spp_api_v2_programs.routers.program_membership"), + ): + response = self.url_put(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertNotIn("internal detail 4343", json.loads(response.content)["detail"]) From da2bceef9615ba76a669be38d1e1d6a927405b15 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 20/53] test(spp_api_v2): group= and membership-role= must hold on the same membership (#554 K) --- .../tests/test_search_filters_fail_closed.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/spp_api_v2/tests/test_search_filters_fail_closed.py b/spp_api_v2/tests/test_search_filters_fail_closed.py index bdc046b44..82e9bfea0 100644 --- a/spp_api_v2/tests/test_search_filters_fail_closed.py +++ b/spp_api_v2/tests/test_search_filters_fail_closed.py @@ -145,6 +145,37 @@ def test_identifier_system_and_value_must_be_on_the_same_id(self): self.assertEqual(total, 0) self.assertFalse(records) + def test_group_and_membership_role_must_be_on_the_same_membership(self): + """Plain member of G and head of H: not a head of G""" + self._add_member(self.group_g, self.ind1) + self._add_member(self.group_h, self.ind1, role=self.relationship_head) + self._add_member(self.group_g, self.ind2, role=self.relationship_head) # positive control: head of G + + records, _total = self.service.search_individuals( + {"group": f"{HOUSEHOLD_ID}|FC-HH-G", "membership-role": "head"} + ) + + self.assertNotIn(self.ind1, records) + self.assertIn(self.ind2, records) + + def test_group_and_membership_role_ignore_ended_membership(self): + """Head of G, but the membership has ended: not a current head of G""" + self._add_member(self.group_g, self.ind1, ended=True, role=self.relationship_head) + + records, _total = self.service.search_individuals( + {"group": f"{HOUSEHOLD_ID}|FC-HH-G", "membership-role": "head"} + ) + + self.assertNotIn(self.ind1, records) + + def test_no_group_and_membership_role_matches_nothing(self): + """group=none (no active group) with a role held on an active membership is contradictory""" + self._add_member(self.group_g, self.ind1, role=self.relationship_head) + + records, _total = self.service.search_individuals({"group": "none", "membership-role": "head"}) + + self.assertNotIn(self.ind1, records) + def test_member_filter_ignores_ended_memberships(self): """A group the individual has left is not listed for ?member=""" self._add_member(self.group_g, self.ind2, ended=True) From af9a89e3e9913aa060a4b72ff204a150ad1ab23a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:39:46 +0800 Subject: [PATCH 21/53] test(spp_api_v2): consent-filtered paging must not skip rows or stop early (#554 M) --- spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_consent_paging.py | 150 ++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 spp_api_v2/tests/test_consent_paging.py diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index ffbf00969..5404fa3db 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -42,3 +42,4 @@ from . import test_membership_end_now from . import test_identifier_ambiguity from . import test_identifier_ambiguity_paths +from . import test_consent_paging diff --git a/spp_api_v2/tests/test_consent_paging.py b/spp_api_v2/tests/test_consent_paging.py new file mode 100644 index 000000000..463745fca --- /dev/null +++ b/spp_api_v2/tests/test_consent_paging.py @@ -0,0 +1,150 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Consent-filtered paging neither skips records nor stops early (#554 item M). + +When consent filtering hides records, a page is filled from the rows after +them. The next page must start right after the last row actually examined, +and a page cut short by the over-fetch safety limit must still link to the +next page while rows remain. +""" + +import json + +from odoo.tests.common import BaseCase + +from ..utils.pagination import fetch_with_consent +from .common import ApiV2HttpTestCase + +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" +HOUSEHOLD_ID = "urn:openspp:vocab:id-type#test_household_id" + +# The search services return at most this many rows per query +SERVICE_MAX_ROWS = 100 + + +def _search_over(rows): + """A search function over a list of rows that caps each query like the services do""" + + def search_function(offset, limit): + limit = min(limit, SERVICE_MAX_ROWS) + return rows[offset : offset + limit], len(rows) + + return search_function + + +class TestFetchWithConsent(BaseCase): + """utils.pagination.fetch_with_consent""" + + def test_consumed_offset_counts_only_examined_rows(self): + """The page fills partway through a batch: rows after the last one used are not consumed""" + rows = list(range(10)) + denied = {1} + + collected, consumed, _total, consent_applied = fetch_with_consent( + _search_over(rows), lambda row: None if row in denied else row, count=2, offset=0 + ) + + self.assertEqual(collected, [0, 2]) + self.assertTrue(consent_applied) + self.assertEqual(consumed, 3, "The next page must start at row 3, the first row not examined") + + def test_page_larger_than_half_the_service_cap_is_filled(self): + """count > 50: a batch capped at the service maximum is not mistaken for the end of the data""" + rows = list(range(500)) + + collected, _consumed, _total, _applied = fetch_with_consent( + _search_over(rows), lambda row: None if row % 2 else row, count=60, offset=0 + ) + + self.assertEqual(len(collected), 60) + self.assertEqual(collected, list(range(0, 120, 2))) + + def test_pages_cover_every_visible_row_exactly_once(self): + """Following the consumed offset visits every consented row once""" + rows = list(range(37)) + denied = {1, 2, 5, 11, 12, 13, 20, 36} + seen = [] + offset = 0 + + for _page in range(50): + collected, offset, total, _applied = fetch_with_consent( + _search_over(rows), lambda row: None if row in denied else row, count=4, offset=offset + ) + seen.extend(collected) + if offset >= total: + break + + self.assertEqual(seen, [row for row in rows if row not in denied]) + + +class TestConsentPagingAPI(ApiV2HttpTestCase): + """HTTP: next links of consent-filtered Individual and Group searches""" + + def setUp(self): + super().setUp() + self.client = self.create_api_client( + name="Consent Paging Client", + scopes=[ + {"resource": "individual", "action": "read"}, + {"resource": "group", "action": "read"}, + ], + ) + self.token = self.generate_jwt_token(self.client) + + def _consent(self, registrant, resource_type): + self.create_consent( + registrant=registrant, + grantee_partner=self.client.partner_id, + resource_type=resource_type, + field_access="all", + ) + + def _page(self, url): + response = self.url_open(url, headers={"Authorization": f"Bearer {self.token}"}) + self.assertEqual(response.status_code, 200, response.content) + body = json.loads(response.content) + return [record["identifier"][0]["value"] for record in body["data"]], body["links"].get("next") + + def test_individual_next_page_does_not_skip(self): + """B has no consent: page 1 is A, C and page 2 starts at D""" + for letter in "ABCDE": + person = self.create_test_individual(name=f"Paging Person {letter}", identifier_value=f"CP-{letter}") + if letter != "B": + self._consent(person, "individual") + + page1, next_url = self._page("/api/v2/spp/Individual?name=Paging+Person&_count=2") + page2, _next = self._page(next_url) + + self.assertEqual(page1, ["CP-A", "CP-C"]) + self.assertEqual(page2, ["CP-D", "CP-E"]) + + def test_group_next_page_does_not_skip(self): + """Group B has no consent: page 1 is A, C and page 2 starts at D""" + for letter in "ABCDE": + group = self.create_test_group(name=f"Paging Group {letter}", identifier_value=f"CPG-{letter}") + if letter != "B": + self._consent(group, "group") + + page1, next_url = self._page("/api/v2/spp/Group?name=Paging+Group&_count=2") + page2, _next = self._page(next_url) + + self.assertEqual(page1, ["CPG-A", "CPG-C"]) + self.assertEqual(page2, ["CPG-D", "CPG-E"]) + + def test_short_page_keeps_next_while_rows_remain(self): + """The safety limit cuts the page short: the next link is still given, and leads to E""" + for letter in "ABCDE": + person = self.create_test_individual(name=f"Sparse Person {letter}", identifier_value=f"SP-{letter}") + if letter == "E": + self._consent(person, "individual") + + page1, next_url = self._page("/api/v2/spp/Individual?name=Sparse+Person&_count=1") + self.assertEqual(page1, [], "A, B and C are hidden and the 3x safety limit stops the scan") + self.assertIsNotNone(next_url, "Rows remain, so the client must be told to continue") + + seen = [] + for _page in range(5): + values, next_url = self._page(next_url) + seen.extend(values) + if not next_url: + break + self.assertEqual(seen, ["SP-E"]) From 913052bacf1bc77b2bef7299df5eee52c2c49c5e Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:24 +0800 Subject: [PATCH 22/53] fix(spp_api_v2_programs): GET /Program without scope returns 403, not 500 (#554 Q) --- spp_api_v2_programs/routers/program.py | 12 ++++++------ .../tests/test_scope_enforcement_program.py | 11 +++++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/spp_api_v2_programs/routers/program.py b/spp_api_v2_programs/routers/program.py index 98d431fb5..a55055c18 100644 --- a/spp_api_v2_programs/routers/program.py +++ b/spp_api_v2_programs/routers/program.py @@ -85,7 +85,7 @@ async def search_programs( api_client: Annotated[dict, Depends(get_authenticated_client)], identifier: Annotated[str | None, Query()] = None, name: Annotated[str | None, Query()] = None, - status: Annotated[str | None, Query()] = None, + status_: Annotated[str | None, Query(alias="status")] = None, type_: Annotated[str | None, Query(alias="type")] = None, target_type: Annotated[str | None, Query(alias="targetType")] = None, count: Annotated[int, Query(alias="_count", ge=1, le=100)] = 20, @@ -121,10 +121,10 @@ async def search_programs( domain.append(("name", "ilike", name)) # Status search - if status: - if status == "active": + if status_: + if status_ == "active": domain.append(("state", "=", "active")) - elif status == "ended": + elif status_ == "ended": domain.append(("state", "=", "ended")) # Target type search @@ -160,8 +160,8 @@ async def search_programs( params = {} if name: params["name"] = name - if status: - params["status"] = status + if status_: + params["status"] = status_ if target_type: params["targetType"] = target_type params["_count"] = str(count) diff --git a/spp_api_v2_programs/tests/test_scope_enforcement_program.py b/spp_api_v2_programs/tests/test_scope_enforcement_program.py index 67c956aa8..fb6b87847 100644 --- a/spp_api_v2_programs/tests/test_scope_enforcement_program.py +++ b/spp_api_v2_programs/tests/test_scope_enforcement_program.py @@ -6,6 +6,8 @@ import json +from odoo.tools import mute_logger + from odoo.addons.spp_api_v2.tests.common import ApiV2HttpTestCase @@ -152,10 +154,11 @@ def test_program_search_requires_scope(self): ) token = self.generate_jwt_token(client) - response = self.url_open( - "/api/v2/spp/Program", - headers={"Authorization": f"Bearer {token}"}, - ) + with mute_logger("odoo.http"): + response = self.url_open( + "/api/v2/spp/Program", + headers={"Authorization": f"Bearer {token}"}, + ) self.assertEqual(response.status_code, 403) data = json.loads(response.content) From ddaf59e98624d5764066e18d0232ba585549549f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:24 +0800 Subject: [PATCH 23/53] fix(spp_api_v2): PATCH /Individual updates gender and rejects unknown codes (#554 N) --- spp_api_v2/services/individual_service.py | 27 ++++++++++++++++------- spp_api_v2/tests/test_patch_api.py | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 579f499db..0b7daccca 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -581,15 +581,26 @@ def partial_update(self, partner, patch: IndividualPatch, source: str) -> Any: if gender: if gender.get("coding"): gender_coding = gender["coding"][0] - gender_code = self.env["spp.vocabulary.code"].search( - [ - ("namespace_uri", "=", gender_coding.get("system")), - ("code", "=", gender_coding.get("code")), - ], - limit=1, + gender_code = ( + self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search( + [ + ("namespace_uri", "=", gender_coding.get("system")), + ("code", "=", gender_coding.get("code")), + ], + limit=1, + ) ) - if gender_code: - vals["gender_id"] = gender_code.id + if not gender_code: + # Same message as create: an unknown code is an error, not a no-op + raise ValidationError( + f"Invalid gender code: system='{gender_coding.get('system')}', " + f"code='{gender_coding.get('code')}'. " + f"Expected system 'urn:iso:std:iso:5218' with codes: " + f"0 (Not Known), 1 (Male), 2 (Female), 9 (Not Applicable)." + ) + vals["gender_id"] = gender_code.id else: # RFC 7396: null clears the field vals["gender_id"] = False diff --git a/spp_api_v2/tests/test_patch_api.py b/spp_api_v2/tests/test_patch_api.py index ef12cab59..6f1ffafc1 100644 --- a/spp_api_v2/tests/test_patch_api.py +++ b/spp_api_v2/tests/test_patch_api.py @@ -319,7 +319,7 @@ def test_patch_individual_unknown_gender_returns_422(self): url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" payload = {"gender": {"coding": [{"system": "urn:iso:std:iso:5218", "code": "7"}]}} - with mute_logger("odoo.addons.spp_api_v2.routers.individual"): + with mute_logger("odoo.addons.spp_api_v2.routers.individual", "odoo.http"): response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) self.assertEqual(response.status_code, 422) From 95059644f17c4fd610e36e396e46ba504d03f568 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:25 +0800 Subject: [PATCH 24/53] fix(spp_api_v2): group= and membership-role= match on the same membership (#554 K) --- spp_api_v2/services/search_service.py | 88 +++++++++++++++++++-------- 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index af2abc3fd..45041dc2d 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -73,11 +73,16 @@ def search_individuals(self, params: dict[str, Any]) -> tuple[list, int]: ] domain.extend(addr_domain) - if params.get("group"): - domain.extend(self._parse_group_param(params["group"])) - - if params.get("membership-role"): - domain.extend(self._parse_membership_role_param(params["membership-role"])) + group = params.get("group") + role = params.get("membership-role") + if group and role and group.lower() != "none": + # Together they must hold on the same membership + domain.extend(self._parse_group_and_role_params(group, role)) + else: + if group: + domain.extend(self._parse_group_param(group)) + if role: + domain.extend(self._parse_membership_role_param(role)) if params.get("_lastUpdated"): domain.extend(self._parse_date_param("write_date", params["_lastUpdated"])) @@ -274,14 +279,7 @@ def _parse_group_param(self, group: str) -> list: return [("id", "not in", membered_individual_ids)] # Standard case: filter by specific group - if "|" not in group: - raise InvalidSearchParam('Invalid group format. Expected: {system}|{value} or "none"') - - system, value = group.split("|", 1) - - # Find group by identifier (raises AmbiguousIdentifierError when - # several groups hold it) - group_partner = resolve_registrant(self.env, system, value, is_group=True) + group_partner = self._resolve_group_filter(group) if not group_partner: return MATCH_NOTHING @@ -307,35 +305,75 @@ def _parse_membership_role_param(self, role: str) -> list: if not role: return [] - # Find role code in vocabulary - role_code = ( - self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( + role_code = self._resolve_role_filter(role) + if not role_code: + return MATCH_NOTHING + + # Find individuals holding this role on an active membership; "any" + # keeps both conditions on the same membership row + return [ + ( + "individual_membership_ids", + "any", [ - ("namespace_uri", "=", "urn:openspp:vocab:group-membership-type"), - ("code", "=", role), + ("membership_type_ids", "in", role_code.id), + ("is_ended", "=", False), ], - limit=1, ) - ) + ] - if not role_code: + def _parse_group_and_role_params(self, group: str, role: str) -> list: + """ + Parse group and membership-role given together. + + Returns domain for individuals holding the role on an active + membership of that group: one "any" keeps the group, the role and + the active check on the same membership row. + """ + group_partner = self._resolve_group_filter(group) + role_code = self._resolve_role_filter(role) + if not group_partner or not role_code: return MATCH_NOTHING - # Find individuals holding this role on an active membership; "any" - # keeps both conditions on the same membership row return [ ( "individual_membership_ids", "any", [ + ("group", "=", group_partner.id), ("membership_type_ids", "in", role_code.id), ("is_ended", "=", False), ], ) ] + def _resolve_group_filter(self, group: str): + """ + Resolve a group filter (format: system|value) to the group, or an + empty recordset when no group holds it. + """ + if "|" not in group: + raise InvalidSearchParam('Invalid group format. Expected: {system}|{value} or "none"') + + system, value = group.split("|", 1) + + # Raises AmbiguousIdentifierError when several groups hold it + return resolve_registrant(self.env, system, value, is_group=True) + + def _resolve_role_filter(self, role: str): + """Resolve a membership-role code to its vocabulary code, or an empty recordset""" + return ( + self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search( + [ + ("namespace_uri", "=", "urn:openspp:vocab:group-membership-type"), + ("code", "=", role), + ], + limit=1, + ) + ) + def _parse_member_param(self, member: str) -> list: """ Parse the groups ``member`` parameter (format: Individual/{system}|{value}). From 0a8bda91b9600d38696ca33686fd83ff4f5fd17f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:38 +0800 Subject: [PATCH 25/53] fix(spp_api_v2): reject unknown membership role codes on add/update member (#554 P) --- spp_api_v2/routers/group.py | 18 ++++++++++++++++ spp_api_v2/services/group_service.py | 32 +++++++++++++--------------- spp_api_v2/tests/test_group_api.py | 4 ++-- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index eda337605..2ba2ac803 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -565,6 +565,15 @@ async def add_member( role_coding=role_coding, start_date=request.start_date, ) + except ValidationError as ve: + if "already a member" in str(ve).lower(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(ve)) from ve + # Other client errors (eg unknown role): keep the message + _logger.warning("Validation error adding member to group: %s", ve) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(ve), + ) from ve except Exception as e: _logger.exception("Error adding member to group") # Check for specific errors @@ -700,6 +709,15 @@ async def update_member( start_date=request.start_date, ended_date=request.ended_date, ) + except ValidationError as ve: + if "not a member" in str(ve).lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ve)) from ve + # Other client errors (eg unknown role): keep the message + _logger.warning("Validation error updating member in group: %s", ve) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(ve), + ) from ve except Exception as e: _logger.exception("Error updating member in group") # Check for specific errors diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index d61411cf8..0a0e41c9e 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -605,6 +605,14 @@ def get_membership(self, group, individual): ) ) + @staticmethod + def _unknown_role_message(role_coding): + """Error for a role code that doesn't exist, so a typo isn't silently dropped""" + return ( + f"Unknown membership role: {role_coding.get('system')}|{role_coding.get('code')}. " + "Expected a code from urn:openspp:vocab:group-membership-type, eg head" + ) + def add_member(self, group, individual, role_coding=None, start_date=None) -> dict[str, Any]: """ Add an individual to a group. @@ -651,14 +659,9 @@ def add_member(self, group, individual, role_coding=None, start_date=None) -> di limit=1, ) ) - if vocab_code: - vals["membership_type_ids"] = [Command.link(vocab_code.id)] - else: - _logger.warning( - "Vocabulary code not found for role: %s|%s", - role_coding.get("system"), - role_coding.get("code"), - ) + if not vocab_code: + raise ValidationError(self._unknown_role_message(role_coding)) + vals["membership_type_ids"] = [Command.link(vocab_code.id)] # Create membership membership = ( @@ -775,15 +778,10 @@ def update_member(self, group, individual, role_coding=None, start_date=None, en limit=1, ) ) - if vocab_code: - # Replace existing roles with new role - vals["membership_type_ids"] = [Command.set([vocab_code.id])] - else: - _logger.warning( - "Vocabulary code not found for role: %s|%s", - role_coding.get("system"), - role_coding.get("code"), - ) + if not vocab_code: + raise ValidationError(self._unknown_role_message(role_coding)) + # Replace existing roles with new role + vals["membership_type_ids"] = [Command.set([vocab_code.id])] if vals: membership.sudo().write(vals) # nosemgrep: odoo-sudo-without-context diff --git a/spp_api_v2/tests/test_group_api.py b/spp_api_v2/tests/test_group_api.py index b130e4be2..4d38830d4 100644 --- a/spp_api_v2/tests/test_group_api.py +++ b/spp_api_v2/tests/test_group_api.py @@ -701,7 +701,7 @@ def test_update_member_unknown_role_returns_422(self): "role": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "not_a_role"}]}, } - with mute_logger("odoo.addons.spp_api_v2.routers.group"): + with mute_logger("odoo.addons.spp_api_v2.routers.group", "odoo.http"): response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) self.assertEqual(response.status_code, 422) @@ -719,7 +719,7 @@ def test_add_member_unknown_role_returns_422(self): "role": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "not_a_role"}]}, } - with mute_logger("odoo.addons.spp_api_v2.routers.group"): + with mute_logger("odoo.addons.spp_api_v2.routers.group", "odoo.http"): response = self.url_open(url, data=json.dumps(payload), headers=self._get_headers()) self.assertEqual(response.status_code, 422) From 5b79734bf8979241bc456dfc216512adf898ea5a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:38 +0800 Subject: [PATCH 26/53] fix(spp_api_v2_programs): membership search fails closed; duplicate enrollment is 409 without DB internals (#554 L, O) --- .../routers/program_membership.py | 20 ++++++-- .../services/program_membership_service.py | 47 ++++++++++++++++++- .../tests/test_program_membership_api.py | 30 +++++++----- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index faff36562..c82bd53f8 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -35,7 +35,11 @@ ) from ..schemas.program_membership import ProgramMembership -from ..services.program_membership_service import AmbiguousMembershipError, ProgramMembershipService +from ..services.program_membership_service import ( + AmbiguousMembershipError, + DuplicateMembershipError, + ProgramMembershipService, +) _logger = logging.getLogger(__name__) @@ -350,11 +354,20 @@ async def create_program_membership( except AmbiguousIdentifierError as e: # The beneficiary reference matches more than one registrant await raise_ambiguous_identifier(env, api_client, e, "program_membership") + except DuplicateMembershipError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + except ValidationError as e: + # Client error (unknown program or beneficiary): our own message, no traceback + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Failed to create program membership: {e.args[0]}", + ) from e except Exception as e: + # Unexpected errors can carry database internals: log them, don't return them _logger.exception("Error creating program membership") raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Failed to create program membership: {str(e)}", + detail="Failed to create program membership", ) from e # Return created resource @@ -435,10 +448,11 @@ async def update_program_membership( detail=f"Failed to update program membership: {e.args[0]}", ) from e except Exception as e: + # Unexpected errors can carry database internals: log them, don't return them _logger.exception("Error updating program membership") raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=f"Failed to update program membership: {str(e)}", + detail="Failed to update program membership", ) from e # Return updated resource diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 824af8e24..5fdde1f8c 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -4,6 +4,8 @@ import logging from typing import Any +from psycopg2.errors import UniqueViolation + from odoo.api import Environment from odoo.exceptions import ValidationError @@ -17,6 +19,18 @@ _logger = logging.getLogger(__name__) +INVALID_BENEFICIARY_FILTER = ( + "Invalid beneficiary format. Expected: Individual/{system}|{value} or Group/{system}|{value}" +) +INVALID_PROGRAM_FILTER = "Invalid program format. Expected: Program/{system}|{value}" + + +class DuplicateMembershipError(Exception): + """The beneficiary already has a membership in the program.""" + + def __init__(self): + super().__init__("Beneficiary is already a member of this program") + class AmbiguousMembershipError(Exception): """The beneficiary has several program memberships and no program was given.""" @@ -68,6 +82,11 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: else: # No matching partner found, return empty result domain.append(("id", "=", -1)) + else: + raise ValidationError(INVALID_BENEFICIARY_FILTER) + else: + # A filter that can't be applied must not widen the result to every membership + raise ValidationError(INVALID_BENEFICIARY_FILTER) # Program search program = params.get("program") @@ -85,6 +104,10 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: else: # No matching program found, return empty result domain.append(("id", "=", -1)) + else: + raise ValidationError(INVALID_PROGRAM_FILTER) + else: + raise ValidationError(INVALID_PROGRAM_FILTER) # Status search status = params.get("status") @@ -410,6 +433,18 @@ def _parse_beneficiary_reference(self, reference: str): return self.find_beneficiary(system, value, is_group=reference.startswith("Group/")) + def _find_existing_membership(self, partner_id, program_id): + """Return the beneficiary's membership in the program, if any (the pair is unique)""" + if not partner_id or not program_id: + return None + membership = ( + self.env["spp.program.membership"] # nosemgrep: odoo-sudo-without-context + .sudo() + .with_context(active_test=False) + .search([("partner_id", "=", partner_id), ("program_id", "=", program_id)], limit=1) + ) + return membership or None + def create(self, schema: ProgramMembership, source: str) -> Any: """ Create new ProgramMembership with source tracking. @@ -426,7 +461,17 @@ def create(self, schema: ProgramMembership, source: str) -> Any: # Add source tracking if the model supports it # Base model doesn't have source_system field, but extensions might add it - membership = self.env["spp.program.membership"].sudo().create(vals) # nosemgrep: odoo-sudo-without-context + if self._find_existing_membership(vals.get("partner_id"), vals.get("program_id")): + raise DuplicateMembershipError() + + # The UNIQUE(partner_id, program_id) constraint still catches a concurrent + # create; the savepoint keeps the request's transaction usable after it + try: + with self.env.cr.savepoint(): + # nosemgrep: odoo-sudo-without-context + membership = self.env["spp.program.membership"].sudo().create(vals) + except UniqueViolation as e: + raise DuplicateMembershipError() from e # Log using beneficiary identifier, not database ID partner_id = membership.partner_id.reg_ids[0] if membership.partner_id.reg_ids else None diff --git a/spp_api_v2_programs/tests/test_program_membership_api.py b/spp_api_v2_programs/tests/test_program_membership_api.py index f02e754f8..8fea5b843 100644 --- a/spp_api_v2_programs/tests/test_program_membership_api.py +++ b/spp_api_v2_programs/tests/test_program_membership_api.py @@ -65,6 +65,11 @@ def _get_headers(self, token=None): "Authorization": f"Bearer {token or self.token}", } + def _get_rejected(self, url): + """GET a request the API answers with an error, without the error log it writes""" + with mute_logger("odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"): + return self.url_open(url, headers=self._get_headers()) + def test_read_program_membership_success(self): """GET /ProgramMembership/{id} returns membership""" url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|ENROLL-001" @@ -502,7 +507,7 @@ def test_search_with_invalid_beneficiary_format(self): """Search with unrecognized beneficiary format returns 400 instead of every membership""" url = f"{self.api_base_url}?beneficiary=InvalidFormat" - response = self.url_open(url, headers=self._get_headers()) + response = self._get_rejected(url) # Unrecognized format (not starting with Individual/ or Group/) is rejected self.assertEqual(response.status_code, 400) @@ -512,7 +517,7 @@ def test_search_with_invalid_program_format(self): """Search with unrecognized program format returns 400 instead of every membership""" url = f"{self.api_base_url}?program=InvalidFormat" - response = self.url_open(url, headers=self._get_headers()) + response = self._get_rejected(url) # Unrecognized format (not starting with Program/) is rejected self.assertEqual(response.status_code, 400) @@ -522,7 +527,7 @@ def test_search_with_beneficiary_missing_value_separator(self): """A beneficiary reference without system|value returns 400""" url = f"{self.api_base_url}?beneficiary=Individual/ENROLL-001" - response = self.url_open(url, headers=self._get_headers()) + response = self._get_rejected(url) self.assertEqual(response.status_code, 400) self.assertIn("Invalid beneficiary", json.loads(response.content)["detail"]) @@ -531,7 +536,7 @@ def test_search_with_program_missing_value_separator(self): """A program reference without system|value returns 400""" url = f"{self.api_base_url}?program=Program/test-enrollment-program" - response = self.url_open(url, headers=self._get_headers()) + response = self._get_rejected(url) self.assertEqual(response.status_code, 400) self.assertIn("Invalid program", json.loads(response.content)["detail"]) @@ -631,11 +636,12 @@ def _assert_no_database_internals(self, detail): def test_create_duplicate_membership_returns_409(self): """POST for a beneficiary already in the program returns 409 without database internals""" - response = self.url_open( - self.api_base_url, - data=json.dumps(self._duplicate_payload()), - headers=self._get_headers(), - ) + with mute_logger("odoo.http"): + response = self.url_open( + self.api_base_url, + data=json.dumps(self._duplicate_payload()), + headers=self._get_headers(), + ) self.assertEqual(response.status_code, 409) detail = json.loads(response.content)["detail"] @@ -646,7 +652,7 @@ def test_create_duplicate_membership_race_returns_409(self): """When the pre-check misses (concurrent create), the database constraint still answers 409""" with ( patch.object(ProgramMembershipService, "_find_existing_membership", return_value=None, create=True), - mute_logger("odoo.sql_db", "odoo.addons.spp_api_v2_programs.routers.program_membership"), + mute_logger("odoo.sql_db", "odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), ): response = self.url_open( self.api_base_url, @@ -668,7 +674,7 @@ def test_create_unexpected_error_hides_exception_text(self): with ( patch.object(ProgramMembershipService, "create", side_effect=RuntimeError("internal detail 4242")), - mute_logger("odoo.addons.spp_api_v2_programs.routers.program_membership"), + mute_logger("odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), ): response = self.url_open(self.api_base_url, data=json.dumps(payload), headers=self._get_headers()) @@ -683,7 +689,7 @@ def test_update_unexpected_error_hides_exception_text(self): with ( patch.object(ProgramMembershipService, "update", side_effect=RuntimeError("internal detail 4343")), - mute_logger("odoo.addons.spp_api_v2_programs.routers.program_membership"), + mute_logger("odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), ): response = self.url_put(url, data=json.dumps(payload), headers=self._get_headers()) From 39329fb63bdd895f43b25cdbd001e7806dd974e7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:53 +0800 Subject: [PATCH 27/53] fix(spp_api_v2): consent-filtered pages no longer skip rows or drop next early (#554 M) --- spp_api_v2/routers/group.py | 4 +++- spp_api_v2/routers/individual.py | 4 +++- spp_api_v2/utils/pagination.py | 17 ++++++++++++++--- .../routers/program_membership.py | 4 +++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 2ba2ac803..c026f1a7b 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -248,7 +248,9 @@ def build_url(offset_val: int) -> str: return f"{base_url}?{urlencode(url_params)}" next_offset = db_offset_consumed if consent_was_applied else offset + count - has_more = len(data) >= count + # With consent filtering the page can be cut short while rows remain: + # keep linking the next page until the scan reaches the end + has_more = db_offset_consumed < raw_total if consent_was_applied else len(data) >= count self_url = build_url(offset) next_url = build_url(next_offset) if has_more else None diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index c3222ce2a..92aaa7b3d 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -262,7 +262,9 @@ def build_url(offset_val: int) -> str: # Use the consumed DB offset for next page link when consent filtering next_offset = db_offset_consumed if consent_was_applied else offset + count - has_more = len(individuals_data) >= count + # With consent filtering the page can be cut short while rows remain: + # keep linking the next page until the scan reaches the end + has_more = db_offset_consumed < raw_total if consent_was_applied else len(individuals_data) >= count self_url = build_url(offset) next_url = build_url(next_offset) if has_more else None diff --git a/spp_api_v2/utils/pagination.py b/spp_api_v2/utils/pagination.py index ca3acabd5..02495edc1 100644 --- a/spp_api_v2/utils/pagination.py +++ b/spp_api_v2/utils/pagination.py @@ -5,6 +5,10 @@ _logger = logging.getLogger(__name__) +# The search services return at most this many rows per query, so a batch +# must not ask for more: a short batch is read as the end of the data +MAX_ROWS_PER_QUERY = 100 + def fetch_with_consent( search_function, @@ -32,6 +36,9 @@ def fetch_with_consent( Returns: tuple of (collected_records, database_offset_consumed, raw_total, consent_was_applied) + + database_offset_consumed is the offset just after the last row + examined, so the next page starts at the first row not yet seen. """ collected = [] current_offset = offset @@ -42,11 +49,13 @@ def fetch_with_consent( fetched_so_far = 0 while len(collected) < count and fetched_so_far < max_overfetch: - batch_size = min(count * 2, max_overfetch - fetched_so_far) + batch_size = min(count * 2, max_overfetch - fetched_so_far, MAX_ROWS_PER_QUERY) records, raw_total = search_function(offset=current_offset, limit=batch_size) if not records: break + examined = 0 for record in records: + examined += 1 filtered = consent_filter_function(record) if filtered is not None: collected.append(filtered) @@ -54,8 +63,10 @@ def fetch_with_consent( break else: consent_was_applied = True - fetched_so_far += len(records) - current_offset += len(records) + # Rows after the one that filled the page were not examined: the next + # page must start at them, not skip them + fetched_so_far += examined + current_offset += examined if len(records) < batch_size: break # No more records in DB diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index c82bd53f8..3bb7bd1b9 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -296,7 +296,9 @@ def consent_filter_function(membership): self_url = f"{request.url.path}?{query_string}" next_offset = db_offset_consumed if consent_was_applied else offset + count - has_more = len(resources) >= count + # With consent filtering the page can be cut short while rows remain: + # keep linking the next page until the scan reaches the end + has_more = db_offset_consumed < raw_total if consent_was_applied else len(resources) >= count next_url = None if has_more: From c0dcef7cdf9676576d7c393ff87e7daf11caeff7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 13:54:53 +0800 Subject: [PATCH 28/53] docs(spp_api_v2, spp_api_v2_programs): HISTORY for #554 items K-Q --- spp_api_v2/readme/HISTORY.md | 4 ++++ spp_api_v2_programs/readme/HISTORY.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 45da1d004..8ad15d100 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -6,6 +6,10 @@ - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). - fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. - fix: `GET /Group` applies `_offset` (#554). The group search ignored it, so every page (and every `next` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. +- fix: consent-filtered searches (`GET /Individual`, `GET /Group`) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the `next` link jumped past the rows not yet examined. Pages over 50 records mistook the per-query cap of 100 for the end of the data. A page cut short by the over-fetch safety limit dropped its `next` link although rows remained; the link is now given until the scan reaches the end. +- fix: `GET /Individual?group=…&membership-role=…` requires the role on the membership of that group (#554). Someone who held the role in another group was also returned. +- fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. +- fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. ### 19.0.2.1.1 diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index 08505d267..acc217085 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -5,6 +5,10 @@ - fix: the `Location` header of `POST /ProgramMembership` is URL-encoded and carries `?program=`, so following it returns the created membership (#554). - fix: `PUT /ProgramMembership` accepts its own `ETag`/`meta.versionId` in `If-Match`; it previously compared against a different timestamp format and always returned `409` (#554). - fix: a beneficiary identifier held by more than one registrant is refused on `GET`/`PUT`/`POST /ProgramMembership` (`409`, or `403` for a client that requires consent and may not read every match) and in the `beneficiary` search filter (`409`, or an empty page for such a client), instead of acting on an arbitrary one; `Individual/` and `Group/` beneficiary references resolve only that kind. Membership identifiers, beneficiary references and `Location` use only live (not soft-removed) IDs (#554). +- fix: `GET /ProgramMembership` filters fail closed (#554). A `beneficiary` that isn't `Individual/{system}|{value}` or `Group/{system}|{value}`, or a `program` that isn't `Program/{system}|{value}`, returns `400` instead of being dropped and returning every membership. A well-formed reference that matches nothing still returns an empty page. +- fix: `POST /ProgramMembership` for a beneficiary already in the program returns `409` "Beneficiary is already a member of this program" (#554). It returned `422` with the database error text, including internal record ids. Create and `PUT` errors that aren't client errors now return a generic message; the details are only logged. +- fix: `GET /Program` without the `program:search` or `program:read` scope returns `403` (#554). The `status` query parameter shadowed FastAPI's `status` module, so it returned `500`. +- fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early (#554, see `spp_api_v2`). ### 19.0.1.0.0 From 0c7802d00b375c423a2bb9575c25fa9775ce7e24 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:17:03 +0800 Subject: [PATCH 29/53] test(spp_api_v2, spp_api_v2_programs): review round for #554 J-Q (S1 total oracle, A2 links, A4, A7, A9, A11) --- spp_api_v2/tests/test_consent_paging.py | 141 ++++++++++++++- spp_api_v2/tests/test_individual_api.py | 17 ++ spp_api_v2/tests/test_patch_api.py | 13 ++ .../tests/test_search_filters_fail_closed.py | 34 ++++ spp_api_v2_programs/tests/test_program_api.py | 15 ++ .../tests/test_program_membership_api.py | 170 +++++++++++++++++- 6 files changed, 379 insertions(+), 11 deletions(-) diff --git a/spp_api_v2/tests/test_consent_paging.py b/spp_api_v2/tests/test_consent_paging.py index 463745fca..cf02746e0 100644 --- a/spp_api_v2/tests/test_consent_paging.py +++ b/spp_api_v2/tests/test_consent_paging.py @@ -11,6 +11,7 @@ from odoo.tests.common import BaseCase +from ..utils import pagination from ..utils.pagination import fetch_with_consent from .common import ApiV2HttpTestCase @@ -76,6 +77,136 @@ def test_pages_cover_every_visible_row_exactly_once(self): self.assertEqual(seen, [row for row in rows if row not in denied]) +class TestPageTotalAndNext(BaseCase): + """utils.pagination.page_total_and_next""" + + def test_consent_client_total_is_the_page_size(self): + """Hidden rows are never counted, even when none were met in this window""" + total, next_offset = pagination.page_total_and_next( + returned=1, count=1, offset=0, db_offset_consumed=1, raw_total=6, consent_filtered=True + ) + self.assertEqual(total, 1) + self.assertEqual(next_offset, 1) + + def test_consent_client_past_the_end(self): + """Past the end: nothing returned, total 0, no next, whatever the raw count""" + total, next_offset = pagination.page_total_and_next( + returned=0, count=1, offset=1, db_offset_consumed=1, raw_total=1, consent_filtered=True + ) + self.assertEqual(total, 0) + self.assertIsNone(next_offset) + + def test_consent_client_next_until_the_scan_ends(self): + """A short page links on while rows remain, and not once the scan reached the end""" + self.assertEqual( + pagination.page_total_and_next( + returned=0, count=1, offset=0, db_offset_consumed=3, raw_total=5, consent_filtered=True + )[1], + 3, + ) + self.assertIsNone( + pagination.page_total_and_next( + returned=1, count=1, offset=3, db_offset_consumed=5, raw_total=5, consent_filtered=True + )[1] + ) + + def test_legal_basis_client_unchanged(self): + """Without consent filtering: the real total, next while pages are full""" + self.assertEqual( + pagination.page_total_and_next( + returned=2, count=2, offset=0, db_offset_consumed=2, raw_total=7, consent_filtered=False + ), + (7, 2), + ) + self.assertEqual( + pagination.page_total_and_next( + returned=1, count=2, offset=6, db_offset_consumed=7, raw_total=7, consent_filtered=False + ), + (7, None), + ) + + +class TestConsentTotalNotLeakedAPI(ApiV2HttpTestCase): + """HTTP: the total never counts records the client has no consent for""" + + def setUp(self): + super().setUp() + scopes = [ + {"resource": "individual", "action": "read"}, + {"resource": "group", "action": "read"}, + ] + self.consent_client = self.create_api_client(name="Total Consent Client", scopes=scopes) + self.consent_token = self.generate_jwt_token(self.consent_client) + self.legal_client = self.create_api_client( + name="Total Legal Client", scopes=scopes, require_consent=False, legal_basis="public_task" + ) + self.legal_token = self.generate_jwt_token(self.legal_client) + + def _body(self, url, token): + response = self.url_open(url, headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(response.status_code, 200, response.content) + return json.loads(response.content) + + def _assert_nothing(self, body): + self.assertEqual(body["data"], []) + self.assertEqual(body["meta"]["total"], 0) + self.assertIsNone(body["links"].get("next")) + + def test_individual_hidden_match_past_the_end_is_not_counted(self): + """identifier= of a registrant without consent, _offset=1: same answer as an unknown identifier""" + self.create_test_individual(name="Hidden Total", identifier_value="HT-IND-1") + + hidden = self._body( + f"/api/v2/spp/Individual?identifier={NATIONAL_ID.replace('#', '%23')}|HT-IND-1&_count=1&_offset=1", + self.consent_token, + ) + unknown = self._body( + f"/api/v2/spp/Individual?identifier={NATIONAL_ID.replace('#', '%23')}|HT-NOBODY&_count=1&_offset=1", + self.consent_token, + ) + + self._assert_nothing(hidden) + self._assert_nothing(unknown) + + def test_group_hidden_match_past_the_end_is_not_counted(self): + """identifier= of a group without consent, _offset=1: same answer as an unknown identifier""" + self.create_test_group(name="Hidden Total Group", identifier_value="HT-GRP-1") + + hidden = self._body( + f"/api/v2/spp/Group?identifier={HOUSEHOLD_ID.replace('#', '%23')}|HT-GRP-1&_count=1&_offset=1", + self.consent_token, + ) + + self._assert_nothing(hidden) + + def test_page_filled_before_hidden_rows_does_not_count_them(self): + """A is consented and fills the page; the hidden B, C, D after it are not in the total""" + for letter in "ABCD": + person = self.create_test_individual(name=f"Filled First {letter}", identifier_value=f"FF-{letter}") + if letter == "A": + self.create_consent( + registrant=person, + grantee_partner=self.consent_client.partner_id, + resource_type="individual", + field_access="all", + ) + + body = self._body("/api/v2/spp/Individual?name=Filled+First&_count=1", self.consent_token) + + self.assertEqual([r["identifier"][0]["value"] for r in body["data"]], ["FF-A"]) + self.assertEqual(body["meta"]["total"], 1) + + def test_legal_basis_client_gets_the_real_total(self): + """A client that isn't consent-filtered still gets the exact total""" + for letter in "ABC": + self.create_test_individual(name=f"Legal Total {letter}", identifier_value=f"LT-{letter}") + + body = self._body("/api/v2/spp/Individual?name=Legal+Total&_count=1", self.legal_token) + + self.assertEqual(body["meta"]["total"], 3) + self.assertIsNotNone(body["links"].get("next")) + + class TestConsentPagingAPI(ApiV2HttpTestCase): """HTTP: next links of consent-filtered Individual and Group searches""" @@ -112,10 +243,11 @@ def test_individual_next_page_does_not_skip(self): self._consent(person, "individual") page1, next_url = self._page("/api/v2/spp/Individual?name=Paging+Person&_count=2") - page2, _next = self._page(next_url) + page2, last_next = self._page(next_url) self.assertEqual(page1, ["CP-A", "CP-C"]) self.assertEqual(page2, ["CP-D", "CP-E"]) + self.assertIsNone(last_next, "E is the last row: no link to an empty page") def test_group_next_page_does_not_skip(self): """Group B has no consent: page 1 is A, C and page 2 starts at D""" @@ -125,10 +257,11 @@ def test_group_next_page_does_not_skip(self): self._consent(group, "group") page1, next_url = self._page("/api/v2/spp/Group?name=Paging+Group&_count=2") - page2, _next = self._page(next_url) + page2, last_next = self._page(next_url) self.assertEqual(page1, ["CPG-A", "CPG-C"]) self.assertEqual(page2, ["CPG-D", "CPG-E"]) + self.assertIsNone(last_next, "E is the last row: no link to an empty page") def test_short_page_keeps_next_while_rows_remain(self): """The safety limit cuts the page short: the next link is still given, and leads to E""" @@ -142,9 +275,13 @@ def test_short_page_keeps_next_while_rows_remain(self): self.assertIsNotNone(next_url, "Rows remain, so the client must be told to continue") seen = [] + pages = 1 for _page in range(5): values, next_url = self._page(next_url) + pages += 1 seen.extend(values) if not next_url: break self.assertEqual(seen, ["SP-E"]) + self.assertIsNone(next_url) + self.assertEqual(pages, 2, "Page 2 scans D and E and ends the scan: no third, empty page") diff --git a/spp_api_v2/tests/test_individual_api.py b/spp_api_v2/tests/test_individual_api.py index 8a513b064..617b55a0a 100644 --- a/spp_api_v2/tests/test_individual_api.py +++ b/spp_api_v2/tests/test_individual_api.py @@ -4,6 +4,8 @@ import json from datetime import date +from odoo.tools import mute_logger + from .common import ApiV2HttpTestCase @@ -259,6 +261,21 @@ def test_create_individual_success(self): self.assertIn("location", response.headers) self.assertIn("CREATE-001", response.headers["location"]) + def test_create_individual_gender_from_another_vocabulary_returns_422(self): + """POST /Individual with a gender code from another vocabulary (eg a membership role) returns 422""" + payload = { + "type": "Individual", + "identifier": [{"system": "urn:openspp:vocab:id-type#test_national_id", "value": "CREATE-GENDER-VOCAB"}], + "name": {"given": "Wrong", "family": "Vocabulary"}, + "gender": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "head"}]}, + } + + with mute_logger("odoo.addons.spp_api_v2.routers.individual", "odoo.http"): + response = self.url_open(self.api_base_url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertIn("Invalid gender code", json.loads(response.content)["detail"]) + def test_create_individual_source_tracking(self): """Created individual has source_system set""" payload = { diff --git a/spp_api_v2/tests/test_patch_api.py b/spp_api_v2/tests/test_patch_api.py index 6f1ffafc1..bb92aed5a 100644 --- a/spp_api_v2/tests/test_patch_api.py +++ b/spp_api_v2/tests/test_patch_api.py @@ -327,6 +327,19 @@ def test_patch_individual_unknown_gender_returns_422(self): self.individual.invalidate_recordset() self.assertEqual(self.individual.gender_id, self.gender_female) + def test_patch_individual_gender_from_another_vocabulary_returns_422(self): + """PATCH gender with a code from another vocabulary (eg a membership role) returns 422""" + url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" + payload = {"gender": {"coding": [{"system": "urn:openspp:vocab:group-membership-type", "code": "head"}]}} + + with mute_logger("odoo.addons.spp_api_v2.routers.individual", "odoo.http"): + response = self.url_patch(url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertIn("Invalid gender code", json.loads(response.content)["detail"]) + self.individual.invalidate_recordset() + self.assertEqual(self.individual.gender_id, self.gender_female) + def test_patch_individual_gender_null_clears(self): """PATCH /Individual/{id} with gender null clears it""" url = "/api/v2/spp/Individual/urn:openspp:vocab:id-type%23test_national_id|IND-PATCH-001" diff --git a/spp_api_v2/tests/test_search_filters_fail_closed.py b/spp_api_v2/tests/test_search_filters_fail_closed.py index 82e9bfea0..d3954a780 100644 --- a/spp_api_v2/tests/test_search_filters_fail_closed.py +++ b/spp_api_v2/tests/test_search_filters_fail_closed.py @@ -11,6 +11,7 @@ from datetime import timedelta from odoo import fields +from odoo.tools import mute_logger from ..services import search_service from ..services.search_service import SearchService @@ -227,6 +228,39 @@ def _assert_empty(self, response): self.assertEqual(data["meta"]["total"], 0) self.assertFalse(data.get("data")) + def test_individual_group_and_role_on_the_same_membership(self): + """?group=G1&membership-role=head: a plain member of G1 who heads G2 is not returned""" + plain = self.create_test_individual(name="FC Plain G1 Head G2", identifier_value="FC-API-PLAIN") + head = self.create_test_individual(name="FC Head G1", identifier_value="FC-API-HEAD") + self.create_test_group( + name="FC API G1", identifier_value="FC-API-G1", members=[(plain, None), (head, self.relationship_head)] + ) + self.create_test_group( + name="FC API G2", identifier_value="FC-API-G2", members=[(plain, self.relationship_head)] + ) + for person in (plain, head): + self.create_consent( + registrant=person, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + + response = self._get( + f"/api/v2/spp/Individual?group={HOUSEHOLD_ID.replace('#', '%23')}|FC-API-G1&membership-role=head" + ) + + self.assertEqual(response.status_code, 200, response.text) + values = [r["identifier"][0]["value"] for r in json.loads(response.content)["data"]] + self.assertEqual(values, ["FC-API-HEAD"]) + + def test_huge_offset_is_a_validation_error(self): + """An _offset beyond what the database accepts is a 422, not a server error""" + for resource in ("Individual", "Group"): + with mute_logger("odoo.http"): + response = self._get(f"/api/v2/spp/{resource}?_offset=99999999999999999999") + self.assertEqual(response.status_code, 422, f"{resource}: {response.text[:200]}") + def test_individual_unknown_group_returns_nothing(self): self._assert_empty( self._get("/api/v2/spp/Individual?group=urn:openspp:vocab:id-type%23test_household_id%7CDOES-NOT-EXIST") diff --git a/spp_api_v2_programs/tests/test_program_api.py b/spp_api_v2_programs/tests/test_program_api.py index 98300f7c6..51200a51e 100644 --- a/spp_api_v2_programs/tests/test_program_api.py +++ b/spp_api_v2_programs/tests/test_program_api.py @@ -150,6 +150,21 @@ def test_search_by_status_ended(self): data = json.loads(response.content) self.assertGreater(data["meta"]["total"], 0) + def test_search_status_filter_selects_exactly_one_state(self): + """?status=active and ?status=ended each return only the program in that state""" + self.create_test_program(name="Status Filter Probe Running", state="active") + self.create_test_program(name="Status Filter Probe Closed", state="ended", active=False) + + def names(status): + response = self.url_open( + f"{self.api_base_url}?name=Status+Filter+Probe&status={status}", headers=self._get_headers() + ) + self.assertEqual(response.status_code, 200, response.content) + return [program["name"] for program in json.loads(response.content)["data"]] + + self.assertEqual(names("active"), ["Status Filter Probe Running"]) + self.assertEqual(names("ended"), ["Status Filter Probe Closed"]) + def test_search_by_target_type(self): """Search by targetType filters results""" # Create individual-targeted program diff --git a/spp_api_v2_programs/tests/test_program_membership_api.py b/spp_api_v2_programs/tests/test_program_membership_api.py index 8fea5b843..8e2469163 100644 --- a/spp_api_v2_programs/tests/test_program_membership_api.py +++ b/spp_api_v2_programs/tests/test_program_membership_api.py @@ -11,6 +11,8 @@ from ..services.program_membership_service import ProgramMembershipService +NATIONAL_ID = "urn:openspp:vocab:id-type#test_national_id" + class TestProgramMembershipAPIEndpoints(ApiV2HttpTestCase): """Test ProgramMembership resource HTTP endpoints""" @@ -651,7 +653,7 @@ def test_create_duplicate_membership_returns_409(self): def test_create_duplicate_membership_race_returns_409(self): """When the pre-check misses (concurrent create), the database constraint still answers 409""" with ( - patch.object(ProgramMembershipService, "_find_existing_membership", return_value=None, create=True), + patch.object(ProgramMembershipService, "_find_existing_membership", return_value=None) as pre_check, mute_logger("odoo.sql_db", "odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), ): response = self.url_open( @@ -660,38 +662,188 @@ def test_create_duplicate_membership_race_returns_409(self): headers=self._get_headers(), ) + pre_check.assert_called_once() self.assertEqual(response.status_code, 409) detail = json.loads(response.content)["detail"] self.assertIn("already a member", detail) self._assert_no_database_internals(detail) + # The transaction is still usable after the violation, and no second row was written + self.env.invalidate_all() + self.assertEqual( + self.env["spp.program.membership"].search_count( + [("partner_id", "=", self.individual.id), ("program_id", "=", self.program.id)] + ), + 1, + ) + + def test_create_other_unique_violation_is_not_reported_as_duplicate(self): + """A unique violation on another table during create is a generic error, not 'already a member'""" + new_individual = self.create_test_individual(identifier_value="OTHER-UNIQUE-001") + payload = self._duplicate_payload() + payload["beneficiary"]["reference"] = "Individual/urn:openspp:vocab:id-type#test_national_id|OTHER-UNIQUE-001" + membership_model = type(self.env["spp.program.membership"]) + + def violate_another_constraint(*args, **kwargs): + # Duplicate an existing system parameter key: a real UniqueViolation on ir_config_parameter + self.env.cr.execute( + "INSERT INTO ir_config_parameter (key, value) SELECT key, value FROM ir_config_parameter LIMIT 1" + ) + + with ( + patch.object(membership_model, "create", side_effect=violate_another_constraint), + mute_logger("odoo.sql_db", "odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), + ): + response = self.url_open(self.api_base_url, data=json.dumps(payload), headers=self._get_headers()) + + self.assertEqual(response.status_code, 422) + self.assertEqual(json.loads(response.content)["detail"], "Failed to create program membership") + self.env.invalidate_all() + self.assertFalse(self.env["spp.program.membership"].search([("partner_id", "=", new_individual.id)])) def test_create_unexpected_error_hides_exception_text(self): - """An unexpected create error returns a generic 422, not the exception text""" - new_individual = self.create_test_individual(identifier_value="HIDE-ERR-001") + """An unexpected create error returns a generic 422 and is only logged""" + self.create_test_individual(identifier_value="HIDE-ERR-001") payload = self._duplicate_payload() payload["beneficiary"]["reference"] = "Individual/urn:openspp:vocab:id-type#test_national_id|HIDE-ERR-001" - self.assertTrue(new_individual) with ( patch.object(ProgramMembershipService, "create", side_effect=RuntimeError("internal detail 4242")), - mute_logger("odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), + mute_logger("odoo.http"), + self.assertLogs("odoo.addons.spp_api_v2_programs.routers.program_membership", "ERROR") as logs, ): response = self.url_open(self.api_base_url, data=json.dumps(payload), headers=self._get_headers()) self.assertEqual(response.status_code, 422) - self.assertNotIn("internal detail 4242", json.loads(response.content)["detail"]) + self.assertEqual(json.loads(response.content)["detail"], "Failed to create program membership") + self.assertIn("internal detail 4242", "\n".join(logs.output)) def test_update_unexpected_error_hides_exception_text(self): - """An unexpected PUT error returns a generic 422, not the exception text""" + """An unexpected PUT error returns a generic 422 and is only logged""" url = f"{self.api_base_url}/urn:openspp:vocab:id-type%23test_national_id|ENROLL-001" payload = json.loads(self.url_open(url, headers=self._get_headers()).content) payload["status"] = "paused" with ( patch.object(ProgramMembershipService, "update", side_effect=RuntimeError("internal detail 4343")), - mute_logger("odoo.http", "odoo.addons.spp_api_v2_programs.routers.program_membership"), + mute_logger("odoo.http"), + self.assertLogs("odoo.addons.spp_api_v2_programs.routers.program_membership", "ERROR") as logs, ): response = self.url_put(url, data=json.dumps(payload), headers=self._get_headers()) self.assertEqual(response.status_code, 422) - self.assertNotIn("internal detail 4343", json.loads(response.content)["detail"]) + self.assertEqual(json.loads(response.content)["detail"], "Failed to update program membership") + self.assertIn("internal detail 4343", "\n".join(logs.output)) + + def test_search_unexpected_error_hides_exception_text(self): + """An unexpected search error returns a generic error and is only logged""" + with ( + patch.object(ProgramMembershipService, "search", side_effect=RuntimeError("internal detail 4545")), + mute_logger("odoo.http"), + self.assertLogs("odoo.addons.spp_api_v2_programs.routers.program_membership", "ERROR") as logs, + ): + response = self.url_open(self.api_base_url, headers=self._get_headers()) + + self.assertEqual(response.status_code, 500) + self.assertEqual(json.loads(response.content)["detail"], "Failed to search program memberships") + self.assertIn("internal detail 4545", "\n".join(logs.output)) + + def test_search_huge_offset_is_rejected(self): + """An _offset beyond what the database accepts is a validation error, not a database error text""" + response = self._get_rejected(f"{self.api_base_url}?_offset=99999999999999999999") + + self.assertEqual(response.status_code, 422) + self.assertNotIn("bigint", response.text) + self.assertNotIn("LINE", response.text) + + +class TestProgramMembershipPagingAPI(ApiV2HttpTestCase): + """GET /ProgramMembership next links: followable, consent-aware, and no leaked total""" + + PROGRAM_REF = "Program/urn:openspp:program|paging-program" + + def setUp(self): + super().setUp() + self.program = self.create_test_program(name="Paging Program", target_type="individual") + self.other_program = self.create_test_program(name="Other Paging Program", target_type="individual") + self.client = self.create_api_client( + name="Membership Paging Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "search"}, + ], + ) + self.token = self.generate_jwt_token(self.client) + + def _consent(self, partner): + self.create_consent( + registrant=partner, + grantee_partner=self.client.partner_id, + resource_type="all", + field_access="all", + ) + + def _enroll(self, value, program, consented=True): + partner = self.env["res.partner"].search([("reg_ids.value", "=", value)], limit=1) + if not partner: + partner = self.create_test_individual(identifier_value=value) + if consented: + self._consent(partner) + self.create_test_membership(partner=partner, program=program, state="enrolled") + return partner + + def _get(self, url): + response = self.url_open(url, headers={"Authorization": f"Bearer {self.token}"}) + self.assertEqual(response.status_code, 200, response.content) + return json.loads(response.content) + + def test_next_link_keeps_the_beneficiary_filter(self): + """The '#' in the beneficiary's identifier type is encoded, so following next stays on that beneficiary""" + self._enroll("PAGE-TWO-PROGRAMS", self.program) + self._enroll("PAGE-TWO-PROGRAMS", self.other_program) + self._enroll("PAGE-SOMEONE-ELSE", self.program) + + first = self._get( + f"/api/v2/spp/ProgramMembership?beneficiary=Individual/{NATIONAL_ID.replace('#', '%23')}|PAGE-TWO-PROGRAMS" + "&_count=1" + ) + self.assertIn("%23", first["links"]["self"]) + second = self._get(first["links"]["next"]) + + references = {m["beneficiary"]["reference"] for m in first["data"] + second["data"]} + programs = {m["program"]["reference"] for m in first["data"] + second["data"]} + self.assertEqual(references, {f"Individual/{NATIONAL_ID}|PAGE-TWO-PROGRAMS"}) + self.assertEqual(len(programs), 2) + + def test_consent_filtered_pages_cover_every_visible_membership_once(self): + """Following next visits each consented member exactly once and then stops""" + visible = set() + for number in range(1, 7): + value = f"PAGE-MEMBER-{number}" + self._enroll(value, self.program, consented=number not in (2, 5)) + if number not in (2, 5): + visible.add(f"Individual/{NATIONAL_ID}|{value}") + + seen = [] + url = f"/api/v2/spp/ProgramMembership?program={self.PROGRAM_REF.replace('|', '%7C')}&_count=2" + for _page in range(10): + body = self._get(url) + seen.extend(m["beneficiary"]["reference"] for m in body["data"]) + url = body["links"].get("next") + if not url: + break + + self.assertIsNone(url) + self.assertEqual(sorted(seen), sorted(visible)) + + def test_hidden_beneficiary_past_the_end_is_not_counted(self): + """beneficiary= of someone without consent, _offset=1: no data, total 0, no next""" + self._enroll("PAGE-HIDDEN", self.program, consented=False) + + body = self._get( + f"/api/v2/spp/ProgramMembership?beneficiary=Individual/{NATIONAL_ID.replace('#', '%23')}|PAGE-HIDDEN" + "&_count=1&_offset=1" + ) + + self.assertEqual(body["data"], []) + self.assertEqual(body["meta"]["total"], 0) + self.assertIsNone(body["links"].get("next")) From 502216546d51320283621d8ab9e17086fb51aea9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:32:17 +0800 Subject: [PATCH 30/53] fix(spp_api_v2, spp_api_v2_programs): never count hidden records in search totals; encode membership links; bound _offset (#554 review S1, A2, A11) --- spp_api_v2/routers/group.py | 26 +++---- spp_api_v2/routers/individual.py | 30 ++++---- spp_api_v2/services/consent_service.py | 26 ++++--- spp_api_v2/utils/pagination.py | 33 ++++++-- .../routers/program_membership.py | 77 +++++++++---------- 5 files changed, 108 insertions(+), 84 deletions(-) diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index c026f1a7b..823b65c34 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -43,7 +43,7 @@ from ..services.group_service import GroupService from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService -from ..utils.pagination import fetch_with_consent +from ..utils.pagination import MAX_OFFSET, fetch_with_consent, page_total_and_next from ..utils.registrant_lookup import ( ambiguous_filter_result, lookup_registrant, @@ -163,7 +163,7 @@ async def search_groups( type_: Annotated[str | None, Query(alias="type")] = None, member: Annotated[str | None, Query()] = None, count: Annotated[int, Query(alias="_count", ge=1, le=100)] = 20, - offset: Annotated[int, Query(alias="_offset", ge=0)] = 0, + offset: Annotated[int, Query(alias="_offset", ge=0, le=MAX_OFFSET)] = 0, elements: Annotated[str | None, Query(alias="_elements")] = None, extensions: Annotated[str | None, Query(alias="_extensions")] = None, ): @@ -232,11 +232,16 @@ def consent_filter_function(group): if elements: data = filter_list(data, elements) - # Suppress total when consent filtering is active - if consent_was_applied: - total = len(data) - else: - total = raw_total + # For a consent-filtered client the total is the page size and the next + # link continues while rows remain (see page_total_and_next) + total, next_offset = page_total_and_next( + returned=len(data), + count=count, + offset=offset, + db_offset_consumed=db_offset_consumed, + raw_total=raw_total, + consent_filtered=consent_was_applied or consent_service.is_consent_filtered(api_client), + ) # Build pagination links with proper URL encoding base_url = "/api/v2/spp/Group" @@ -247,13 +252,8 @@ def build_url(offset_val: int) -> str: url_params = {**base_params, "_count": count, "_offset": offset_val} return f"{base_url}?{urlencode(url_params)}" - next_offset = db_offset_consumed if consent_was_applied else offset + count - # With consent filtering the page can be cut short while rows remain: - # keep linking the next page until the scan reaches the end - has_more = db_offset_consumed < raw_total if consent_was_applied else len(data) >= count - self_url = build_url(offset) - next_url = build_url(next_offset) if has_more else None + next_url = build_url(next_offset) if next_offset is not None else None prev_url = build_url(max(0, offset - count)) if offset > 0 else None return create_search_result( diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index 92aaa7b3d..a84a819b2 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -35,7 +35,7 @@ from ..services.individual_service import IndividualService from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService -from ..utils.pagination import fetch_with_consent +from ..utils.pagination import MAX_OFFSET, fetch_with_consent, page_total_and_next from ..utils.registrant_lookup import ambiguous_filter_result, lookup_registrant from .dependencies import check_individual_access, parse_identifier @@ -160,7 +160,7 @@ async def search_individuals( membership_role: Annotated[str | None, Query(alias="membership-role")] = None, last_updated: Annotated[str | None, Query(alias="_lastUpdated")] = None, count: Annotated[int, Query(alias="_count", ge=1, le=100)] = 20, - offset: Annotated[int, Query(alias="_offset", ge=0)] = 0, + offset: Annotated[int, Query(alias="_offset", ge=0, le=MAX_OFFSET)] = 0, sort: Annotated[str | None, Query(alias="_sort")] = None, elements: Annotated[str | None, Query(alias="_elements")] = None, extensions: Annotated[str | None, Query(alias="_extensions")] = None, @@ -243,13 +243,17 @@ def consent_filter_function(partner): if elements: individuals_data = filter_list(individuals_data, elements) - # When consent filtering is active, suppress the exact total to - # avoid leaking the count of denied records. Clients detect - # end-of-results when they receive fewer than `count` records. - if consent_was_applied: - total = len(individuals_data) - else: - total = raw_total + # For a consent-filtered client the total is the page size and the next + # link continues while rows remain: clients follow `next` until it is + # null, a short or empty page is not the end of the results + total, next_offset = page_total_and_next( + returned=len(individuals_data), + count=count, + offset=offset, + db_offset_consumed=db_offset_consumed, + raw_total=raw_total, + consent_filtered=consent_was_applied or consent_service.is_consent_filtered(api_client), + ) # Build pagination links with proper URL encoding base_url = "/api/v2/spp/Individual" @@ -260,14 +264,8 @@ def build_url(offset_val: int) -> str: url_params = {**base_params, "_count": count, "_offset": offset_val} return f"{base_url}?{urlencode(url_params)}" - # Use the consumed DB offset for next page link when consent filtering - next_offset = db_offset_consumed if consent_was_applied else offset + count - # With consent filtering the page can be cut short while rows remain: - # keep linking the next page until the scan reaches the end - has_more = db_offset_consumed < raw_total if consent_was_applied else len(individuals_data) >= count - self_url = build_url(offset) - next_url = build_url(next_offset) if has_more else None + next_url = build_url(next_offset) if next_offset is not None else None prev_url = build_url(max(0, offset - count)) if offset > 0 else None return create_search_result( diff --git a/spp_api_v2/services/consent_service.py b/spp_api_v2/services/consent_service.py index 493ce52dc..c993161e7 100644 --- a/spp_api_v2/services/consent_service.py +++ b/spp_api_v2/services/consent_service.py @@ -9,6 +9,17 @@ _logger = logging.getLogger(__name__) +# Legal bases that allow processing without the registrant's consent +# (GDPR Article 6); clients with any other basis are consent-filtered +NON_CONSENT_BASES = ( + "legal_obligation", + "vital_interest", + "public_interest", + "public_task", + "contract", + "legitimate_interest", +) + class ConsentService: """Service for applying consent-based filtering to API responses""" @@ -19,6 +30,11 @@ def __init__(self, env: Environment): self._ip_address = None self._user_agent = None + @staticmethod + def is_consent_filtered(api_client) -> bool: + """Whether responses to this client are filtered by registrant consent""" + return api_client.legal_basis not in NON_CONSENT_BASES + def set_request_context( self, request_id: str | None = None, @@ -107,15 +123,7 @@ def filter_response( # - public_task: Official authority (e.g., government agencies) # - contract: Contractual necessity (still needs a contract, not individual consent) # - legitimate_interest: Legitimate business interest (requires balancing test) - non_consent_bases = ( - "legal_obligation", - "vital_interest", - "public_interest", - "public_task", - "contract", - "legitimate_interest", - ) - if api_client.legal_basis in non_consent_bases: + if not self.is_consent_filtered(api_client): # Still apply client scope filtering for field-level access control filtered = self._apply_client_scope_filter(api_client, resource_type, data) return filtered diff --git a/spp_api_v2/utils/pagination.py b/spp_api_v2/utils/pagination.py index 02495edc1..106834d18 100644 --- a/spp_api_v2/utils/pagination.py +++ b/spp_api_v2/utils/pagination.py @@ -9,6 +9,30 @@ # must not ask for more: a short batch is read as the end of the data MAX_ROWS_PER_QUERY = 100 +# Largest _offset PostgreSQL accepts (bigint); larger values are rejected as +# request validation errors instead of reaching the database +MAX_OFFSET = 2**63 - 1 + + +def page_total_and_next(returned, count, offset, db_offset_consumed, raw_total, consent_filtered): + """ + Return the (total, next_offset) to publish for a search page. + + For a client subject to consent filtering the total is the number of + records returned, whether or not this page met a hidden record: the raw + total counts records the client may not see, and a page past the end + would otherwise reveal whether a hidden match exists. The next page + starts after the last row examined and is given while rows remain, even + when the page came back short. + + next_offset is None on the last page. + """ + if consent_filtered: + next_offset = db_offset_consumed if db_offset_consumed < raw_total else None + return returned, next_offset + next_offset = offset + count if returned >= count else None + return raw_total, next_offset + def fetch_with_consent( search_function, @@ -20,11 +44,10 @@ def fetch_with_consent( Fetch records with consent filtering, over-fetching to fill pages. When consent filtering removes records from a page, this function - fetches additional records to fill the requested page size. This - prevents clients from seeing short pages due to consent denials. - - To prevent leaking the count of denied records, the total is - suppressed when any consent filtering occurs. + fetches additional records to fill the requested page size, up to a + safety limit of 3x the page size. A page can therefore still come back + short, or empty, while rows remain: clients follow `next` until it is + null (see page_total_and_next for the total and next offset to publish). Args: search_function: callable(offset, limit) -> (records, total) diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index 3bb7bd1b9..004bf1885 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -3,7 +3,7 @@ import logging from typing import Annotated -from urllib.parse import quote +from urllib.parse import quote, urlencode from odoo.api import Environment from odoo.exceptions import ValidationError @@ -13,7 +13,7 @@ from odoo.addons.spp_api_v2.schemas.search_result import SearchResult, create_search_result from odoo.addons.spp_api_v2.services.consent_service import ConsentService from odoo.addons.spp_api_v2.services.registrant_resolver import AmbiguousIdentifierError -from odoo.addons.spp_api_v2.utils.pagination import fetch_with_consent +from odoo.addons.spp_api_v2.utils.pagination import MAX_OFFSET, fetch_with_consent, page_total_and_next from odoo.addons.spp_api_v2.utils.registrant_lookup import ( ambiguous_filter_result, consent_denied, @@ -202,7 +202,7 @@ async def search_program_memberships( program: Annotated[str | None, Query()] = None, status_: Annotated[str | None, Query(alias="status")] = None, count: Annotated[int, Query(alias="_count", ge=1, le=100)] = 20, - offset: Annotated[int, Query(alias="_offset", ge=0)] = 0, + offset: Annotated[int, Query(alias="_offset", ge=0, le=MAX_OFFSET)] = 0, ): """ Search for program memberships. @@ -243,11 +243,18 @@ def search_function(offset, limit): except AmbiguousIdentifierError as e: # The beneficiary filter matches more than one registrant return ambiguous_filter_result(env, api_client, e, env["spp.program.membership"], "program_membership") - except Exception as e: - _logger.warning("Error in program membership search: %s", e) + except ValidationError as e: + # A filter the API can't apply (malformed reference): our own message raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid search parameters: {e}", + detail=f"Invalid search parameters: {e.args[0]}", + ) from e + except Exception as e: + # Unexpected errors can carry database internals: log them, don't return them + _logger.exception("Error in program membership search") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to search program memberships", ) from e def consent_filter_function(membership): @@ -275,45 +282,33 @@ def consent_filter_function(membership): search_function, consent_filter_function, count, offset ) - # Suppress total when consent filtering is active - if consent_was_applied: - total = len(resources) - else: - total = raw_total + # For a consent-filtered client the total is the page size and the next + # link continues while rows remain (see page_total_and_next) + total, next_offset = page_total_and_next( + returned=len(resources), + count=count, + offset=offset, + db_offset_consumed=db_offset_consumed, + raw_total=raw_total, + consent_filtered=consent_was_applied or consent_service.is_consent_filtered(api_client), + ) - # Build pagination links - url_params = {} + # Build pagination links; references carry '#' and '|', so they are + # URL-encoded: a raw '#' would cut the link short at the fragment + base_params = {} if beneficiary: - url_params["beneficiary"] = beneficiary + base_params["beneficiary"] = beneficiary if program: - url_params["program"] = program + base_params["program"] = program if status_: - url_params["status"] = status_ - url_params["_count"] = str(count) - url_params["_offset"] = str(offset) - - query_string = "&".join(f"{k}={v}" for k, v in url_params.items()) - self_url = f"{request.url.path}?{query_string}" - - next_offset = db_offset_consumed if consent_was_applied else offset + count - # With consent filtering the page can be cut short while rows remain: - # keep linking the next page until the scan reaches the end - has_more = db_offset_consumed < raw_total if consent_was_applied else len(resources) >= count - - next_url = None - if has_more: - next_params = url_params.copy() - next_params["_offset"] = str(next_offset) - next_query_string = "&".join(f"{k}={v}" for k, v in next_params.items()) - next_url = f"{request.url.path}?{next_query_string}" - - prev_url = None - if offset > 0: - prev_offset = max(0, offset - count) - prev_params = url_params.copy() - prev_params["_offset"] = str(prev_offset) - prev_query_string = "&".join(f"{k}={v}" for k, v in prev_params.items()) - prev_url = f"{request.url.path}?{prev_query_string}" + base_params["status"] = status_ + + def build_url(offset_val: int) -> str: + return f"{request.url.path}?{urlencode({**base_params, '_count': count, '_offset': offset_val})}" + + self_url = build_url(offset) + next_url = build_url(next_offset) if next_offset is not None else None + prev_url = build_url(max(0, offset - count)) if offset > 0 else None return create_search_result( data=resources, From f260c8df5cc556cd8870a9f1a9cb8a706e939cbb Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:32:17 +0800 Subject: [PATCH 31/53] fix(spp_api_v2, spp_api_v2_programs): 409 only for the membership constraint; shared row cap; role message (#554 review A4, A8, A10) --- spp_api_v2/services/group_service.py | 11 ++++++----- spp_api_v2/services/search_service.py | 5 +++-- .../services/program_membership_service.py | 15 ++++++++++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index 0a0e41c9e..dd95fabad 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -605,12 +605,13 @@ def get_membership(self, group, individual): ) ) - @staticmethod - def _unknown_role_message(role_coding): + def _unknown_role_message(self, role_coding): """Error for a role code that doesn't exist, so a typo isn't silently dropped""" - return ( - f"Unknown membership role: {role_coding.get('system')}|{role_coding.get('code')}. " - "Expected a code from urn:openspp:vocab:group-membership-type, eg head" + return self.env._( + "Unknown membership role: %(system)s|%(code)s. Roles are vocabulary codes, " + "eg urn:openspp:vocab:group-membership-type|head", + system=role_coding.get("system"), + code=role_coding.get("code"), ) def add_member(self, group, individual, role_coding=None, start_date=None) -> dict[str, Any]: diff --git a/spp_api_v2/services/search_service.py b/spp_api_v2/services/search_service.py index 45041dc2d..6c430e030 100644 --- a/spp_api_v2/services/search_service.py +++ b/spp_api_v2/services/search_service.py @@ -8,6 +8,7 @@ from odoo.api import Environment from odoo.osv import expression +from ..utils.pagination import MAX_ROWS_PER_QUERY from .registrant_resolver import LIVE_ID, resolve_registrant _logger = logging.getLogger(__name__) @@ -103,7 +104,7 @@ def search_individuals(self, params: dict[str, Any]) -> tuple[list, int]: _logger.warning("Invalid _lastId parameter: %s, skipping cursor pagination", last_id) # Apply pagination - limit = min(int(params.get("_count", 20)), 100) # Max 100 + limit = min(int(params.get("_count", 20)), MAX_ROWS_PER_QUERY) offset = int(params.get("_offset", 0)) # Apply sorting - always include id for consistent cursor pagination @@ -156,7 +157,7 @@ def search_groups(self, params: dict[str, Any]) -> tuple[list, int]: except (ValueError, TypeError): _logger.warning("Invalid _lastId parameter: %s, skipping cursor pagination", last_id) - limit = min(int(params.get("_count", 20)), 100) + limit = min(int(params.get("_count", 20)), MAX_ROWS_PER_QUERY) offset = int(params.get("_offset", 0)) order = self._parse_sort_param(params.get("_sort")) if "id" not in order.lower(): diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 5fdde1f8c..7f248a472 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -14,6 +14,7 @@ primary_registry_id, resolve_registrant, ) +from odoo.addons.spp_api_v2.utils.pagination import MAX_ROWS_PER_QUERY from ..schemas.program_membership import ProgramMembership @@ -24,6 +25,10 @@ ) INVALID_PROGRAM_FILTER = "Invalid program format. Expected: Program/{system}|{value}" +# PostgreSQL name of spp.program.membership's UNIQUE(partner_id, program_id) +# constraint (_unique_partner_program) +MEMBERSHIP_UNIQUE_CONSTRAINT = "spp_program_membership_unique_partner_program" + class DuplicateMembershipError(Exception): """The beneficiary already has a membership in the program.""" @@ -115,7 +120,7 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: domain.append(("state", "=", status)) # Execute search - count = min(int(params.get("_count", 20)), 100) + count = min(int(params.get("_count", 20)), MAX_ROWS_PER_QUERY) offset = int(params.get("_offset", 0)) Membership = self.env["spp.program.membership"] @@ -437,6 +442,8 @@ def _find_existing_membership(self, partner_id, program_id): """Return the beneficiary's membership in the program, if any (the pair is unique)""" if not partner_id or not program_id: return None + # The API runs as the public user; the pre-check must see every row the + # UNIQUE constraint sees, including archived beneficiaries membership = ( self.env["spp.program.membership"] # nosemgrep: odoo-sudo-without-context .sudo() @@ -468,9 +475,15 @@ def create(self, schema: ProgramMembership, source: str) -> Any: # create; the savepoint keeps the request's transaction usable after it try: with self.env.cr.savepoint(): + # Writes as the public API user; access is checked by the API scopes # nosemgrep: odoo-sudo-without-context membership = self.env["spp.program.membership"].sudo().create(vals) except UniqueViolation as e: + # Only the membership's own constraint means "already a member"; + # a violation elsewhere (another table written during create) is + # an unexpected error + if e.diag.constraint_name != MEMBERSHIP_UNIQUE_CONSTRAINT: + raise raise DuplicateMembershipError() from e # Log using beneficiary identifier, not database ID From da2815fde9d744ee1b9de64ca7e46e2492610d1c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:32:17 +0800 Subject: [PATCH 32/53] fix(spp_api_v2): gender accepts only ISO 5218 codes on create and PATCH (#554 review A9) --- spp_api_v2/services/individual_service.py | 72 ++++++++++------------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/spp_api_v2/services/individual_service.py b/spp_api_v2/services/individual_service.py index 0b7daccca..83a63bd2b 100644 --- a/spp_api_v2/services/individual_service.py +++ b/spp_api_v2/services/individual_service.py @@ -24,6 +24,9 @@ _logger = logging.getLogger(__name__) +# The vocabulary gender codes come from (gender_id's domain) +GENDER_SYSTEM = "urn:iso:std:iso:5218" + class IndividualService: """Service for Individual resource CRUD and mapping""" @@ -31,6 +34,30 @@ class IndividualService: def __init__(self, env: Environment): self.env = env + def _resolve_gender_code(self, system, code): + """ + Resolve a gender coding to its vocabulary code. + + Only codes of the ISO 5218 vocabulary are genders: an unknown code, or + a code from another vocabulary (eg a membership role), is a + ValidationError. + """ + gender_code = self.env["spp.vocabulary.code"] + if system == GENDER_SYSTEM: + # The API runs as the public user; vocabularies are public reference data + gender_code = ( + self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context + .sudo() + .search([("namespace_uri", "=", GENDER_SYSTEM), ("code", "=", code)], limit=1) + ) + if not gender_code: + raise ValidationError( + f"Invalid gender code: system='{system}', code='{code}'. " + f"Expected system '{GENDER_SYSTEM}' with codes: " + f"0 (Not Known), 1 (Male), 2 (Female), 9 (Not Applicable)." + ) + return gender_code + def find_by_identifier(self, system_uri: str, value: str): """ Lookup partner by external identifier. @@ -324,26 +351,7 @@ def from_api_schema(self, schema: Individual) -> dict[str, Any]: # Gender - CRITICAL: Find gender_id by namespace_uri + code if schema.gender and schema.gender.coding: gender_coding = schema.gender.coding[0] - gender_code = ( - self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("namespace_uri", "=", gender_coding.system), - ("code", "=", gender_coding.code), - ], - limit=1, - ) - ) - if gender_code: - vals["gender_id"] = gender_code.id - else: - # Gender code not found - raise validation error with helpful message - raise ValidationError( - f"Invalid gender code: system='{gender_coding.system}', code='{gender_coding.code}'. " - f"Expected system 'urn:iso:std:iso:5218' with codes: " - f"0 (Not Known), 1 (Male), 2 (Female), 9 (Not Applicable)." - ) + vals["gender_id"] = self._resolve_gender_code(gender_coding.system, gender_coding.code).id # Telecom for contact in schema.telecom or []: @@ -581,26 +589,10 @@ def partial_update(self, partner, patch: IndividualPatch, source: str) -> Any: if gender: if gender.get("coding"): gender_coding = gender["coding"][0] - gender_code = ( - self.env["spp.vocabulary.code"] # nosemgrep: odoo-sudo-without-context - .sudo() - .search( - [ - ("namespace_uri", "=", gender_coding.get("system")), - ("code", "=", gender_coding.get("code")), - ], - limit=1, - ) - ) - if not gender_code: - # Same message as create: an unknown code is an error, not a no-op - raise ValidationError( - f"Invalid gender code: system='{gender_coding.get('system')}', " - f"code='{gender_coding.get('code')}'. " - f"Expected system 'urn:iso:std:iso:5218' with codes: " - f"0 (Not Known), 1 (Male), 2 (Female), 9 (Not Applicable)." - ) - vals["gender_id"] = gender_code.id + # As on create: an unknown code is an error, not a no-op + vals["gender_id"] = self._resolve_gender_code( + gender_coding.get("system"), gender_coding.get("code") + ).id else: # RFC 7396: null clears the field vals["gender_id"] = False From 664386214899b0cf7de7ffbdc2539093d463b4e7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:32:17 +0800 Subject: [PATCH 33/53] docs(spp_api_v2, spp_api_v2_programs): HISTORY for the #554 review round --- spp_api_v2/readme/HISTORY.md | 8 +++++--- spp_api_v2_programs/readme/HISTORY.md | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 8ad15d100..fb18557e4 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -6,10 +6,12 @@ - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). - fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. - fix: `GET /Group` applies `_offset` (#554). The group search ignored it, so every page (and every `next` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. -- fix: consent-filtered searches (`GET /Individual`, `GET /Group`) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the `next` link jumped past the rows not yet examined. Pages over 50 records mistook the per-query cap of 100 for the end of the data. A page cut short by the over-fetch safety limit dropped its `next` link although rows remained; the link is now given until the scan reaches the end. +- fix: consent-filtered searches (`GET /Individual`, `GET /Group`) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the `next` link jumped past the rows not yet examined. Pages over 50 records mistook the per-query cap of 100 for the end of the data. A page cut short by the over-fetch safety limit dropped its `next` link although rows remained; the link is now given until the scan reaches the end. **Clients must follow `next` until it is null: a page can be short, or empty, and still have a `next` link.** A consent-filtered page that ends on the last row no longer links to an empty page. +- fix(security): the search `total` never counts records the client may not see (#554). For a client whose legal basis requires consent, `meta.total` is the number of records returned on every page. It was hidden only when this page met a hidden record, so a page past the end (`?identifier=…&_offset=1`) or one filled before any hidden row returned the full count, which revealed whether a registrant without consent exists and how many records match. Clients with a legal basis that doesn't require consent still get the exact total. +- fix: `_offset` values beyond what the database accepts return `422` instead of a server error (#554). - fix: `GET /Individual?group=…&membership-role=…` requires the role on the membership of that group (#554). Someone who held the role in another group was also returned. -- fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. -- fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. +- fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. On create and `PATCH`, only codes of the `urn:iso:std:iso:5218` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns `422`. +- fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. For someone who is already a member, `$add-member` still answers `409` first. Other validation errors on these two endpoints now return their message with the `422` (eg "Only one Head is allowed per group", "End Date cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". ### 19.0.2.1.1 diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index acc217085..2430f3900 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -8,7 +8,9 @@ - fix: `GET /ProgramMembership` filters fail closed (#554). A `beneficiary` that isn't `Individual/{system}|{value}` or `Group/{system}|{value}`, or a `program` that isn't `Program/{system}|{value}`, returns `400` instead of being dropped and returning every membership. A well-formed reference that matches nothing still returns an empty page. - fix: `POST /ProgramMembership` for a beneficiary already in the program returns `409` "Beneficiary is already a member of this program" (#554). It returned `422` with the database error text, including internal record ids. Create and `PUT` errors that aren't client errors now return a generic message; the details are only logged. - fix: `GET /Program` without the `program:search` or `program:read` scope returns `403` (#554). The `status` query parameter shadowed FastAPI's `status` module, so it returned `500`. -- fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early (#554, see `spp_api_v2`). +- fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early, and `meta.total` never counts memberships the client may not see (#554, see `spp_api_v2`); it revealed how many memberships a beneficiary without consent has. +- fix: `GET /ProgramMembership` `self`/`next`/`prev` links are URL-encoded (#554). The `#` in a beneficiary's identifier type cut the link short, so following `next` lost the beneficiary filter. +- fix: an unexpected error in `GET /ProgramMembership` returns `500` "Failed to search program memberships" instead of `400` with the error text (#554); malformed filters are still `400`. An `_offset` beyond what the database accepts returns `422`. ### 19.0.1.0.0 From edd89d714eb14788d14bed7ede5d740c0e134814 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:50:12 +0800 Subject: [PATCH 34/53] test(spp_api_v2): legal-basis clients keep the exact GET /Group total (#554 review R5) --- spp_api_v2/tests/test_consent_paging.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spp_api_v2/tests/test_consent_paging.py b/spp_api_v2/tests/test_consent_paging.py index cf02746e0..ee40c2e4f 100644 --- a/spp_api_v2/tests/test_consent_paging.py +++ b/spp_api_v2/tests/test_consent_paging.py @@ -206,6 +206,16 @@ def test_legal_basis_client_gets_the_real_total(self): self.assertEqual(body["meta"]["total"], 3) self.assertIsNotNone(body["links"].get("next")) + def test_legal_basis_client_gets_the_real_group_total(self): + """GET /Group: a client that isn't consent-filtered still gets the exact total""" + for letter in "ABC": + self.create_test_group(name=f"Legal Group Total {letter}", identifier_value=f"LGT-{letter}") + + body = self._body("/api/v2/spp/Group?name=Legal+Group+Total&_count=1", self.legal_token) + + self.assertEqual(body["meta"]["total"], 3) + self.assertIsNotNone(body["links"].get("next")) + class TestConsentPagingAPI(ApiV2HttpTestCase): """HTTP: next links of consent-filtered Individual and Group searches""" From 038745c52dda8e745c5eb51a41aa616536d342be Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:50:12 +0800 Subject: [PATCH 35/53] docs(spp_api_v2, spp_api_v2_programs): HISTORY states only the total channel is closed (#554 review R1) --- spp_api_v2/readme/HISTORY.md | 2 +- spp_api_v2_programs/readme/HISTORY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index fb18557e4..ba0df9aa0 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -7,7 +7,7 @@ - fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. - fix: `GET /Group` applies `_offset` (#554). The group search ignored it, so every page (and every `next` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. - fix: consent-filtered searches (`GET /Individual`, `GET /Group`) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the `next` link jumped past the rows not yet examined. Pages over 50 records mistook the per-query cap of 100 for the end of the data. A page cut short by the over-fetch safety limit dropped its `next` link although rows remained; the link is now given until the scan reaches the end. **Clients must follow `next` until it is null: a page can be short, or empty, and still have a `next` link.** A consent-filtered page that ends on the last row no longer links to an empty page. -- fix(security): the search `total` never counts records the client may not see (#554). For a client whose legal basis requires consent, `meta.total` is the number of records returned on every page. It was hidden only when this page met a hidden record, so a page past the end (`?identifier=…&_offset=1`) or one filled before any hidden row returned the full count, which revealed whether a registrant without consent exists and how many records match. Clients with a legal basis that doesn't require consent still get the exact total. +- fix(security): the search `total` never counts records the client may not see (#554). For a client whose legal basis requires consent, `meta.total` is the number of records returned on every page. It was hidden only when this page met a hidden record, so a page past the end (`?identifier=…&_offset=1`) or one filled before any hidden row returned the full count, including records the client may not see. Clients with a legal basis that doesn't require consent still get the exact total. The `next` offsets are still database positions, so hidden rows can be counted from them; see the follow-up issue. - fix: `_offset` values beyond what the database accepts return `422` instead of a server error (#554). - fix: `GET /Individual?group=…&membership-role=…` requires the role on the membership of that group (#554). Someone who held the role in another group was also returned. - fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. On create and `PATCH`, only codes of the `urn:iso:std:iso:5218` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns `422`. diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index 2430f3900..3c60f4f1a 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -8,7 +8,7 @@ - fix: `GET /ProgramMembership` filters fail closed (#554). A `beneficiary` that isn't `Individual/{system}|{value}` or `Group/{system}|{value}`, or a `program` that isn't `Program/{system}|{value}`, returns `400` instead of being dropped and returning every membership. A well-formed reference that matches nothing still returns an empty page. - fix: `POST /ProgramMembership` for a beneficiary already in the program returns `409` "Beneficiary is already a member of this program" (#554). It returned `422` with the database error text, including internal record ids. Create and `PUT` errors that aren't client errors now return a generic message; the details are only logged. - fix: `GET /Program` without the `program:search` or `program:read` scope returns `403` (#554). The `status` query parameter shadowed FastAPI's `status` module, so it returned `500`. -- fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early, and `meta.total` never counts memberships the client may not see (#554, see `spp_api_v2`); it revealed how many memberships a beneficiary without consent has. +- fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early, and `meta.total` never counts memberships the client may not see (#554, see `spp_api_v2`). - fix: `GET /ProgramMembership` `self`/`next`/`prev` links are URL-encoded (#554). The `#` in a beneficiary's identifier type cut the link short, so following `next` lost the beneficiary filter. - fix: an unexpected error in `GET /ProgramMembership` returns `500` "Failed to search program memberships" instead of `400` with the error text (#554); malformed filters are still `400`. An `_offset` beyond what the database accepts returns `422`. From 5d1f80db5efe9668602dcbb7b1a34b4022969859 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 14:57:09 +0800 Subject: [PATCH 36/53] docs(spp_api_v2, spp_api_v2_programs): regenerate README from CI (#554) --- spp_api_v2/README.rst | 39 +++++++++++++++++++ spp_api_v2/static/description/index.html | 39 +++++++++++++++++++ spp_api_v2_programs/README.rst | 26 +++++++++++++ .../static/description/index.html | 26 +++++++++++++ 4 files changed, 130 insertions(+) diff --git a/spp_api_v2/README.rst b/spp_api_v2/README.rst index 6370a1e3b..9d5417092 100644 --- a/spp_api_v2/README.rst +++ b/spp_api_v2/README.rst @@ -207,6 +207,45 @@ Changelog page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. +- fix: consent-filtered searches (``GET /Individual``, ``GET /Group``) + no longer skip records or stop early (#554). When the page filled + partway through a fetched batch, the ``next`` link jumped past the + rows not yet examined. Pages over 50 records mistook the per-query cap + of 100 for the end of the data. A page cut short by the over-fetch + safety limit dropped its ``next`` link although rows remained; the + link is now given until the scan reaches the end. **Clients must + follow ``next`` until it is null: a page can be short, or empty, and + still have a ``next`` link.** A consent-filtered page that ends on the + last row no longer links to an empty page. +- fix(security): the search ``total`` never counts records the client + may not see (#554). For a client whose legal basis requires consent, + ``meta.total`` is the number of records returned on every page. It was + hidden only when this page met a hidden record, so a page past the end + (``?identifier=…&_offset=1``) or one filled before any hidden row + returned the full count, including records the client may not see. + Clients with a legal basis that doesn't require consent still get the + exact total. The ``next`` offsets are still database positions, so + hidden rows can be counted from them; see the follow-up issue. +- fix: ``_offset`` values beyond what the database accepts return + ``422`` instead of a server error (#554). +- fix: ``GET /Individual?group=…&membership-role=…`` requires the role + on the membership of that group (#554). Someone who held the role in + another group was also returned. +- fix: ``PATCH /Individual`` updates ``gender`` (#554). The vocabulary + lookup ran without the privileges create uses, so every gender change + returned ``422 "Failed to patch individual"``. An unknown gender code + now returns ``422`` naming the code, as create does, instead of being + ignored. On create and ``PATCH``, only codes of the + ``urn:iso:std:iso:5218`` vocabulary are accepted: a code from another + vocabulary (eg a membership role) returns ``422``. +- fix: ``$add-member`` and ``PATCH /Group/{id}/member/{id}`` reject a + role code that doesn't exist with ``422`` naming it (#554), instead of + adding or keeping the member without the role. For someone who is + already a member, ``$add-member`` still answers ``409`` first. Other + validation errors on these two endpoints now return their message with + the ``422`` (eg "Only one Head is allowed per group", "End Date cannot + be earlier than Start Date") instead of a generic "Failed to add + member" / "Failed to update member". 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_api_v2/static/description/index.html b/spp_api_v2/static/description/index.html index 8797988c1..a97e25023 100644 --- a/spp_api_v2/static/description/index.html +++ b/spp_api_v2/static/description/index.html @@ -590,6 +590,45 @@

19.0.2.2.0

page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. +
  • fix: consent-filtered searches (GET /Individual, GET /Group) +no longer skip records or stop early (#554). When the page filled +partway through a fetched batch, the next link jumped past the +rows not yet examined. Pages over 50 records mistook the per-query cap +of 100 for the end of the data. A page cut short by the over-fetch +safety limit dropped its next link although rows remained; the +link is now given until the scan reaches the end. Clients must +follow ``next`` until it is null: a page can be short, or empty, and +still have a ``next`` link. A consent-filtered page that ends on the +last row no longer links to an empty page.
  • +
  • fix(security): the search total never counts records the client +may not see (#554). For a client whose legal basis requires consent, +meta.total is the number of records returned on every page. It was +hidden only when this page met a hidden record, so a page past the end +(?identifier=…&_offset=1) or one filled before any hidden row +returned the full count, including records the client may not see. +Clients with a legal basis that doesn’t require consent still get the +exact total. The next offsets are still database positions, so +hidden rows can be counted from them; see the follow-up issue.
  • +
  • fix: _offset values beyond what the database accepts return +422 instead of a server error (#554).
  • +
  • fix: GET /Individual?group=…&membership-role=… requires the role +on the membership of that group (#554). Someone who held the role in +another group was also returned.
  • +
  • fix: PATCH /Individual updates gender (#554). The vocabulary +lookup ran without the privileges create uses, so every gender change +returned 422 "Failed to patch individual". An unknown gender code +now returns 422 naming the code, as create does, instead of being +ignored. On create and PATCH, only codes of the +urn:iso:std:iso:5218 vocabulary are accepted: a code from another +vocabulary (eg a membership role) returns 422.
  • +
  • fix: $add-member and PATCH /Group/{id}/member/{id} reject a +role code that doesn’t exist with 422 naming it (#554), instead of +adding or keeping the member without the role. For someone who is +already a member, $add-member still answers 409 first. Other +validation errors on these two endpoints now return their message with +the 422 (eg “Only one Head is allowed per group”, “End Date cannot +be earlier than Start Date”) instead of a generic “Failed to add +member” / “Failed to update member”.
  • diff --git a/spp_api_v2_programs/README.rst b/spp_api_v2_programs/README.rst index 12a5b8e9d..6b21ec0d9 100644 --- a/spp_api_v2_programs/README.rst +++ b/spp_api_v2_programs/README.rst @@ -99,6 +99,32 @@ Changelog ``Individual/`` and ``Group/`` beneficiary references resolve only that kind. Membership identifiers, beneficiary references and ``Location`` use only live (not soft-removed) IDs (#554). +- fix: ``GET /ProgramMembership`` filters fail closed (#554). A + ``beneficiary`` that isn't ``Individual/{system}|{value}`` or + ``Group/{system}|{value}``, or a ``program`` that isn't + ``Program/{system}|{value}``, returns ``400`` instead of being dropped + and returning every membership. A well-formed reference that matches + nothing still returns an empty page. +- fix: ``POST /ProgramMembership`` for a beneficiary already in the + program returns ``409`` "Beneficiary is already a member of this + program" (#554). It returned ``422`` with the database error text, + including internal record ids. Create and ``PUT`` errors that aren't + client errors now return a generic message; the details are only + logged. +- fix: ``GET /Program`` without the ``program:search`` or + ``program:read`` scope returns ``403`` (#554). The ``status`` query + parameter shadowed FastAPI's ``status`` module, so it returned + ``500``. +- fix: consent-filtered ``GET /ProgramMembership`` pages no longer skip + records or stop early, and ``meta.total`` never counts memberships the + client may not see (#554, see ``spp_api_v2``). +- fix: ``GET /ProgramMembership`` ``self``/``next``/``prev`` links are + URL-encoded (#554). The ``#`` in a beneficiary's identifier type cut + the link short, so following ``next`` lost the beneficiary filter. +- fix: an unexpected error in ``GET /ProgramMembership`` returns ``500`` + "Failed to search program memberships" instead of ``400`` with the + error text (#554); malformed filters are still ``400``. An ``_offset`` + beyond what the database accepts returns ``422``. 19.0.1.0.0 ~~~~~~~~~~ diff --git a/spp_api_v2_programs/static/description/index.html b/spp_api_v2_programs/static/description/index.html index cd6228347..7d9f83038 100644 --- a/spp_api_v2_programs/static/description/index.html +++ b/spp_api_v2_programs/static/description/index.html @@ -446,6 +446,32 @@

    19.0.1.1.0

    Individual/ and Group/ beneficiary references resolve only that kind. Membership identifiers, beneficiary references and Location use only live (not soft-removed) IDs (#554). +
  • fix: GET /ProgramMembership filters fail closed (#554). A +beneficiary that isn’t Individual/{system}|{value} or +Group/{system}|{value}, or a program that isn’t +Program/{system}|{value}, returns 400 instead of being dropped +and returning every membership. A well-formed reference that matches +nothing still returns an empty page.
  • +
  • fix: POST /ProgramMembership for a beneficiary already in the +program returns 409 “Beneficiary is already a member of this +program” (#554). It returned 422 with the database error text, +including internal record ids. Create and PUT errors that aren’t +client errors now return a generic message; the details are only +logged.
  • +
  • fix: GET /Program without the program:search or +program:read scope returns 403 (#554). The status query +parameter shadowed FastAPI’s status module, so it returned +500.
  • +
  • fix: consent-filtered GET /ProgramMembership pages no longer skip +records or stop early, and meta.total never counts memberships the +client may not see (#554, see spp_api_v2).
  • +
  • fix: GET /ProgramMembership self/next/prev links are +URL-encoded (#554). The # in a beneficiary’s identifier type cut +the link short, so following next lost the beneficiary filter.
  • +
  • fix: an unexpected error in GET /ProgramMembership returns 500 +“Failed to search program memberships” instead of 400 with the +error text (#554); malformed filters are still 400. An _offset +beyond what the database accepts returns 422.
  • From 56df5df57890d4b86a01bcdb6d58d402af6d8dbe Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 17:30:29 +0800 Subject: [PATCH 37/53] docs(spp_api_v2): HISTORY example lists only messages the API can return (#554) --- spp_api_v2/readme/HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index ba0df9aa0..3b6f71a89 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -11,7 +11,7 @@ - fix: `_offset` values beyond what the database accepts return `422` instead of a server error (#554). - fix: `GET /Individual?group=…&membership-role=…` requires the role on the membership of that group (#554). Someone who held the role in another group was also returned. - fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. On create and `PATCH`, only codes of the `urn:iso:std:iso:5218` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns `422`. -- fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. For someone who is already a member, `$add-member` still answers `409` first. Other validation errors on these two endpoints now return their message with the `422` (eg "Only one Head is allowed per group", "End Date cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". +- fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. For someone who is already a member, `$add-member` still answers `409` first. Other validation errors on these two endpoints now return their message with the `422` (eg "Duplication of Member is not allowed", "End Date cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". ### 19.0.2.1.1 From c219df9d603a59da2b0401559de65be30651e7e6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Mon, 28 Sep 2026 17:37:24 +0800 Subject: [PATCH 38/53] docs(spp_api_v2): regenerate README from CI (#554) --- spp_api_v2/README.rst | 6 +++--- spp_api_v2/static/description/index.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/spp_api_v2/README.rst b/spp_api_v2/README.rst index 9d5417092..6225c75e3 100644 --- a/spp_api_v2/README.rst +++ b/spp_api_v2/README.rst @@ -243,9 +243,9 @@ Changelog adding or keeping the member without the role. For someone who is already a member, ``$add-member`` still answers ``409`` first. Other validation errors on these two endpoints now return their message with - the ``422`` (eg "Only one Head is allowed per group", "End Date cannot - be earlier than Start Date") instead of a generic "Failed to add - member" / "Failed to update member". + the ``422`` (eg "Duplication of Member is not allowed", "End Date + cannot be earlier than Start Date") instead of a generic "Failed to + add member" / "Failed to update member". 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_api_v2/static/description/index.html b/spp_api_v2/static/description/index.html index a97e25023..eec054131 100644 --- a/spp_api_v2/static/description/index.html +++ b/spp_api_v2/static/description/index.html @@ -626,9 +626,9 @@

    19.0.2.2.0

    adding or keeping the member without the role. For someone who is already a member, $add-member still answers 409 first. Other validation errors on these two endpoints now return their message with -the 422 (eg “Only one Head is allowed per group”, “End Date cannot -be earlier than Start Date”) instead of a generic “Failed to add -member” / “Failed to update member”. +the 422 (eg “Duplication of Member is not allowed”, “End Date +cannot be earlier than Start Date”) instead of a generic “Failed to +add member” / “Failed to update member”.
    From 27bc12af56ca573ae90dbc86d785c3f6613dbb08 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:44:15 +0800 Subject: [PATCH 39/53] fix(spp_api_v2): decide consent by legal basis on not-found paths too (#555 review) The not-found branches and ConsentService.check_access keyed off is_require_consent while filter_response keys off legal_basis. A client with legal_basis=consent and is_require_consent=False got 404 for an unknown registrant but 403 (or a filtered 200) for a real one, so the status code revealed that the registrant exists. --- spp_api_v2/routers/bulk.py | 4 +- spp_api_v2/routers/dependencies.py | 8 +- spp_api_v2/routers/group.py | 14 +- spp_api_v2/routers/individual.py | 4 +- spp_api_v2/services/consent_service.py | 5 +- spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_consent_predicate.py | 125 ++++++++++++++++++ .../routers/program_membership.py | 2 +- .../tests/test_program_membership_identity.py | 17 +++ 9 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 spp_api_v2/tests/test_consent_predicate.py diff --git a/spp_api_v2/routers/bulk.py b/spp_api_v2/routers/bulk.py index adbe6f8ca..b10fb679b 100644 --- a/spp_api_v2/routers/bulk.py +++ b/spp_api_v2/routers/bulk.py @@ -108,7 +108,7 @@ async def bulk_export( continue if not record: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): # SECURITY: Add timing jitter to prevent enumeration via timing await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # 50-70ms delay # Don't reveal if resource exists for consent-required clients @@ -142,7 +142,7 @@ async def bulk_export( ) # Check if consent was denied - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_info = filtered_data.get("_consent", {}) if consent_info.get("status") in ("no_consent", "scope_mismatch"): items.append( diff --git a/spp_api_v2/routers/dependencies.py b/spp_api_v2/routers/dependencies.py index 486f08665..9bf43bdf7 100644 --- a/spp_api_v2/routers/dependencies.py +++ b/spp_api_v2/routers/dependencies.py @@ -94,7 +94,7 @@ async def check_group_access( # SECURITY: Prevent user enumeration # For clients requiring consent, return same error for "not found" and "no consent" if not group: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): # SECURITY: Add timing jitter to prevent timing-based enumeration await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # 50-70ms delay raise HTTPException( @@ -107,7 +107,7 @@ async def check_group_access( ) # SECURITY: Check consent before allowing operation (for clients requiring consent) - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_service = ConsentService(env) if not consent_service.check_access(group.id, api_client, "group", operation): raise HTTPException( @@ -140,7 +140,7 @@ async def check_individual_access( """ # SECURITY: Prevent user enumeration if not individual: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): # SECURITY: Add timing jitter to prevent timing-based enumeration await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # 50-70ms delay raise HTTPException( @@ -153,7 +153,7 @@ async def check_individual_access( ) # SECURITY: Check consent before allowing operation (for clients requiring consent) - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_service = ConsentService(env) if not consent_service.check_access(individual.id, api_client, "individual", operation): raise HTTPException( diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index 823b65c34..fef878796 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -93,7 +93,7 @@ async def read_group( # SECURITY: Prevent user enumeration # For clients requiring consent, return same error for "not found" and "no consent" if not group: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): # SECURITY: Add timing jitter to prevent timing-based enumeration await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # 50-70ms delay raise HTTPException( @@ -119,7 +119,7 @@ async def read_group( ) # SECURITY: Check if consent was denied and return same error as "not found" - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_info = filtered_data.get("_consent", {}) if consent_info.get("status") in ("no_consent", "scope_mismatch"): raise HTTPException( @@ -372,7 +372,7 @@ async def update_group( # SECURITY: Prevent user enumeration if not group_record: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", @@ -383,7 +383,7 @@ async def update_group( ) # SECURITY: Check consent before allowing update (for clients requiring consent) - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_service = ConsentService(env) if not consent_service.check_access(group_record.id, api_client, "group", "update"): raise HTTPException( @@ -770,19 +770,19 @@ async def merge_groups( # Security checks for both groups (with specific error messages) if not source_group: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): await asyncio.sleep(0.05 + random.uniform(0, 0.02)) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source group not found") if not target_group: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): await asyncio.sleep(0.05 + random.uniform(0, 0.02)) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target group not found") # SECURITY: Check consent for both groups (for clients requiring consent) - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_service = ConsentService(env) if not consent_service.check_access(source_group.id, api_client, "group", "update"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") diff --git a/spp_api_v2/routers/individual.py b/spp_api_v2/routers/individual.py index a84a819b2..b690b3080 100644 --- a/spp_api_v2/routers/individual.py +++ b/spp_api_v2/routers/individual.py @@ -84,7 +84,7 @@ async def read_individual( # For clients requiring consent, return same error for "not found" and "no consent" # to prevent attackers from determining which individuals exist in the system. if not partner: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): # SECURITY: Add timing jitter to prevent timing-based enumeration # Simulate the time it would take to process a found record await asyncio.sleep(0.05 + random.uniform(0, 0.02)) # 50-70ms delay @@ -111,7 +111,7 @@ async def read_individual( ) # SECURITY: Check if consent was denied and return same error as "not found" - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): consent_info = filtered_data.get("_consent", {}) if consent_info.get("status") in ("no_consent", "scope_mismatch"): raise HTTPException( diff --git a/spp_api_v2/services/consent_service.py b/spp_api_v2/services/consent_service.py index c993161e7..80c29a99d 100644 --- a/spp_api_v2/services/consent_service.py +++ b/spp_api_v2/services/consent_service.py @@ -456,8 +456,9 @@ def check_access(self, registrant_id: int, api_client, resource_type: str, actio # For read operations, check consent if action in ("read", "search"): - # Check if client requires consent - if api_client.is_require_consent: + # Same rule as filter_response: the legal basis decides whether + # the client is subject to consent + if self.is_consent_filtered(api_client): # Fast path: Use consent_summary cache for O(1) check if self._check_consent_fast(registrant_id, api_client): return True diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index 5404fa3db..ed1291b00 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -43,3 +43,4 @@ from . import test_identifier_ambiguity from . import test_identifier_ambiguity_paths from . import test_consent_paging +from . import test_consent_predicate diff --git a/spp_api_v2/tests/test_consent_predicate.py b/spp_api_v2/tests/test_consent_predicate.py new file mode 100644 index 000000000..784b2aee2 --- /dev/null +++ b/spp_api_v2/tests/test_consent_predicate.py @@ -0,0 +1,125 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""One rule decides whether a client is subject to consent: its legal basis. + +A client has two consent settings, edited independently on its form: +``legal_basis`` and ``is_require_consent``. Consent filtering +(``ConsentService.filter_response``) keys off the legal basis. The +"not found" answer must follow the same rule, or the status code tells +an unknown registrant apart from one the client may not read. +""" + +import json + +from .common import ApiV2HttpTestCase + +NATIONAL_ID = "urn:openspp:vocab:id-type%23test_national_id" +HOUSEHOLD_ID = "urn:openspp:vocab:id-type%23test_household_id" + +SCOPES = [ + {"resource": "individual", "action": "read"}, + {"resource": "individual", "action": "update"}, + {"resource": "group", "action": "read"}, + {"resource": "group", "action": "update"}, +] + + +class TestConsentPredicate(ApiV2HttpTestCase): + def setUp(self): + super().setUp() + self.person = self.create_test_individual(name="Predicate Person", identifier_value="PRED-IND") + self.household = self.create_test_group(name="Predicate Household", identifier_value="PRED-HH") + + def _token(self, **kwargs): + client = self.create_api_client(name="Predicate Client", scopes=SCOPES, **kwargs) + return self.generate_jwt_token(client) + + def _consent_basis_token(self): + """Consent is its legal basis, but the "require consent" box is unticked""" + return self._token(require_consent=False, legal_basis="consent") + + def _public_task_token(self): + """A legal basis that needs no consent, with the "require consent" box ticked""" + return self._token(require_consent=True, legal_basis="public_task") + + def _get(self, path, token): + return self.url_open(path, headers={"Authorization": f"Bearer {token}"}) + + def _post(self, path, payload, token): + return self.url_open( + path, + data=json.dumps(payload), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + ) + + def _assert_same_answer(self, unknown, existing, status): + self.assertEqual(unknown.status_code, status, unknown.text) + self.assertEqual(existing.status_code, status, existing.text) + + # --- consent basis, box unticked: unknown and unconsented look the same + + def test_individual_read(self): + token = self._consent_basis_token() + self._assert_same_answer( + self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-NONE", token), + self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-IND", token), + 403, + ) + + def test_group_read(self): + token = self._consent_basis_token() + self._assert_same_answer( + self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-NONE", token), + self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-HH", token), + 403, + ) + + def test_individual_groups(self): + token = self._consent_basis_token() + self._assert_same_answer( + self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-NONE/groups", token), + self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-IND/groups", token), + 403, + ) + + def test_group_membership_history(self): + token = self._consent_basis_token() + self._assert_same_answer( + self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-NONE/membership-history", token), + self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-HH/membership-history", token), + 403, + ) + + def test_bulk_export(self): + token = self._consent_basis_token() + response = self._post( + "/api/v2/spp/$bulk/export", + { + "type": "Individual", + "identifiers": [ + "urn:openspp:vocab:id-type#test_national_id|PRED-NONE", + "urn:openspp:vocab:id-type#test_national_id|PRED-IND", + ], + }, + token, + ) + + self.assertEqual(response.status_code, 200, response.text) + statuses = [item["status"] for item in response.json()["items"]] + self.assertEqual(statuses, ["access_denied", "access_denied"]) + + # --- legal basis, box ticked: reads without consent, so unknown is 404 + + def test_legal_basis_client_reads_without_consent(self): + token = self._public_task_token() + + self.assertEqual(self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-IND", token).status_code, 200) + self.assertEqual(self._get(f"/api/v2/spp/Individual/{NATIONAL_ID}|PRED-NONE", token).status_code, 404) + + def test_legal_basis_client_reads_group_history_without_consent(self): + token = self._public_task_token() + + existing = self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-HH/membership-history", token) + unknown = self._get(f"/api/v2/spp/Group/{HOUSEHOLD_ID}|PRED-NONE/membership-history", token) + + self.assertEqual(existing.status_code, 200, existing.text) + self.assertEqual(unknown.status_code, 404, unknown.text) diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index 004bf1885..c68ac0048 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -82,7 +82,7 @@ async def _resolve_membership(env, api_client, service, system, value, program_r env, api_client, service.find_beneficiary, system, value, resource_type="program_membership" ) if not partner: - if api_client.is_require_consent: + if ConsentService.is_consent_filtered(api_client): await deny_access() raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index a93291544..2d8791913 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -135,6 +135,22 @@ def test_unknown_beneficiary_is_403_for_a_consent_client(self): self.assertEqual(response.status_code, 403, response.text) + def test_consent_basis_client_with_require_consent_unticked_gets_403_for_both(self): + """Consent filtering keys off the legal basis, so the not-found answer does too""" + client = self.create_api_client( + name="Consent Basis Membership Client", + scopes=[{"resource": "program_membership", "action": "read"}], + require_consent=False, + legal_basis="consent", + ) + headers = {"Authorization": f"Bearer {self.generate_jwt_token(client)}"} + + unknown = self.url_open(self._url("NO-SUCH-BENEFICIARY"), headers=headers) + unconsented = self.url_open(self._url("SINGLE-001"), headers=headers) + + self.assertEqual(unknown.status_code, 403, unknown.text) + self.assertEqual(unconsented.status_code, 403, unconsented.text) + def test_put_without_program_and_without_consent_is_403(self): """PUT does not reveal that the beneficiary has several memberships""" headers = self._no_consent_headers() @@ -486,3 +502,4 @@ def test_put_with_ambiguous_body_beneficiary_is_409(self): self.assertEqual(response.status_code, 409, response.text) self.assertEqual(membership.state, "enrolled") + From a848f781c53cf9e38885cc4fcb41ef6bb9f7bcdd Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:47:37 +0800 Subject: [PATCH 40/53] fix(spp_api_v2_programs): keep an archived beneficiary's memberships addressable (#555 review) spp.program.membership _inherits res.partner's active field, so the lookup dropped memberships of archived beneficiaries: POST answered 409 already a member while GET/PUT answered 404 for the same membership. --- .../services/program_membership_service.py | 13 +++- .../tests/test_program_membership_identity.py | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 7f248a472..e7e0c7daf 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -192,11 +192,20 @@ def find_for_beneficiary(self, partner, program=None): domain = [("partner_id", "=", partner.id)] if program: domain.append(("program_id", "=", program.id)) - Membership = self.env["spp.program.membership"].sudo() # nosemgrep: odoo-sudo-without-context + # The membership _inherits res.partner's ``active``: without + # active_test=False an archived beneficiary's memberships vanish here + # while the duplicate check (_find_existing_membership) still sees them + Membership = ( + self.env["spp.program.membership"] # nosemgrep: odoo-sudo-without-context + .sudo() + .with_context(active_test=False) + ) memberships = Membership.search(domain, limit=2) if len(memberships) > 1: raise AmbiguousMembershipError(Membership.search_count(domain)) - return memberships + # Hand back a recordset without active_test=False, so reads through it + # (e.g. the beneficiary's IDs) keep the usual archived-record filtering + return self.env["spp.program.membership"].sudo().browse(memberships.ids) # nosemgrep: odoo-sudo-without-context def find_by_partner_and_program(self, partner_id: int, program_id: int): """ diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 2d8791913..c1ffc48f4 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -503,3 +503,76 @@ def test_put_with_ambiguous_body_beneficiary_is_409(self): self.assertEqual(response.status_code, 409, response.text) self.assertEqual(membership.state, "enrolled") + +class TestProgramMembershipArchivedBeneficiary(ApiV2HttpTestCase): + """An archived beneficiary keeps its memberships, and they stay addressable (D8)""" + + def setUp(self): + super().setUp() + self.api_base_url = "/api/v2/spp/ProgramMembership" + self.program = self.create_test_program(name="First Identity Program") + self.archived = self.create_test_individual(name="Archived", identifier_value="PM-ARCHIVED") + self.membership = self.create_test_membership(partner=self.archived, program=self.program, state="enrolled") + self.archived.active = False + self.client = self.create_api_client( + name="Legal Basis Membership Client", + scopes=[ + {"resource": "program_membership", "action": "read"}, + {"resource": "program_membership", "action": "create"}, + {"resource": "program_membership", "action": "update"}, + ], + require_consent=False, + legal_basis="public_task", + ) + self.token = self.generate_jwt_token(self.client) + + def _headers(self): + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"} + + def _url(self): + return ( + f"{self.api_base_url}/{quote(NATIONAL_ID, safe=':')}|PM-ARCHIVED?program={quote(PROGRAM_1_REF, safe=':/|')}" + ) + + def test_get_reads_the_membership(self): + response = self.url_open(self._url(), headers=self._headers()) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["status"], "enrolled") + + def test_put_updates_the_membership(self): + self.env.cr.flush() + response = self.url_put( + self._url(), + data=json.dumps( + { + "type": "ProgramMembership", + "program": {"reference": PROGRAM_1_REF}, + "beneficiary": {"reference": _beneficiary_ref("PM-ARCHIVED")}, + "status": "exited", + } + ), + headers=self._headers(), + ) + self.env.invalidate_all() + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(self.membership.state, "exited") + + def test_post_again_is_409_and_the_membership_is_still_readable(self): + """The duplicate check and the lookup agree: the membership exists""" + response = self.url_open( + self.api_base_url, + data=json.dumps( + { + "type": "ProgramMembership", + "program": {"reference": PROGRAM_1_REF}, + "beneficiary": {"reference": _beneficiary_ref("PM-ARCHIVED")}, + "status": "enrolled", + } + ), + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 409, response.text) + self.assertEqual(self.url_open(self._url(), headers=self._headers()).status_code, 200) From 9b102b48dffecef518fcdd39cf059112e7909aa0 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:50:24 +0800 Subject: [PATCH 41/53] fix(spp_api_v2): only say an identifier is ambiguous in bundles to a client that may read every match (#555 review) Batch and transaction entries answered 409 to any client, so a consent-filtered client learned that an identifier exists, and is duplicated, for registrants it can't read. Entry lookups now give that client the not-found answer, and an ambiguous member reference on create the 403 POST /Group gives it. --- spp_api_v2/routers/batch.py | 14 +++ spp_api_v2/services/bundle_service.py | 68 +++++++--- .../tests/test_identifier_ambiguity_paths.py | 117 ++++++++++++++++++ 3 files changed, 179 insertions(+), 20 deletions(-) diff --git a/spp_api_v2/routers/batch.py b/spp_api_v2/routers/batch.py index dff79dff0..93bcde731 100644 --- a/spp_api_v2/routers/batch.py +++ b/spp_api_v2/routers/batch.py @@ -15,6 +15,7 @@ from ..schemas.bundle import RegistrantBundle from ..services.bundle_service import BundleProcessor from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError +from ..utils.registrant_lookup import ambiguous_identifier_status _logger = logging.getLogger(__name__) @@ -129,6 +130,19 @@ async def process_bundle( return result except ValidationError as e: + if ( + isinstance(e.__cause__, AmbiguousIdentifierError) + and ambiguous_identifier_status(env, api_client, e.__cause__) == status.HTTP_403_FORBIDDEN + ): + # Only a client that may read every match learns the identifier is + # ambiguous; the others get the 403 POST /Group gives them + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "resourceType": "OperationOutcome", + "issue": [{"severity": "error", "code": "forbidden", "diagnostics": "Access denied"}], + }, + ) from e if isinstance(e.__cause__, AmbiguousIdentifierError | IdentifierInUseError): # A transaction entry's identifier matches, or would match, more # than one registrant: a conflict, as for the single-resource endpoints diff --git a/spp_api_v2/services/bundle_service.py b/spp_api_v2/services/bundle_service.py index 16f2649c8..53ac7c55c 100644 --- a/spp_api_v2/services/bundle_service.py +++ b/spp_api_v2/services/bundle_service.py @@ -8,9 +8,12 @@ from odoo.api import Environment from odoo.exceptions import UserError, ValidationError +from fastapi import status + from ..schemas.bundle import Bundle, BundleEntry from ..schemas.group import Group from ..schemas.individual import Individual +from ..utils.registrant_lookup import ambiguous_identifier_status from .group_service import GroupService from .individual_service import IndividualService from .registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError @@ -158,7 +161,7 @@ def process_batch(self, bundle: Bundle, api_client, source: str) -> dict[str, An _logger.warning(f"Batch entry {idx + 1} failed: {str(e)}", exc_info=True) # Create error response - error_response = self._create_error_response(e) + error_response = self._create_error_response(e, api_client) results.append( { @@ -372,9 +375,7 @@ def _handle_update(self, url: str, resource_data: dict, api_client, source: str) raise ValidationError("Client does not have permission to update individuals") # Find individual - partner = self.individual_service.find_by_identifier(system, value) - if not partner: - raise ValidationError(f"Individual not found: {system}|{value}") + partner = self._find_registrant(self.individual_service, "Individual", system, value, api_client) # Parse schema individual = Individual(**resource_data) @@ -398,9 +399,7 @@ def _handle_update(self, url: str, resource_data: dict, api_client, source: str) raise ValidationError("Client does not have permission to update groups") # Find group - group_record = self.group_service.find_by_identifier(system, value) - if not group_record: - raise ValidationError(f"Group not found: {system}|{value}") + group_record = self._find_registrant(self.group_service, "Group", system, value, api_client) # Parse schema group = Group(**resource_data) @@ -442,9 +441,7 @@ def _handle_read(self, url: str, api_client) -> dict[str, Any]: raise ValidationError("Client does not have permission to read individuals") # Find individual - partner = self.individual_service.find_by_identifier(system, value) - if not partner: - raise ValidationError(f"Individual not found: {system}|{value}") + partner = self._find_registrant(self.individual_service, "Individual", system, value, api_client) # Convert to API schema result = self.individual_service.to_api_schema(partner) @@ -462,9 +459,7 @@ def _handle_read(self, url: str, api_client) -> dict[str, Any]: raise ValidationError("Client does not have permission to read groups") # Find group - group_record = self.group_service.find_by_identifier(system, value) - if not group_record: - raise ValidationError(f"Group not found: {system}|{value}") + group_record = self._find_registrant(self.group_service, "Group", system, value, api_client) # Convert to API schema result = self.group_service.to_api_schema(group_record) @@ -500,9 +495,7 @@ def _handle_delete(self, url: str, api_client, source: str) -> dict[str, Any]: raise ValidationError("Client does not have permission to delete individuals") # Find individual - partner = self.individual_service.find_by_identifier(system, value) - if not partner: - raise ValidationError(f"Individual not found: {system}|{value}") + partner = self._find_registrant(self.individual_service, "Individual", system, value, api_client) # Soft delete (set active=False) partner.with_context(source_system=source).write({"active": False}) @@ -522,9 +515,7 @@ def _handle_delete(self, url: str, api_client, source: str) -> dict[str, Any]: raise ValidationError("Client does not have permission to delete groups") # Find group - group_record = self.group_service.find_by_identifier(system, value) - if not group_record: - raise ValidationError(f"Group not found: {system}|{value}") + group_record = self._find_registrant(self.group_service, "Group", system, value, api_client) # Soft delete (set active=False) group_record.with_context(source_system=source).write({"active": False}) @@ -541,6 +532,29 @@ def _handle_delete(self, url: str, api_client, source: str) -> dict[str, Any]: else: raise ValidationError(f"Unsupported resource type: {resource_type}") + def _find_registrant(self, service, resource_type: str, system: str, value: str, api_client): + """ + Find the one registrant an entry addresses, or raise "not found". + + An identifier held by several registrants is a 409 only for a client + that may read every match; anyone else gets the "not found" answer, + so it doesn't learn that the identifier exists. + + Raises: + AmbiguousIdentifierError: several registrants hold it and the + client may read them all + ValidationError: no registrant (as far as the client may know) + """ + try: + record = service.find_by_identifier(system, value) + except AmbiguousIdentifierError as e: + if ambiguous_identifier_status(self.env, api_client, e) != status.HTTP_403_FORBIDDEN: + raise + record = None + if not record: + raise ValidationError(f"{resource_type} not found: {system}|{value}") + return record + def _extract_identifier_from_result(self, result: dict[str, Any]) -> str | None: """ Extract the identifier from a result for placeholder mapping. @@ -570,17 +584,31 @@ def _extract_identifier_from_result(self, result: dict[str, Any]) -> str | None: return None - def _create_error_response(self, exception: Exception) -> dict[str, Any]: + def _create_error_response(self, exception: Exception, api_client) -> dict[str, Any]: """ Create error response from exception. Args: exception: Exception that occurred + api_client: client the bundle is answered to Returns: Dict with status and OperationOutcome """ # Determine status code based on exception type + if ( + isinstance(exception, AmbiguousIdentifierError) + and ambiguous_identifier_status(self.env, api_client, exception) == status.HTTP_403_FORBIDDEN + ): + # Only a client that may read every match learns the identifier is + # ambiguous; the others get the 403 POST /Group gives them + return { + "status": "403 Forbidden", + "outcome": { + "resourceType": "OperationOutcome", + "issue": [{"severity": "error", "code": "forbidden", "diagnostics": "Access denied"}], + }, + } if isinstance(exception, AmbiguousIdentifierError | IdentifierInUseError): # The identifier matches, or would match, more than one registrant status_code = "409 Conflict" diff --git a/spp_api_v2/tests/test_identifier_ambiguity_paths.py b/spp_api_v2/tests/test_identifier_ambiguity_paths.py index 8fb776fa1..facf98a7d 100644 --- a/spp_api_v2/tests/test_identifier_ambiguity_paths.py +++ b/spp_api_v2/tests/test_identifier_ambiguity_paths.py @@ -269,3 +269,120 @@ def test_individual_and_group_sharing_a_value_are_resolved_by_kind(self): self.assertEqual(individual.json()["type"], "Individual") self.assertEqual(group.status_code, 200, group.text) self.assertEqual(group.json()["type"], "Group") + + +class TestBundleAmbiguityForConsentClient(ApiV2HttpTestCase): + """A bundle only says "ambiguous" to a client that may read every match + + Anyone else gets what they would get for an identifier nobody holds, + as on the single-resource endpoints. + """ + + def setUp(self): + super().setUp() + self.first = self.create_test_individual(name="Original", identifier_value="BND-DUP") + self.second = self.create_test_individual(name="Copy", identifier_value="BND-DUP") + self.consent_client = self.create_api_client(name="Bundle Consent Client", scopes=SCOPES) + self.create_consent( + registrant=self.first, + grantee_partner=self.consent_client.partner_id, + resource_type="all", + field_access="all", + ) + self.token = self.generate_jwt_token(self.consent_client) + + def _bundle(self, bundle_type, entries): + return self.url_open( + "/api/v2/spp/$batch", + data=json.dumps({"resourceType": "Bundle", "type": bundle_type, "entry": entries}), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"}, + ) + + def _read(self, value): + return {"request": {"method": "GET", "url": f"Individual/{NATIONAL_ID}|{value}"}} + + def test_batch_read_of_an_ambiguous_identifier_looks_like_not_found(self): + response = self._bundle("batch", [self._read("BND-DUP"), self._read("BND-NOBODY")]) + + self.assertEqual(response.status_code, 200, response.text) + ambiguous, unknown = response.json()["entry"] + self.assertEqual(ambiguous["response"]["status"], unknown["response"]["status"]) + self.assertEqual( + ambiguous["resource"]["issue"][0]["diagnostics"].replace("BND-DUP", "X"), + unknown["resource"]["issue"][0]["diagnostics"].replace("BND-NOBODY", "X"), + ) + + def test_batch_update_and_delete_of_an_ambiguous_identifier_look_like_not_found(self): + update = { + "request": {"method": "PUT", "url": f"Individual/{NATIONAL_ID}|BND-DUP"}, + "resource": {"type": "Individual", "name": {"given": "Changed"}}, + } + delete = {"request": {"method": "DELETE", "url": f"Individual/{NATIONAL_ID}|BND-DUP"}} + + response = self._bundle("batch", [update, delete, self._read("BND-NOBODY")]) + + self.assertEqual(response.status_code, 200, response.text) + statuses = [entry["response"]["status"] for entry in response.json()["entry"]] + self.assertNotIn("409 Conflict", statuses) + self.assertEqual(statuses[0], statuses[2]) + self.assertEqual(statuses[1], statuses[2]) + + def test_batch_create_with_an_ambiguous_member_is_403(self): + """As POST /Group answers the same client: the jittered "not found" 403""" + response = self._bundle( + "batch", + [ + { + "request": {"method": "POST", "url": "Group"}, + "resource": { + "type": "Group", + "identifier": [{"system": HOUSEHOLD_ID, "value": "BND-NEW-HH"}], + "name": "Bundle Household", + "member": [{"entity": {"reference": f"Individual/{NATIONAL_ID}|BND-DUP"}}], + }, + } + ], + ) + + self.assertEqual(response.status_code, 200, response.text) + entry = response.json()["entry"][0] + self.assertEqual(entry["response"]["status"], "403 Forbidden") + self.assertNotIn("more than one", json.dumps(entry)) + + def test_transaction_create_with_an_ambiguous_member_is_403(self): + response = self._bundle( + "transaction", + [ + { + "request": {"method": "POST", "url": "Group"}, + "resource": { + "type": "Group", + "identifier": [{"system": HOUSEHOLD_ID, "value": "BND-TX-HH"}], + "name": "Bundle Household", + "member": [{"entity": {"reference": f"Individual/{NATIONAL_ID}|BND-DUP"}}], + }, + } + ], + ) + + self.assertEqual(response.status_code, 403, response.text) + self.assertNotIn("more than one", response.text) + + def test_transaction_read_of_an_ambiguous_identifier_looks_like_not_found(self): + ambiguous = self._bundle("transaction", [self._read("BND-DUP")]) + unknown = self._bundle("transaction", [self._read("BND-NOBODY")]) + + self.assertEqual(ambiguous.status_code, unknown.status_code, ambiguous.text) + self.assertNotEqual(ambiguous.status_code, 409) + + def test_client_that_may_read_every_match_still_gets_409(self): + self.create_consent( + registrant=self.second, + grantee_partner=self.consent_client.partner_id, + resource_type="all", + field_access="all", + ) + + response = self._bundle("batch", [self._read("BND-DUP")]) + + self.assertEqual(response.json()["entry"][0]["response"]["status"], "409 Conflict") From 783a1d52b70a177bb064b97d54b7247e274ea827 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:53:35 +0800 Subject: [PATCH 42/53] fix(spp_api_v2): keep identifier values out of bundle logs (#555 review) The identifier-in-use message carried the raw value, and bundles logged str(e) for every failed entry, so national IDs reached the server log. The message now names the ID type only, and bundle entries that fail on client input are logged by error type. --- spp_api_v2/routers/batch.py | 8 +++- spp_api_v2/services/bundle_service.py | 16 +++++++- spp_api_v2/services/registrant_resolver.py | 4 +- spp_api_v2/tests/test_identifier_ambiguity.py | 10 +++++ .../tests/test_identifier_ambiguity_paths.py | 39 +++++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/spp_api_v2/routers/batch.py b/spp_api_v2/routers/batch.py index 93bcde731..76661a0e9 100644 --- a/spp_api_v2/routers/batch.py +++ b/spp_api_v2/routers/batch.py @@ -153,7 +153,13 @@ async def process_bundle( "issue": [{"severity": "error", "code": "conflict", "diagnostics": str(e)}], }, ) from e - _logger.error(f"Bundle processing failed for client {api_client.client_id}: {str(e)}") + # The message can carry the identifiers the client sent (PII): log the + # failing entry's error type, not its text + _logger.warning( + "Bundle processing failed for client %s: %s", + api_client.client_id, + type(e.__cause__ or e).__name__, + ) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ diff --git a/spp_api_v2/services/bundle_service.py b/spp_api_v2/services/bundle_service.py index 53ac7c55c..15a0ad583 100644 --- a/spp_api_v2/services/bundle_service.py +++ b/spp_api_v2/services/bundle_service.py @@ -20,6 +20,18 @@ _logger = logging.getLogger(__name__) +# Errors a bundle entry raises for bad input. Their messages can carry the +# identifiers the client sent (often national IDs), so they are logged by type +CLIENT_ERRORS = (ValidationError, UserError, AmbiguousIdentifierError) + + +def _log_entry_failure(bundle_kind, idx, error): + """Log a failed entry: a client error by type only, anything else with its traceback.""" + if isinstance(error, CLIENT_ERRORS): + _logger.warning("%s entry %s failed: %s", bundle_kind, idx + 1, type(error).__name__) + else: + _logger.error("%s entry %s failed", bundle_kind, idx + 1, exc_info=error) + class BundleProcessor: """Process transaction and batch bundles atomically""" @@ -91,7 +103,7 @@ def process_transaction(self, bundle: Bundle, api_client, source: str) -> dict[s except Exception as e: # Transaction failed - savepoint will rollback automatically - _logger.error(f"Transaction entry {idx + 1} failed: {str(e)}", exc_info=True) + _log_entry_failure("Transaction", idx, e) raise ValidationError( f"Transaction failed at entry {idx + 1} ({entry.full_url or 'unknown'}): {str(e)}" ) from e @@ -158,7 +170,7 @@ def process_batch(self, bundle: Bundle, api_client, source: str) -> dict[str, An except Exception as e: # For batch, continue processing - just record the error - _logger.warning(f"Batch entry {idx + 1} failed: {str(e)}", exc_info=True) + _log_entry_failure("Batch", idx, e) # Create error response error_response = self._create_error_response(e, api_client) diff --git a/spp_api_v2/services/registrant_resolver.py b/spp_api_v2/services/registrant_resolver.py index 88d21b5e6..2974e7ab6 100644 --- a/spp_api_v2/services/registrant_resolver.py +++ b/spp_api_v2/services/registrant_resolver.py @@ -119,7 +119,9 @@ def assert_identifier_free(env, id_type, value, exclude_partner=None): if exclude_partner: domain.append(("partner_id", "!=", exclude_partner.id)) if _registry_ids(env).search_count(domain, limit=1): - raise IdentifierInUseError(f"Identifier {id_type.uri}|{value} is already in use by another registrant") + # The ID type is enough for the client to act on. The value (often a + # national ID) stays out: this message reaches the server log + raise IdentifierInUseError(f"Identifier of type {id_type.uri} is already in use by another registrant") def assert_new_identifiers_free(env, reg_id_commands): diff --git a/spp_api_v2/tests/test_identifier_ambiguity.py b/spp_api_v2/tests/test_identifier_ambiguity.py index 3a3f96b7a..d44c4bdfe 100644 --- a/spp_api_v2/tests/test_identifier_ambiguity.py +++ b/spp_api_v2/tests/test_identifier_ambiguity.py @@ -94,6 +94,16 @@ def test_create_individual_with_identifier_in_use_is_refused(self): self.assertEqual(self.env["spp.registry.id"].search_count([("value", "=", "RES-TAKEN")]), 1) + def test_identifier_in_use_message_names_the_type_not_the_value(self): + """The message is logged by bundles, and an ID value is PII""" + self.create_test_individual(identifier_value="RES-PII-VALUE") + + with self.assertRaises(registrant_resolver.IdentifierInUseError) as ctx: + self.individual_service.create(self._individual_schema("RES-PII-VALUE"), source="test", api_authorized=True) + + self.assertNotIn("RES-PII-VALUE", str(ctx.exception)) + self.assertIn(NATIONAL_ID, str(ctx.exception)) + def test_create_individual_may_reuse_an_invalid_identifier(self): removed = self.create_test_individual(identifier_value="RES-FREED") self._invalidate_id(removed, "RES-FREED") diff --git a/spp_api_v2/tests/test_identifier_ambiguity_paths.py b/spp_api_v2/tests/test_identifier_ambiguity_paths.py index facf98a7d..5dcd1f3cd 100644 --- a/spp_api_v2/tests/test_identifier_ambiguity_paths.py +++ b/spp_api_v2/tests/test_identifier_ambiguity_paths.py @@ -7,6 +7,7 @@ """ import json +import logging from .common import ApiV2HttpTestCase @@ -134,6 +135,44 @@ def test_batch_post_individual_with_identifier_in_use_is_409(self): self.assertEqual(response.json()["entry"][0]["response"]["status"], "409 Conflict") self.assertEqual(self._count_ids("PATH-HEAD"), 1) + def _logged_text(self, records): + formatter = logging.Formatter("%(message)s") + return "\n".join(formatter.format(record) for record in records) + + def test_bundles_do_not_log_the_identifier_value_in_use(self): + """A failed entry is logged; an ID value (often a national ID) must not be""" + entry = { + "request": {"method": "POST", "url": "Individual"}, + "resource": { + "type": "Individual", + "identifier": [{"system": NATIONAL_ID, "value": "PATH-HEAD"}], + "name": {"given": "Clash"}, + }, + } + for bundle_type in ("batch", "transaction"): + with self.subTest(bundle_type=bundle_type): + with self.assertLogs("odoo.addons.spp_api_v2", level="WARNING") as logs: + response = self._post( + "/api/v2/spp/$batch", + {"resourceType": "Bundle", "type": bundle_type, "entry": [entry]}, + ) + + self.assertIn(response.status_code, (200, 409), response.text) + self.assertNotIn("PATH-HEAD", self._logged_text(logs.records)) + + def test_bundles_do_not_log_the_identifier_value_not_found(self): + entry = {"request": {"method": "GET", "url": f"Individual/{NATIONAL_ID}|PATH-NOBODY-PII"}} + for bundle_type in ("batch", "transaction"): + with self.subTest(bundle_type=bundle_type): + with self.assertLogs("odoo.addons.spp_api_v2", level="WARNING") as logs: + response = self._post( + "/api/v2/spp/$batch", + {"resourceType": "Bundle", "type": bundle_type, "entry": [entry]}, + ) + + self.assertIn(response.status_code, (200, 422), response.text) + self.assertNotIn("PATH-NOBODY-PII", self._logged_text(logs.records)) + def test_transaction_bundle_with_ambiguous_identifier_is_409(self): response = self._post( "/api/v2/spp/$batch", From 8cbfb200b9c46d68c396d6d571cb5e7d296468f7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:55:05 +0800 Subject: [PATCH 43/53] refactor(spp_api_v2): read one list of non-consent legal bases (#555 review) AuthenticatedClient.has_legal_basis_bypass kept its own copy of the bases ConsentService.is_consent_filtered uses; a test pins that the two agree for every legal basis. --- spp_api_v2/services/auth_service.py | 21 +++++++-------------- spp_api_v2/tests/test_consent_predicate.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/spp_api_v2/services/auth_service.py b/spp_api_v2/services/auth_service.py index 3f2e3fa75..d1e641e13 100644 --- a/spp_api_v2/services/auth_service.py +++ b/spp_api_v2/services/auth_service.py @@ -10,6 +10,8 @@ import odoo +from .consent_service import NON_CONSENT_BASES + _logger = logging.getLogger(__name__) @@ -117,24 +119,15 @@ def get_allowed_fields(self, resource_type: str) -> set | None: def has_legal_basis_bypass(self) -> bool: """Check if client has legal basis that bypasses individual consent. - Per GDPR Article 6, these bases allow processing without consent: - - legal_obligation: Required by law - - vital_interest: Life-threatening emergencies - - public_interest: Public interest tasks - - public_task: Official authority + Per GDPR Article 6, the bases in ``NON_CONSENT_BASES`` allow processing + without consent (legal obligation, vital interest, public interest, + public task, contract, legitimate interest). The list lives in + consent_service, next to ``ConsentService.is_consent_filtered``. Returns: True if consent check can be bypassed """ - non_consent_bases = ( - "legal_obligation", - "vital_interest", - "public_interest", - "public_task", - "contract", - "legitimate_interest", - ) - return self.legal_basis in non_consent_bases + return self.legal_basis in NON_CONSENT_BASES def __repr__(self) -> str: identifier = self.client_id or self.sender_id or str(self.id) diff --git a/spp_api_v2/tests/test_consent_predicate.py b/spp_api_v2/tests/test_consent_predicate.py index 784b2aee2..4c86d3bdd 100644 --- a/spp_api_v2/tests/test_consent_predicate.py +++ b/spp_api_v2/tests/test_consent_predicate.py @@ -10,7 +10,9 @@ import json -from .common import ApiV2HttpTestCase +from ..services.auth_service import AuthenticatedClient +from ..services.consent_service import NON_CONSENT_BASES, ConsentService +from .common import ApiV2HttpTestCase, ApiV2TestCase NATIONAL_ID = "urn:openspp:vocab:id-type%23test_national_id" HOUSEHOLD_ID = "urn:openspp:vocab:id-type%23test_household_id" @@ -123,3 +125,17 @@ def test_legal_basis_client_reads_group_history_without_consent(self): self.assertEqual(existing.status_code, 200, existing.text) self.assertEqual(unknown.status_code, 404, unknown.text) + + +class TestOneListOfLegalBases(ApiV2TestCase): + """The consent checks read one list of the legal bases that need no consent""" + + def test_bypass_and_consent_filtering_agree_for_every_legal_basis(self): + client = self.create_api_client(name="Basis Client") + for basis, _label in client._fields["legal_basis"].selection: + with self.subTest(legal_basis=basis): + client.legal_basis = basis + authenticated = AuthenticatedClient(record=client, auth_type="oauth2") + + self.assertEqual(authenticated.has_legal_basis_bypass(), basis in NON_CONSENT_BASES) + self.assertEqual(ConsentService.is_consent_filtered(client), basis not in NON_CONSENT_BASES) From 8c8a00b8c9c3d8b1538e0b590e04320d42ce4f0d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 17:57:42 +0800 Subject: [PATCH 44/53] refactor(spp_api_v2_programs): one versionId formula for the ETag and If-Match (#555 review) The PUT handler copied the formula from to_api_schema; item B was those two drifting apart. Both now call ProgramMembershipService.version_id. --- spp_api_v2_programs/routers/program_membership.py | 6 ++---- .../services/program_membership_service.py | 10 +++++++--- .../tests/test_program_membership_identity.py | 7 +++++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/spp_api_v2_programs/routers/program_membership.py b/spp_api_v2_programs/routers/program_membership.py index c68ac0048..713b1e1a9 100644 --- a/spp_api_v2_programs/routers/program_membership.py +++ b/spp_api_v2_programs/routers/program_membership.py @@ -420,11 +420,9 @@ async def update_program_membership( program_record = _resolve_program_param(service, program) membership = await _resolve_membership(env, api_client, service, system, value, program_record) - # Check version for optimistic locking (same format as meta.versionId / ETag) + # Check version for optimistic locking (the version meta.versionId / ETag carry) if if_match: - current_version = str(int(membership.write_date.timestamp() * 1000000)) if membership.write_date else "1" - if_match_clean = if_match.strip('"') - if if_match_clean != current_version: + if if_match.strip('"') != service.version_id(membership): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Version conflict. Resource was modified by another request.", diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index e7e0c7daf..062a7a7dd 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -230,6 +230,12 @@ def find_by_partner_and_program(self, partner_id: int, program_id: int): ) ) + @staticmethod + def version_id(membership) -> str: + """The membership's version: meta.versionId, its ETag, and what If-Match is checked against""" + # Integer microseconds avoid float precision issues + return str(int(membership.write_date.timestamp() * 1000000)) if membership.write_date else "1" + def to_api_schema(self, membership, extensions=None) -> dict[str, Any]: """ Convert Odoo program membership to ProgramMembership API schema. @@ -295,10 +301,8 @@ def to_api_schema(self, membership, extensions=None) -> dict[str, Any]: # For now, we don't have exit_reason_id in the base model # Metadata - # Use integer microseconds for versionId to avoid float precision issues - version_id = str(int(membership.write_date.timestamp() * 1000000)) if membership.write_date else "1" membership_data["meta"] = { - "versionId": version_id, + "versionId": self.version_id(membership), "lastUpdated": membership.write_date.isoformat() if membership.write_date else None, "source": None, # Memberships don't have source tracking in base module } diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index c1ffc48f4..97724a5d8 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -389,6 +389,13 @@ def test_find_by_identifier_ambiguous_raises(self): self.assertEqual(ctx.exception.count, 2) + def test_version_id_is_the_schema_version(self): + """One formula for meta.versionId (the ETag) and the If-Match check""" + version = self.service.version_id(self.multi_p1) + + self.assertEqual(version, self.service.to_api_schema(self.multi_p1)["meta"]["versionId"]) + self.assertEqual(version, str(int(self.multi_p1.write_date.timestamp() * 1000000))) + def _schema(self, program_ref, beneficiary_value, status="exited"): return ProgramMembership( program=Reference(reference=program_ref), From 8a87845be416b9b7c04377ee2710e6a453a2c941 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:01:16 +0800 Subject: [PATCH 45/53] perf(spp_api_v2_programs): refuse an ambiguous membership without counting them (#555 review) The count cost a query on every ambiguous GET/PUT and only a test read it; the message deliberately leaves it out. --- .../services/program_membership_service.py | 5 ++--- .../tests/test_program_membership_identity.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 062a7a7dd..843e0e030 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -40,10 +40,9 @@ def __init__(self): class AmbiguousMembershipError(Exception): """The beneficiary has several program memberships and no program was given.""" - def __init__(self, count: int): + def __init__(self): # The message leaves the count out: it is returned to API clients super().__init__("Beneficiary has several program memberships; specify the program") - self.count = count class ProgramMembershipService: @@ -202,7 +201,7 @@ def find_for_beneficiary(self, partner, program=None): ) memberships = Membership.search(domain, limit=2) if len(memberships) > 1: - raise AmbiguousMembershipError(Membership.search_count(domain)) + raise AmbiguousMembershipError() # Hand back a recordset without active_test=False, so reads through it # (e.g. the beneficiary's IDs) keep the usual archived-record filtering return self.env["spp.program.membership"].sudo().browse(memberships.ids) # nosemgrep: odoo-sudo-without-context diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 97724a5d8..3939770a4 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -7,6 +7,7 @@ """ import json +from unittest.mock import patch from urllib.parse import quote from odoo.exceptions import ValidationError @@ -383,11 +384,18 @@ def test_find_by_identifier_with_program_without_membership(self): self.assertFalse(found) def test_find_by_identifier_ambiguous_raises(self): - """Several memberships and no program: refuse, and report how many""" - with self.assertRaises(program_membership_service.AmbiguousMembershipError) as ctx: + """Several memberships and no program: refuse""" + with self.assertRaises(program_membership_service.AmbiguousMembershipError): self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001") - self.assertEqual(ctx.exception.count, 2) + def test_ambiguity_is_refused_without_counting_memberships(self): + """Refusing needs the two rows the lookup already read, not a count of them all""" + Membership = type(self.env["spp.program.membership"]) + with patch.object(Membership, "search_count") as search_count: + with self.assertRaises(program_membership_service.AmbiguousMembershipError): + self.service.find_for_beneficiary(self.multi) + + search_count.assert_not_called() def test_version_id_is_the_schema_version(self): """One formula for meta.versionId (the ETag) and the If-Match check""" From 3f7e889820e47770f02f3ec187b66506b29e90a7 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:04:28 +0800 Subject: [PATCH 46/53] refactor(spp_api_v2_programs): parse search filters with the reference parsers (#555 review) search() re-parsed beneficiary and program references inline and, unlike the parsers, didn't unquote the system, so a percent-encoded system resolved in POST/PUT bodies but matched nothing on GET ?beneficiary=. --- .../services/program_membership_service.py | 53 +++++-------------- .../tests/test_program_membership_identity.py | 26 +++++++++ 2 files changed, 40 insertions(+), 39 deletions(-) diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index 843e0e030..a86a7103d 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -68,50 +68,25 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: """ domain = [] - # Beneficiary search + # Filters parse references as bodies do. A filter that can't be + # applied must not widen the result to every membership: it raises beneficiary = params.get("beneficiary") if beneficiary: - if beneficiary.startswith("Individual/") or beneficiary.startswith("Group/"): - if beneficiary.startswith("Individual/"): - identifier_str = beneficiary.replace("Individual/", "") - else: - identifier_str = beneficiary.replace("Group/", "") - - if "|" in identifier_str: - system, value = identifier_str.split("|", 1) - # Raises AmbiguousIdentifierError when several registrants hold it - partner = self.find_beneficiary(system, value, is_group=beneficiary.startswith("Group/")) - if partner: - domain.append(("partner_id", "=", partner.id)) - else: - # No matching partner found, return empty result - domain.append(("id", "=", -1)) - else: - raise ValidationError(INVALID_BENEFICIARY_FILTER) - else: - # A filter that can't be applied must not widen the result to every membership - raise ValidationError(INVALID_BENEFICIARY_FILTER) + try: + # Raises AmbiguousIdentifierError when several registrants hold it + partner = self._parse_beneficiary_reference(beneficiary) + except ValidationError as e: + raise ValidationError(INVALID_BENEFICIARY_FILTER) from e + # An unknown beneficiary matches no membership + domain.append(("partner_id", "=", partner.id) if partner else ("id", "=", -1)) - # Program search program = params.get("program") if program: - if program.startswith("Program/"): - identifier_str = program.replace("Program/", "") - if "|" in identifier_str: - system, value = identifier_str.split("|", 1) - from .program_service import ProgramService - - program_service = ProgramService(self.env) - prog = program_service.find_by_identifier(system, value) - if prog: - domain.append(("program_id", "=", prog.id)) - else: - # No matching program found, return empty result - domain.append(("id", "=", -1)) - else: - raise ValidationError(INVALID_PROGRAM_FILTER) - else: - raise ValidationError(INVALID_PROGRAM_FILTER) + try: + prog = self._parse_program_reference(program) + except ValidationError as e: + raise ValidationError(INVALID_PROGRAM_FILTER) from e + domain.append(("program_id", "=", prog.id) if prog else ("id", "=", -1)) # Status search status = params.get("status") diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index 3939770a4..d6a647984 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -152,6 +152,32 @@ def test_consent_basis_client_with_require_consent_unticked_gets_403_for_both(se self.assertEqual(unknown.status_code, 403, unknown.text) self.assertEqual(unconsented.status_code, 403, unconsented.text) + def test_search_resolves_a_percent_encoded_system_like_post_does(self): + """``?beneficiary=`` and ``?program=`` parse references as POST/PUT bodies do""" + encoded_beneficiary = _beneficiary_ref("SINGLE-001").replace("#", "%23") + encoded_program = PROGRAM_1_REF.replace(":program", "%3Aprogram") + + response = self.url_open( + f"{self.api_base_url}?beneficiary={quote(encoded_beneficiary)}&program={quote(encoded_program)}", + headers=self._headers(), + ) + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.json()["meta"]["total"], 1) + + def test_search_with_malformed_filters_is_400_with_the_expected_format(self): + for param, value, expected in ( + ("beneficiary", "Nobody/x|y", "Individual/{system}|{value}"), + ("beneficiary", "Individual/no-separator", "Individual/{system}|{value}"), + ("program", "Programme/x|y", "Program/{system}|{value}"), + ("program", "Program/no-separator", "Program/{system}|{value}"), + ): + with self.subTest(param=param, value=value): + response = self.url_open(f"{self.api_base_url}?{param}={quote(value)}", headers=self._headers()) + + self.assertEqual(response.status_code, 400, response.text) + self.assertIn(expected, response.json()["detail"]) + def test_put_without_program_and_without_consent_is_403(self): """PUT does not reveal that the beneficiary has several memberships""" headers = self._no_consent_headers() From 4c7a533f4b6ad6d939feb98ad7708fc1bb2d9a1f Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:07:06 +0800 Subject: [PATCH 47/53] refactor(spp_api_v2): drop lookup code nothing calls (#555 review) assert_identifier_free's exclude_partner had no caller (only creates check identifiers), and made it look as if updates were checked. ProgramMembershipService.find_by_identifier had no production caller since the router resolves the beneficiary and membership in two steps; its tests now exercise those two steps. --- spp_api_v2/services/registrant_resolver.py | 10 +++---- .../services/program_membership_service.py | 30 +++---------------- .../tests/test_program_membership_identity.py | 9 ++++-- .../tests/test_program_membership_service.py | 5 ++-- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/spp_api_v2/services/registrant_resolver.py b/spp_api_v2/services/registrant_resolver.py index 2974e7ab6..61a02d762 100644 --- a/spp_api_v2/services/registrant_resolver.py +++ b/spp_api_v2/services/registrant_resolver.py @@ -103,21 +103,21 @@ def resolve_registrants(env, identifiers, is_group=None): } -def assert_identifier_free(env, id_type, value, exclude_partner=None): +def assert_identifier_free(env, id_type, value): """ - Raise unless no other registrant holds a live ID of ``id_type`` with ``value``. + Raise if any registrant holds a live ID of ``id_type`` with ``value``. + + Only creates call this: the API's updates drop ``reg_ids`` from the + values they write, so an update never adds an identifier. Args: id_type: spp.vocabulary.code record of the ID type value: Identifier value - exclude_partner: registrant allowed to hold it (the one being written) Raises: IdentifierInUseError: another registrant holds the identifier """ domain = [("id_type_id", "=", id_type.id), ("value", "=", value), LIVE_ID] - if exclude_partner: - domain.append(("partner_id", "!=", exclude_partner.id)) if _registry_ids(env).search_count(domain, limit=1): # The ID type is enough for the client to act on. The value (often a # national ID) stays out: this message reaches the server log diff --git a/spp_api_v2_programs/services/program_membership_service.py b/spp_api_v2_programs/services/program_membership_service.py index a86a7103d..25e37c57a 100644 --- a/spp_api_v2_programs/services/program_membership_service.py +++ b/spp_api_v2_programs/services/program_membership_service.py @@ -108,31 +108,6 @@ def search(self, params: dict[str, Any]) -> tuple[list, int]: return records, total - def find_by_identifier(self, system_uri: str, value: str, program=None): - """ - Lookup program membership by the beneficiary's external identifier. - - A membership has no identifier of its own: it is addressed by its - beneficiary's identifier plus, when the beneficiary is enrolled in - more than one program, the program. - - Args: - system_uri: Full URI of identifier type (e.g., urn:openspp:vocab:id-type#national_id) - value: Identifier value - program: Optional spp.program record narrowing the lookup - - Returns: - spp.program.membership record or empty recordset - - Raises: - AmbiguousMembershipError: no program given and the beneficiary - has several memberships - """ - partner = self.find_beneficiary(system_uri, value) - if not partner: - return self.env["spp.program.membership"] - return self.find_for_beneficiary(partner, program) - def find_beneficiary(self, system_uri: str, value: str, is_group=None): """ Lookup the beneficiary (Individual or Group) by external identifier. @@ -153,7 +128,10 @@ def find_for_beneficiary(self, partner, program=None): """ Find the beneficiary's membership, in ``program`` when given. - Never picks one of several memberships: without a program, a + A membership has no identifier of its own: it is addressed by its + beneficiary's identifier (resolved with :meth:`find_beneficiary`) + plus, when the beneficiary is enrolled in more than one program, the + program. Never picks one of several memberships: without a program, a beneficiary enrolled in more than one program is ambiguous. Returns: diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index d6a647984..f2b15aaec 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -397,22 +397,25 @@ def setUp(self): self.multi_p2 = self.create_test_membership(partner=self.multi, program=self.program_2) self.other = self.create_test_individual(identifier_value="SVC-OTHER-001") + def _beneficiary(self): + return self.service.find_beneficiary(NATIONAL_ID, "SVC-MULTI-001") + def test_find_by_identifier_with_program(self): """The program narrows resolution to that program's membership""" - found = self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001", program=self.program_1) + found = self.service.find_for_beneficiary(self._beneficiary(), program=self.program_1) self.assertEqual(found, self.multi_p1) def test_find_by_identifier_with_program_without_membership(self): """A program the beneficiary is not in resolves to nothing""" - found = self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001", program=self.program_3) + found = self.service.find_for_beneficiary(self._beneficiary(), program=self.program_3) self.assertFalse(found) def test_find_by_identifier_ambiguous_raises(self): """Several memberships and no program: refuse""" with self.assertRaises(program_membership_service.AmbiguousMembershipError): - self.service.find_by_identifier(NATIONAL_ID, "SVC-MULTI-001") + self.service.find_for_beneficiary(self._beneficiary()) def test_ambiguity_is_refused_without_counting_memberships(self): """Refusing needs the two rows the lookup already read, not a count of them all""" diff --git a/spp_api_v2_programs/tests/test_program_membership_service.py b/spp_api_v2_programs/tests/test_program_membership_service.py index a536b2f25..5f5a6a104 100644 --- a/spp_api_v2_programs/tests/test_program_membership_service.py +++ b/spp_api_v2_programs/tests/test_program_membership_service.py @@ -29,13 +29,14 @@ def test_find_by_identifier_uses_namespace_uri(self): membership = self.create_test_membership(partner=self.individual, program=self.program) # Find using partner's identifier - found = self.service.find_by_identifier("urn:openspp:vocab:id-type#test_national_id", "MEMBER-TEST-001") + partner = self.service.find_beneficiary("urn:openspp:vocab:id-type#test_national_id", "MEMBER-TEST-001") + found = self.service.find_for_beneficiary(partner) self.assertEqual(found, membership) def test_find_by_identifier_not_found(self): """Returns empty recordset when not found""" - found = self.service.find_by_identifier("urn:openspp:vocab:id-type#test_national_id", "NONEXISTENT") + found = self.service.find_beneficiary("urn:openspp:vocab:id-type#test_national_id", "NONEXISTENT") self.assertFalse(found) From 8dff43546ac09aeecce22fbebb0b078bda0f1757 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:10:12 +0800 Subject: [PATCH 48/53] fix(spp_api_v2): map member errors to a status by type, not message text (#555 review) add_member/update_member/remove_member raise their messages through _(), so with a non-English user language a duplicate $add-member became 422 instead of 409 and an unknown member 422 instead of 404. The service now raises AlreadyMemberError / NotMemberError (ValidationError subclasses) and the router catches those. --- spp_api_v2/routers/group.py | 30 ++------ spp_api_v2/services/group_service.py | 20 +++-- spp_api_v2/tests/__init__.py | 1 + spp_api_v2/tests/test_group_member_errors.py | 78 ++++++++++++++++++++ 4 files changed, 100 insertions(+), 29 deletions(-) create mode 100644 spp_api_v2/tests/test_group_member_errors.py diff --git a/spp_api_v2/routers/group.py b/spp_api_v2/routers/group.py index fef878796..87adffdb3 100644 --- a/spp_api_v2/routers/group.py +++ b/spp_api_v2/routers/group.py @@ -40,7 +40,7 @@ from ..services.api_audit_service import ApiAuditService from ..services.consent_service import ConsentService from ..services.field_filter import filter_fields, filter_list -from ..services.group_service import GroupService +from ..services.group_service import AlreadyMemberError, GroupService, NotMemberError from ..services.registrant_resolver import AmbiguousIdentifierError, IdentifierInUseError from ..services.search_service import InvalidSearchParam, SearchService from ..utils.pagination import MAX_OFFSET, fetch_with_consent, page_total_and_next @@ -567,9 +567,9 @@ async def add_member( role_coding=role_coding, start_date=request.start_date, ) + except AlreadyMemberError as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e except ValidationError as ve: - if "already a member" in str(ve).lower(): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(ve)) from ve # Other client errors (eg unknown role): keep the message _logger.warning("Validation error adding member to group: %s", ve) raise HTTPException( @@ -578,12 +578,6 @@ async def add_member( ) from ve except Exception as e: _logger.exception("Error adding member to group") - # Check for specific errors - if "already a member" in str(e).lower(): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=str(e), - ) from e raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Failed to add member", @@ -638,14 +632,10 @@ async def remove_member( ended_date=request.ended_date, reason=request.reason, ) + except NotMemberError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e except Exception as e: _logger.exception("Error removing member from group") - # Check for specific errors - if "not a member" in str(e).lower(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e), - ) from e raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Failed to remove member", @@ -711,9 +701,9 @@ async def update_member( start_date=request.start_date, ended_date=request.ended_date, ) + except NotMemberError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e except ValidationError as ve: - if "not a member" in str(ve).lower(): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ve)) from ve # Other client errors (eg unknown role): keep the message _logger.warning("Validation error updating member in group: %s", ve) raise HTTPException( @@ -722,12 +712,6 @@ async def update_member( ) from ve except Exception as e: _logger.exception("Error updating member in group") - # Check for specific errors - if "not a member" in str(e).lower(): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e), - ) from e raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Failed to update member", diff --git a/spp_api_v2/services/group_service.py b/spp_api_v2/services/group_service.py index dd95fabad..84fac924c 100644 --- a/spp_api_v2/services/group_service.py +++ b/spp_api_v2/services/group_service.py @@ -25,6 +25,14 @@ _logger = logging.getLogger(__name__) +class AlreadyMemberError(ValidationError): + """The individual is already a member of the group.""" + + +class NotMemberError(ValidationError): + """The individual is not a member of the group.""" + + class GroupService: """Service for Group resource CRUD and mapping""" @@ -628,12 +636,12 @@ def add_member(self, group, individual, role_coding=None, start_date=None) -> di Dictionary matching MembershipResponse schema Raises: - ValidationError: If individual is already a member + AlreadyMemberError: If individual is already a member """ # Check if individual is already a member existing = self.get_membership(group, individual) if existing: - raise ValidationError(_("Individual is already a member of this group")) + raise AlreadyMemberError(_("Individual is already a member of this group")) # Prepare membership values vals = { @@ -695,12 +703,12 @@ def remove_member(self, group, individual, ended_date=None, reason=None) -> dict Dictionary matching MembershipResponse schema Raises: - ValidationError: If individual is not a member or already ended + NotMemberError: If individual is not a member or already ended """ # Find membership membership = self.get_membership(group, individual) if not membership: - raise ValidationError(_("Individual is not a member of this group")) + raise NotMemberError(_("Individual is not a member of this group")) # Set ended date if ended_date: @@ -744,12 +752,12 @@ def update_member(self, group, individual, role_coding=None, start_date=None, en Dictionary matching MembershipResponse schema Raises: - ValidationError: If individual is not a member + NotMemberError: If individual is not a member """ # Find membership membership = self.get_membership(group, individual) if not membership: - raise ValidationError(_("Individual is not a member of this group")) + raise NotMemberError(_("Individual is not a member of this group")) # Prepare update values vals = {} diff --git a/spp_api_v2/tests/__init__.py b/spp_api_v2/tests/__init__.py index ed1291b00..3a0e9c4e2 100644 --- a/spp_api_v2/tests/__init__.py +++ b/spp_api_v2/tests/__init__.py @@ -44,3 +44,4 @@ from . import test_identifier_ambiguity_paths from . import test_consent_paging from . import test_consent_predicate +from . import test_group_member_errors diff --git a/spp_api_v2/tests/test_group_member_errors.py b/spp_api_v2/tests/test_group_member_errors.py new file mode 100644 index 000000000..d573882c1 --- /dev/null +++ b/spp_api_v2/tests/test_group_member_errors.py @@ -0,0 +1,78 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Member operations map the service's errors to a status by type, not by text. + +The service raises its messages through ``_()``, so their text follows the +user's language; a status decided by matching English text changes with it. +""" + +import json +from unittest.mock import patch + +from ..services.group_service import AlreadyMemberError, GroupService, NotMemberError +from .common import ApiV2HttpTestCase + +GROUP_PATH = "/api/v2/spp/Group/urn:openspp:vocab:id-type%23test_household_id|HH-ERR" +MEMBER_REF = "Individual/urn:openspp:vocab:id-type#test_national_id|IND-ERR-MEMBER" +OUTSIDER_ID = "urn:openspp:vocab:id-type%23test_national_id|IND-ERR-OUTSIDER" + + +class TestGroupMemberErrors(ApiV2HttpTestCase): + def setUp(self): + super().setUp() + self.member = self.create_test_individual(name="Member", identifier_value="IND-ERR-MEMBER") + self.outsider = self.create_test_individual(name="Outsider", identifier_value="IND-ERR-OUTSIDER") + self.group = self.create_test_group( + name="Error Household", identifier_value="HH-ERR", members=[(self.member, None)] + ) + client = self.create_api_client( + name="Member Errors Client", + scopes=[{"resource": "group", "action": "read"}, {"resource": "group", "action": "update"}], + require_consent=False, + legal_basis="public_task", + ) + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.generate_jwt_token(client)}", + } + + def _post(self, path, payload): + return self.url_open(path, data=json.dumps(payload), headers=self.headers) + + def _patch(self, path, payload): + return self.url_patch(path, data=json.dumps(payload), headers=self.headers) + + # --- service raises the dedicated errors ------------------------------ + + def test_service_raises_already_member(self): + with self.assertRaises(AlreadyMemberError): + GroupService(self.env).add_member(self.group, self.member) + + def test_service_raises_not_member_on_update_and_remove(self): + service = GroupService(self.env) + with self.assertRaises(NotMemberError): + service.update_member(self.group, self.outsider) + with self.assertRaises(NotMemberError): + service.remove_member(self.group, self.outsider) + + # --- the status does not depend on the message's language ------------- + + def test_add_existing_member_is_409_whatever_the_language(self): + with patch.object(GroupService, "add_member", side_effect=AlreadyMemberError("Déjà membre de ce groupe")): + response = self._post(f"{GROUP_PATH}/$add-member", {"entity": {"reference": MEMBER_REF}}) + + self.assertEqual(response.status_code, 409, response.text) + + def test_update_non_member_is_404_whatever_the_language(self): + with patch.object(GroupService, "update_member", side_effect=NotMemberError("Pas membre de ce groupe")): + response = self._patch(f"{GROUP_PATH}/member/{OUTSIDER_ID}", {"startDate": "2024-01-15"}) + + self.assertEqual(response.status_code, 404, response.text) + + def test_remove_non_member_is_404_whatever_the_language(self): + with patch.object(GroupService, "remove_member", side_effect=NotMemberError("Pas membre de ce groupe")): + response = self._post( + f"{GROUP_PATH}/$remove-member", + {"entity": {"reference": "Individual/urn:openspp:vocab:id-type#test_national_id|IND-ERR-OUTSIDER"}}, + ) + + self.assertEqual(response.status_code, 404, response.text) From 033859e6e8b0f0100a113111e0a59721115479ef Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:10:42 +0800 Subject: [PATCH 49/53] docs(spp_api_v2): changelog for the #555 review fixes --- spp_api_v2/readme/HISTORY.md | 6 +++++- spp_api_v2_programs/readme/HISTORY.md | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 3b6f71a89..4f7f23194 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -4,7 +4,7 @@ - fix: multi-condition search filters apply to one related row (#554). `GET /Individual?group=` no longer lists someone who left the group but is active in another; `membership-role=` requires the role on an active membership; `identifier=` requires system and value on the same ID. Behaviour change: `GET /Group?member=` now lists only groups the individual is an active member of. - fix: returned references can be followed (#554). `Group.member[].entity`, the `group`/`entity` references in `$add-member`, `$remove-member` and member-update responses, membership-history `member`, and `Individual.groupMembership[].group` were built from the identifier type's vocabulary namespace (`urn:openspp:vocab:id-type|…`) and could not be resolved; they now use the full code URI (`urn:openspp:vocab:id-type#|…`), the same value as `identifier[].system`. - fix: ending a membership "now" takes effect immediately (#554). `$remove-member` without `endedDate`, and the member moves in group merge and split, wrote the end time with microseconds, which the `is_ended`/`status` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (`fields.Datetime.now()`). -- fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client that requires consent and may not read every match gets the same `403` as for a registrant that does not exist (bundles, which do no consent filtering, always answer `409`). Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. +- fix: identifiers are resolved to exactly one registrant (#554). `POST /Individual`, `POST /Group`, `$split` and create entries in `$batch` bundles return `409` when an identifier (type + value) is already live on another registrant, instead of creating a second record no lookup could tell apart; an archived registrant still holds its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and `$batch`/transaction bundles return `409`; a client whose legal basis requires consent and may not read every match gets the same `403` as for a registrant that does not exist. In `$batch`/transaction bundles, such a client gets the entry's "not found" answer for a read, update or delete, and `403` for a create whose member reference is ambiguous. Search filters naming an ambiguous identifier (`group=`, `member=`) return `409`, or an empty page for such a client; bulk export reports the identifier with item status `error` (or `access_denied`). Individual endpoints and the `member=` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and `GET /Individual/{id}` no longer returns a group. Soft-removed (`invalid`) IDs no longer resolve, are no longer listed in `identifier[]` and are not matched by `identifier=`; references are built from a live ID, and a registrant with no live ID is left out of `Group.member[]`, membership history and `/Individual/{id}/groups`. Searching by `identifier=` still lists every registrant holding a live match. - fix: `GET /Group` applies `_offset` (#554). The group search ignored it, so every page (and every `next` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. - fix: consent-filtered searches (`GET /Individual`, `GET /Group`) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the `next` link jumped past the rows not yet examined. Pages over 50 records mistook the per-query cap of 100 for the end of the data. A page cut short by the over-fetch safety limit dropped its `next` link although rows remained; the link is now given until the scan reaches the end. **Clients must follow `next` until it is null: a page can be short, or empty, and still have a `next` link.** A consent-filtered page that ends on the last row no longer links to an empty page. - fix(security): the search `total` never counts records the client may not see (#554). For a client whose legal basis requires consent, `meta.total` is the number of records returned on every page. It was hidden only when this page met a hidden record, so a page past the end (`?identifier=…&_offset=1`) or one filled before any hidden row returned the full count, including records the client may not see. Clients with a legal basis that doesn't require consent still get the exact total. The `next` offsets are still database positions, so hidden rows can be counted from them; see the follow-up issue. @@ -13,6 +13,10 @@ - fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. On create and `PATCH`, only codes of the `urn:iso:std:iso:5218` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns `422`. - fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. For someone who is already a member, `$add-member` still answers `409` first. Other validation errors on these two endpoints now return their message with the `422` (eg "Duplication of Member is not allowed", "End Date cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". +- fix(security): one rule decides whether a client is subject to consent: its legal basis (#554 review). The "not found" answers of reads, bulk export, member operations and membership history, and `ConsentService.check_access`, branched on the client's `is_require_consent` box while consent filtering keys off `legal_basis`. A client with legal basis "consent" and the box unticked got `404` for an unknown registrant and `403` (or a filtered `200`) for a real one, so the status told them apart. Behaviour change for clients whose two settings disagree: legal basis "consent" now always gets `403` for both; a legal basis that needs no consent gets `404` for an unknown registrant and reads without consent, whatever the box says. +- fix(security): the "identifier in use" `409` names the ID type, not the value (#554 review), and `$batch`/transaction bundle entries that fail on client input are logged by error type only: identifier values (often national IDs) no longer reach the server log. +- fix: `$add-member`, `$remove-member` and `PATCH /Group/{id}/member/{id}` choose `409`/`404` by error type instead of by matching the English message text (#554 review), so a non-English user language no longer turns them into `422`. + ### 19.0.2.1.1 - chore(api_v2): the API V2 configuration menu moved from Registry > Configuration to Settings > Registry, alongside the other superuser configuration (#1009) diff --git a/spp_api_v2_programs/readme/HISTORY.md b/spp_api_v2_programs/readme/HISTORY.md index 3c60f4f1a..6db4a70f1 100644 --- a/spp_api_v2_programs/readme/HISTORY.md +++ b/spp_api_v2_programs/readme/HISTORY.md @@ -11,6 +11,9 @@ - fix: consent-filtered `GET /ProgramMembership` pages no longer skip records or stop early, and `meta.total` never counts memberships the client may not see (#554, see `spp_api_v2`). - fix: `GET /ProgramMembership` `self`/`next`/`prev` links are URL-encoded (#554). The `#` in a beneficiary's identifier type cut the link short, so following `next` lost the beneficiary filter. - fix: an unexpected error in `GET /ProgramMembership` returns `500` "Failed to search program memberships" instead of `400` with the error text (#554); malformed filters are still `400`. An `_offset` beyond what the database accepts returns `422`. +- fix(security): whether a client gets the jittered `403` for an unknown beneficiary follows its legal basis, the rule consent filtering uses, not its `is_require_consent` box (#554 review, see `spp_api_v2`). +- fix: memberships of an archived beneficiary can be read and updated with `GET`/`PUT /ProgramMembership` (#554 review). The lookup dropped them, while `POST` answered `409` "already a member" for the same membership. +- fix: the `beneficiary` and `program` search filters parse references as `POST`/`PUT` bodies do (#554 review), so a percent-encoded system (`...%23national_id`) matches on `GET` too. ### 19.0.1.0.0 From 89e86021b1ea61cff7b7afd9b0d6454bd3cd79df Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:21:02 +0800 Subject: [PATCH 50/53] fix(spp_api_v2): log a malformed bundle resource by type too (#555 review) pydantic's ValidationError quotes the input (input_value=...), and wasn't in the client errors logged by type only, so a malformed bundle resource logged its values with a traceback. --- spp_api_v2/services/bundle_service.py | 7 +++++-- .../tests/test_identifier_ambiguity_paths.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/spp_api_v2/services/bundle_service.py b/spp_api_v2/services/bundle_service.py index 15a0ad583..e8a844165 100644 --- a/spp_api_v2/services/bundle_service.py +++ b/spp_api_v2/services/bundle_service.py @@ -5,6 +5,8 @@ import re from typing import Any +from pydantic import ValidationError as PydanticValidationError + from odoo.api import Environment from odoo.exceptions import UserError, ValidationError @@ -21,8 +23,9 @@ _logger = logging.getLogger(__name__) # Errors a bundle entry raises for bad input. Their messages can carry the -# identifiers the client sent (often national IDs), so they are logged by type -CLIENT_ERRORS = (ValidationError, UserError, AmbiguousIdentifierError) +# identifiers the client sent (often national IDs), and a schema error quotes +# the input (input_value=...), so they are logged by type +CLIENT_ERRORS = (ValidationError, UserError, AmbiguousIdentifierError, PydanticValidationError) def _log_entry_failure(bundle_kind, idx, error): diff --git a/spp_api_v2/tests/test_identifier_ambiguity_paths.py b/spp_api_v2/tests/test_identifier_ambiguity_paths.py index 5dcd1f3cd..d92668df5 100644 --- a/spp_api_v2/tests/test_identifier_ambiguity_paths.py +++ b/spp_api_v2/tests/test_identifier_ambiguity_paths.py @@ -160,6 +160,21 @@ def test_bundles_do_not_log_the_identifier_value_in_use(self): self.assertIn(response.status_code, (200, 409), response.text) self.assertNotIn("PATH-HEAD", self._logged_text(logs.records)) + def test_bundles_do_not_log_a_malformed_resource(self): + """Schema validation errors quote the input (input_value=...), so they are logged by type too""" + entry = { + "request": {"method": "POST", "url": "Individual"}, + "resource": { + "type": "Individual", + "identifier": [{"system": NATIONAL_ID, "value": "PATH-MALFORMED"}], + "name": "PATH-PII-NAME", + }, + } + with self.assertLogs("odoo.addons.spp_api_v2", level="WARNING") as logs: + self._post("/api/v2/spp/$batch", {"resourceType": "Bundle", "type": "batch", "entry": [entry]}) + + self.assertNotIn("PATH-PII-NAME", self._logged_text(logs.records)) + def test_bundles_do_not_log_the_identifier_value_not_found(self): entry = {"request": {"method": "GET", "url": f"Individual/{NATIONAL_ID}|PATH-NOBODY-PII"}} for bundle_type in ("batch", "transaction"): From 89d2178d25ea67c438c31380db752e915bf79eb3 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:21:03 +0800 Subject: [PATCH 51/53] docs(spp_api_v2): scope the consent and log changelog lines; name retargeted tests after what they call --- spp_api_v2/readme/HISTORY.md | 4 ++-- .../tests/test_program_membership_identity.py | 6 +++--- .../tests/test_program_membership_service.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spp_api_v2/readme/HISTORY.md b/spp_api_v2/readme/HISTORY.md index 4f7f23194..7724ca57a 100644 --- a/spp_api_v2/readme/HISTORY.md +++ b/spp_api_v2/readme/HISTORY.md @@ -13,8 +13,8 @@ - fix: `PATCH /Individual` updates `gender` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned `422 "Failed to patch individual"`. An unknown gender code now returns `422` naming the code, as create does, instead of being ignored. On create and `PATCH`, only codes of the `urn:iso:std:iso:5218` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns `422`. - fix: `$add-member` and `PATCH /Group/{id}/member/{id}` reject a role code that doesn't exist with `422` naming it (#554), instead of adding or keeping the member without the role. For someone who is already a member, `$add-member` still answers `409` first. Other validation errors on these two endpoints now return their message with the `422` (eg "Duplication of Member is not allowed", "End Date cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". -- fix(security): one rule decides whether a client is subject to consent: its legal basis (#554 review). The "not found" answers of reads, bulk export, member operations and membership history, and `ConsentService.check_access`, branched on the client's `is_require_consent` box while consent filtering keys off `legal_basis`. A client with legal basis "consent" and the box unticked got `404` for an unknown registrant and `403` (or a filtered `200`) for a real one, so the status told them apart. Behaviour change for clients whose two settings disagree: legal basis "consent" now always gets `403` for both; a legal basis that needs no consent gets `404` for an unknown registrant and reads without consent, whatever the box says. -- fix(security): the "identifier in use" `409` names the ID type, not the value (#554 review), and `$batch`/transaction bundle entries that fail on client input are logged by error type only: identifier values (often national IDs) no longer reach the server log. +- fix(security): one rule decides whether a client is subject to consent: its legal basis (#554 review). The "not found" answers for the addressed registrant (reads, bulk export, the group of member operations, membership history and `/Individual/{id}/groups`), and `ConsentService.check_access`, branched on the client's `is_require_consent` box while consent filtering keys off `legal_basis`. A client with legal basis "consent" and the box unticked got `404` for an unknown registrant and `403` (or a filtered `200`) for a real one, so the status told them apart. Behaviour change for clients whose two settings disagree: legal basis "consent" now always gets `403` for both; a legal basis that needs no consent gets `404` for an unknown registrant and reads without consent, whatever the box says. +- fix(security): the "identifier in use" `409` names the ID type, not the value (#554 review), and `$batch`/transaction bundle entries that fail on client input (including a malformed resource) are logged by error type only, so their error text no longer carries identifier values (often national IDs) into the server log. - fix: `$add-member`, `$remove-member` and `PATCH /Group/{id}/member/{id}` choose `409`/`404` by error type instead of by matching the English message text (#554 review), so a non-English user language no longer turns them into `422`. ### 19.0.2.1.1 diff --git a/spp_api_v2_programs/tests/test_program_membership_identity.py b/spp_api_v2_programs/tests/test_program_membership_identity.py index f2b15aaec..ef2c64962 100644 --- a/spp_api_v2_programs/tests/test_program_membership_identity.py +++ b/spp_api_v2_programs/tests/test_program_membership_identity.py @@ -400,19 +400,19 @@ def setUp(self): def _beneficiary(self): return self.service.find_beneficiary(NATIONAL_ID, "SVC-MULTI-001") - def test_find_by_identifier_with_program(self): + def test_find_for_beneficiary_with_program(self): """The program narrows resolution to that program's membership""" found = self.service.find_for_beneficiary(self._beneficiary(), program=self.program_1) self.assertEqual(found, self.multi_p1) - def test_find_by_identifier_with_program_without_membership(self): + def test_find_for_beneficiary_with_program_without_membership(self): """A program the beneficiary is not in resolves to nothing""" found = self.service.find_for_beneficiary(self._beneficiary(), program=self.program_3) self.assertFalse(found) - def test_find_by_identifier_ambiguous_raises(self): + def test_find_for_beneficiary_ambiguous_raises(self): """Several memberships and no program: refuse""" with self.assertRaises(program_membership_service.AmbiguousMembershipError): self.service.find_for_beneficiary(self._beneficiary()) diff --git a/spp_api_v2_programs/tests/test_program_membership_service.py b/spp_api_v2_programs/tests/test_program_membership_service.py index 5f5a6a104..73f7edf80 100644 --- a/spp_api_v2_programs/tests/test_program_membership_service.py +++ b/spp_api_v2_programs/tests/test_program_membership_service.py @@ -24,7 +24,7 @@ def setUp(self): self.individual = self.create_test_individual(identifier_value="MEMBER-TEST-001") self.group = self.create_test_group(identifier_value="GROUP-TEST-001") - def test_find_by_identifier_uses_namespace_uri(self): + def test_find_beneficiary_then_membership_by_namespace_uri(self): """Lookup uses partner's namespace_uri""" membership = self.create_test_membership(partner=self.individual, program=self.program) @@ -34,7 +34,7 @@ def test_find_by_identifier_uses_namespace_uri(self): self.assertEqual(found, membership) - def test_find_by_identifier_not_found(self): + def test_find_beneficiary_not_found(self): """Returns empty recordset when not found""" found = self.service.find_beneficiary("urn:openspp:vocab:id-type#test_national_id", "NONEXISTENT") From 632476a47aba9dee27a204fbd0ead3ac7baf9b19 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 29 Sep 2026 18:29:44 +0800 Subject: [PATCH 52/53] docs(spp_api_v2): regenerate READMEs from CI (#555 review) --- spp_api_v2/README.rst | 50 ++++++++++++++++--- spp_api_v2/static/description/index.html | 36 ++++++++++--- spp_api_v2_programs/README.rst | 11 ++++ .../static/description/index.html | 11 ++++ 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/spp_api_v2/README.rst b/spp_api_v2/README.rst index 6225c75e3..4c981fcfe 100644 --- a/spp_api_v2/README.rst +++ b/spp_api_v2/README.rst @@ -157,12 +157,14 @@ Changelog ``gender``, ``birthdate``, ``_lastUpdated`` without the expected format; ``member`` not an ``Individual/{system}|{value}`` reference) return ``400`` on ``GET /Individual`` and ``GET /Group``. + - fix: multi-condition search filters apply to one related row (#554). ``GET /Individual?group=`` no longer lists someone who left the group but is active in another; ``membership-role=`` requires the role on an active membership; ``identifier=`` requires system and value on the same ID. Behaviour change: ``GET /Group?member=`` now lists only groups the individual is an active member of. + - fix: returned references can be followed (#554). ``Group.member[].entity``, the ``group``/``entity`` references in ``$add-member``, ``$remove-member`` and member-update responses, @@ -172,12 +174,14 @@ Changelog could not be resolved; they now use the full code URI (``urn:openspp:vocab:id-type#|…``), the same value as ``identifier[].system``. + - fix: ending a membership "now" takes effect immediately (#554). ``$remove-member`` without ``endedDate``, and the member moves in group merge and split, wrote the end time with microseconds, which the ``is_ended``/``status`` computes (second precision) treated as a moment in the future: the membership stayed active until the repair cron ran. They now use the ORM's clock (``fields.Datetime.now()``). + - fix: identifiers are resolved to exactly one registrant (#554). ``POST /Individual``, ``POST /Group``, ``$split`` and create entries in ``$batch`` bundles return ``409`` when an identifier (type + value) @@ -186,12 +190,14 @@ Changelog its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and ``$batch``/transaction - bundles return ``409``; a client that requires consent and may not - read every match gets the same ``403`` as for a registrant that does - not exist (bundles, which do no consent filtering, always answer - ``409``). Search filters naming an ambiguous identifier (``group=``, - ``member=``) return ``409``, or an empty page for such a client; bulk - export reports the identifier with item status ``error`` (or + bundles return ``409``; a client whose legal basis requires consent + and may not read every match gets the same ``403`` as for a registrant + that does not exist. In ``$batch``/transaction bundles, such a client + gets the entry's "not found" answer for a read, update or delete, and + ``403`` for a create whose member reference is ambiguous. Search + filters naming an ambiguous identifier (``group=``, ``member=``) + return ``409``, or an empty page for such a client; bulk export + reports the identifier with item status ``error`` (or ``access_denied``). Individual endpoints and the ``member=`` filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and @@ -202,11 +208,13 @@ Changelog of ``Group.member[]``, membership history and ``/Individual/{id}/groups``. Searching by ``identifier=`` still lists every registrant holding a live match. + - fix: ``GET /Group`` applies ``_offset`` (#554). The group search ignored it, so every page (and every ``next`` link) returned the first page again, and when consent filtering skipped records the page was refilled from the start of the results, returning no records or repeating groups. + - fix: consent-filtered searches (``GET /Individual``, ``GET /Group``) no longer skip records or stop early (#554). When the page filled partway through a fetched batch, the ``next`` link jumped past the @@ -217,6 +225,7 @@ Changelog follow ``next`` until it is null: a page can be short, or empty, and still have a ``next`` link.** A consent-filtered page that ends on the last row no longer links to an empty page. + - fix(security): the search ``total`` never counts records the client may not see (#554). For a client whose legal basis requires consent, ``meta.total`` is the number of records returned on every page. It was @@ -226,11 +235,14 @@ Changelog Clients with a legal basis that doesn't require consent still get the exact total. The ``next`` offsets are still database positions, so hidden rows can be counted from them; see the follow-up issue. + - fix: ``_offset`` values beyond what the database accepts return ``422`` instead of a server error (#554). + - fix: ``GET /Individual?group=…&membership-role=…`` requires the role on the membership of that group (#554). Someone who held the role in another group was also returned. + - fix: ``PATCH /Individual`` updates ``gender`` (#554). The vocabulary lookup ran without the privileges create uses, so every gender change returned ``422 "Failed to patch individual"``. An unknown gender code @@ -238,6 +250,7 @@ Changelog ignored. On create and ``PATCH``, only codes of the ``urn:iso:std:iso:5218`` vocabulary are accepted: a code from another vocabulary (eg a membership role) returns ``422``. + - fix: ``$add-member`` and ``PATCH /Group/{id}/member/{id}`` reject a role code that doesn't exist with ``422`` naming it (#554), instead of adding or keeping the member without the role. For someone who is @@ -247,6 +260,31 @@ Changelog cannot be earlier than Start Date") instead of a generic "Failed to add member" / "Failed to update member". +- fix(security): one rule decides whether a client is subject to + consent: its legal basis (#554 review). The "not found" answers for + the addressed registrant (reads, bulk export, the group of member + operations, membership history and ``/Individual/{id}/groups``), and + ``ConsentService.check_access``, branched on the client's + ``is_require_consent`` box while consent filtering keys off + ``legal_basis``. A client with legal basis "consent" and the box + unticked got ``404`` for an unknown registrant and ``403`` (or a + filtered ``200``) for a real one, so the status told them apart. + Behaviour change for clients whose two settings disagree: legal basis + "consent" now always gets ``403`` for both; a legal basis that needs + no consent gets ``404`` for an unknown registrant and reads without + consent, whatever the box says. + +- fix(security): the "identifier in use" ``409`` names the ID type, not + the value (#554 review), and ``$batch``/transaction bundle entries + that fail on client input (including a malformed resource) are logged + by error type only, so their error text no longer carries identifier + values (often national IDs) into the server log. + +- fix: ``$add-member``, ``$remove-member`` and + ``PATCH /Group/{id}/member/{id}`` choose ``409``/``404`` by error type + instead of by matching the English message text (#554 review), so a + non-English user language no longer turns them into ``422``. + 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_api_v2/static/description/index.html b/spp_api_v2/static/description/index.html index eec054131..5a193f8a6 100644 --- a/spp_api_v2/static/description/index.html +++ b/spp_api_v2/static/description/index.html @@ -569,12 +569,14 @@

    19.0.2.2.0

    its identifiers (mark the ID invalid to free it). Lookups no longer pick one of several registrants holding an identifier: read, update, patch, member operations, merge/split and $batch/transaction -bundles return 409; a client that requires consent and may not -read every match gets the same 403 as for a registrant that does -not exist (bundles, which do no consent filtering, always answer -409). Search filters naming an ambiguous identifier (group=, -member=) return 409, or an empty page for such a client; bulk -export reports the identifier with item status error (or +bundles return 409; a client whose legal basis requires consent +and may not read every match gets the same 403 as for a registrant +that does not exist. In $batch/transaction bundles, such a client +gets the entry’s “not found” answer for a read, update or delete, and +403 for a create whose member reference is ambiguous. Search +filters naming an ambiguous identifier (group=, member=) +return 409, or an empty page for such a client; bulk export +reports the identifier with item status error (or access_denied). Individual endpoints and the member= filter resolve individuals only, and Group endpoints groups only, so a value held by one individual and one group is not ambiguous, and @@ -629,6 +631,28 @@

    19.0.2.2.0

    the 422 (eg “Duplication of Member is not allowed”, “End Date cannot be earlier than Start Date”) instead of a generic “Failed to add member” / “Failed to update member”. +
  • fix(security): one rule decides whether a client is subject to +consent: its legal basis (#554 review). The “not found” answers for +the addressed registrant (reads, bulk export, the group of member +operations, membership history and /Individual/{id}/groups), and +ConsentService.check_access, branched on the client’s +is_require_consent box while consent filtering keys off +legal_basis. A client with legal basis “consent” and the box +unticked got 404 for an unknown registrant and 403 (or a +filtered 200) for a real one, so the status told them apart. +Behaviour change for clients whose two settings disagree: legal basis +“consent” now always gets 403 for both; a legal basis that needs +no consent gets 404 for an unknown registrant and reads without +consent, whatever the box says.
  • +
  • fix(security): the “identifier in use” 409 names the ID type, not +the value (#554 review), and $batch/transaction bundle entries +that fail on client input (including a malformed resource) are logged +by error type only, so their error text no longer carries identifier +values (often national IDs) into the server log.
  • +
  • fix: $add-member, $remove-member and +PATCH /Group/{id}/member/{id} choose 409/404 by error type +instead of by matching the English message text (#554 review), so a +non-English user language no longer turns them into 422.
  • diff --git a/spp_api_v2_programs/README.rst b/spp_api_v2_programs/README.rst index 6b21ec0d9..e1589009c 100644 --- a/spp_api_v2_programs/README.rst +++ b/spp_api_v2_programs/README.rst @@ -125,6 +125,17 @@ Changelog "Failed to search program memberships" instead of ``400`` with the error text (#554); malformed filters are still ``400``. An ``_offset`` beyond what the database accepts returns ``422``. +- fix(security): whether a client gets the jittered ``403`` for an + unknown beneficiary follows its legal basis, the rule consent + filtering uses, not its ``is_require_consent`` box (#554 review, see + ``spp_api_v2``). +- fix: memberships of an archived beneficiary can be read and updated + with ``GET``/``PUT /ProgramMembership`` (#554 review). The lookup + dropped them, while ``POST`` answered ``409`` "already a member" for + the same membership. +- fix: the ``beneficiary`` and ``program`` search filters parse + references as ``POST``/``PUT`` bodies do (#554 review), so a + percent-encoded system (``...%23national_id``) matches on ``GET`` too. 19.0.1.0.0 ~~~~~~~~~~ diff --git a/spp_api_v2_programs/static/description/index.html b/spp_api_v2_programs/static/description/index.html index 7d9f83038..04ce2229c 100644 --- a/spp_api_v2_programs/static/description/index.html +++ b/spp_api_v2_programs/static/description/index.html @@ -472,6 +472,17 @@

    19.0.1.1.0

    “Failed to search program memberships” instead of 400 with the error text (#554); malformed filters are still 400. An _offset beyond what the database accepts returns 422. +
  • fix(security): whether a client gets the jittered 403 for an +unknown beneficiary follows its legal basis, the rule consent +filtering uses, not its is_require_consent box (#554 review, see +spp_api_v2).
  • +
  • fix: memberships of an archived beneficiary can be read and updated +with GET/PUT /ProgramMembership (#554 review). The lookup +dropped them, while POST answered 409 “already a member” for +the same membership.
  • +
  • fix: the beneficiary and program search filters parse +references as POST/PUT bodies do (#554 review), so a +percent-encoded system (...%23national_id) matches on GET too.
  • From 6ac8ce34422f62d71b19fdcf4f54495626c46e9a Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Mon, 5 Oct 2026 15:05:34 +0700 Subject: [PATCH 53/53] docs(spp_api_v2): describe the anti-enumeration rule instead of citing a private doc (#555 review) api-error-responses.md is not part of this repository, so the citation points readers of a public module at a document they cannot open. --- spp_api_v2/utils/registrant_lookup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spp_api_v2/utils/registrant_lookup.py b/spp_api_v2/utils/registrant_lookup.py index 37d5979f1..94dcdda87 100644 --- a/spp_api_v2/utils/registrant_lookup.py +++ b/spp_api_v2/utils/registrant_lookup.py @@ -4,7 +4,7 @@ The API refuses to guess (409). But saying "this identifier is ambiguous" also says "it exists", so a client only learns it when it may read every matching registrant; otherwise it gets exactly what it would get for a -registrant that does not exist (api-error-responses.md, anti-enumeration): +registrant that does not exist (the API's anti-enumeration rule): the 403 of a read, or the empty page of a search. """