Repository navigation
fix(api_v2): REST API v2 defects found by the OpenFn adaptor (#554) - #555
Conversation
…ip 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).
…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
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
…ollowed (#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.
…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.
…mmediate (#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.
…iate (#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.
…ers (#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.
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
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.
- 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.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 19.0 #555 +/- ##
==========================================
+ Coverage 77.04% 77.45% +0.41%
==========================================
Files 727 755 +28
Lines 47165 48172 +1007
==========================================
+ Hits 36338 37314 +976
- Misses 10827 10858 +31
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
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).
kneckinator
left a comment
There was a problem hiding this comment.
This clears most of what the OpenFn adaptor ran into, and the anti-enumeration handling is careful work. I'd like the first four inline comments fixed before merge: two paths where a consent-filtered client can still tell whether a registrant exists, memberships of archived beneficiaries that can no longer be addressed, and national IDs ending up in the server log. The rest are nits and optional cleanups.
| env, api_client, service.find_beneficiary, system, value, resource_type="program_membership" | ||
| ) | ||
| if not partner: | ||
| if api_client.is_require_consent: |
There was a problem hiding this comment.
This branch and the one at line 100 decide the 403 with different predicates. Here it's is_require_consent; at line 100 it's consent_denied(), which goes through filter_response and so keys off legal_basis. The two fields are edited independently on the client form.
Take a client with legal_basis='consent' and is_require_consent=False. GET /ProgramMembership/{unknown id} returns 404, while GET /ProgramMembership/{real registrant without consent} returns 403, so the status code tells the client the registrant exists. That's the opposite of what the docstring promises ("the same jittered 403 whatever the reason").
ConsentService.is_consent_filtered(api_client) here would make both branches agree with each other and with page_total_and_next. A test with that client configuration, asserting 403 for both the unknown and the non-consented beneficiary, would pin it.
The not-found paths in individual.py and routers/dependencies.py have the same is_require_consent vs legal_basis split. That's fine as a follow-up issue if you'd rather keep this PR focused.
There was a problem hiding this comment.
Fixed in 27bc12a. There's now one predicate, ConsentService.is_consent_filtered(api_client). It's used by this branch
and by every other not-found branch that keyed off is_require_consent: individual.py, group.py (read, PUT,
$merge, membership history), bulk.py and routers/dependencies.py. ConsentService.check_access uses it too,
because dependencies.py pairs its not-found branch with that check. So I kept it in this PR rather than a follow-up.
Tests with the client configuration you described (legal_basis='consent', box unticked) assert the same 403 for the
unknown and the unconsented registrant on:
- Individual and Group reads;
/Individual/{id}/groupsand membership history;- bulk export (
access_deniedfor both); - ProgramMembership.
The reverse split (public_task, box ticked) now reads without consent and gets 404 for an unknown registrant. The
HISTORY records the behaviour change for clients whose two settings disagree. With this, spp_api_v2 no longer
reads is_require_consent anywhere. spp_dci_server's adapter still does, and the checkbox is still on the client
form, so I filed #579 to give the form one source of truth.
The write-side version of this leak (an unknown member gets 404 on $add-member while an unconsented one goes
through) belongs with the missing write-path consent check, so I've added it to #558.
| 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) |
There was a problem hiding this comment.
spp.program.membership _inherits res.partner, so it picks up active, and this search drops memberships of archived beneficiaries. _find_existing_membership further down uses active_test=False for exactly this reason.
So if you archive an enrolled beneficiary via DELETE and then POST /ProgramMembership for them in the same program, you get 409 "already a member". But GET/PUT /ProgramMembership/{id}?program=P returns 404 (403 for consent clients). The membership exists but can't be addressed, which contradicts the "archived registrants keep their IDs" part of the PR.
Adding .with_context(active_test=False) here would fix it. So would a test that archives the beneficiary and reads the membership back.
There was a problem hiding this comment.
Fixed in a848f78: active_test=False on the lookup. The result is re-browsed without that context, so reads
through it keep the usual filtering. Tests archive an enrolled beneficiary, then check that GET and PUT with
?program= work, and that POST is still 409 while the membership stays readable.
| return result | ||
|
|
||
| except ValidationError as e: | ||
| if isinstance(e.__cause__, AmbiguousIdentifierError | IdentifierInUseError): |
There was a problem hiding this comment.
The single-resource endpoints only say "ambiguous" to a client that may read every match (ambiguous_identifier_status). This handler and BundleProcessor._create_error_response (bundle_service.py:584) return the 409 to anyone.
A consent-filtered client can send a batch entry GET Individual/{system}|{value}. It gets 409 when two registrants hold the value and the not-found answer when none do, so it learns the identifier exists, and is duplicated, for registrants it can't read. That's the leak raise_ambiguous_identifier closes on /Individual/{id}.
AmbiguousIdentifierError carries partners, so both places can use ambiguous_identifier_status() and give the not-found answer when it says 403. _create_error_response would need the client passed in for that. IdentifierInUseError is a create-side conflict and can keep its 409.
There was a problem hiding this comment.
Fixed in 9b102b4.
- Entry lookups (GET/PUT/DELETE): these go through one helper. When
ambiguous_identifier_status()says 403, the
client gets exactly the entry's not-found answer (same status and same diagnostics, apart from the value it sent). - Create entries: an ambiguous member reference gets the 403 POST /Group gives that client. That covers both
_create_error_response(now passed the client) and the transaction path in the router. - Unchanged:
IdentifierInUseErrorkeeps its 409, and a client that may read every match still gets 409 (tested).
| 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") |
There was a problem hiding this comment.
This message carries the raw identifier value, typically a national ID, and the bundle service logs str(e) for failed entries: at ERROR for transactions (bundle_service.py:91) and at WARNING for batches (:158). That puts national IDs in the server log, which breaks the no-PII-in-logs rule.
The ID type alone gives the client enough to act on, e.g. Identifier of type {id_type.uri} is already in use by another registrant.
Separately, and more a question than a request: this 409 isn't consent-gated, so a client with create scope can check whether any national ID exists by POSTing it and watching for 409 vs 201. Some of that comes with enforcing uniqueness at all, so "create scope is trusted enough" is a fine answer. It'd be good to write that down next to the API's anti-enumeration rules.
There was a problem hiding this comment.
Fixed in 783a1d5 and 89e8602. The message is now Identifier of type {id_type.uri} is already in use by another registrant.
I also stopped the bundle log lines (bundle_service.py transaction and batch, plus the router's "Bundle
processing failed") from writing str(e) for client errors. They log the error type now, and only unexpected
errors keep their traceback. The not-found messages ("Individual not found: system|value") carried the value into
the log the same way. Tests assert that neither an in-use identifier nor an unknown one reaches the log, for both
bundle types.
On the question: yes, create scope is trusted that far, and that's a deliberate decision. A client allowed to
create registrants may learn, from the 409 on create, that an identifier is already taken. Enforcing uniqueness
can't avoid that, and granting create scope is where we draw the trust line. We've recorded it in our API
error-response principles, next to the anti-enumeration rules, along with the one-predicate rule from (1) and
the ambiguity rule from (3).
| @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 |
There was a problem hiding this comment.
nit: this is the same tuple as APIClient.has_legal_basis_bypass() (auth_service.py:117), and spp_dci_server/services/consent_adapter.py has a third copy. Pulling it out of filter_response is an improvement, but spp_api_v2 still has two copies. return not api_client.has_legal_basis_bypass() would leave one list, so the paging total and next-link rule can't drift from the rest of the consent logic.
There was a problem hiding this comment.
Done in 8cbfb20. AuthenticatedClient.has_legal_basis_bypass() now reads NON_CONSENT_BASES from
consent_service, and a test pins that the two agree for every legal_basis value. I kept the list in
consent_service rather than making is_consent_filtered call has_legal_basis_bypass(): the routers pass the
spp.api.client record, not an AuthenticatedClient, and the record has no such method.
I left the third copy in spp_dci_server alone for now. Staging batch 3 (#513) bumps that module, so touching it
here would collide on the version. I'll point it at the same list once #513 lands.
| # 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" |
There was a problem hiding this comment.
nit: this copies the versionId formula from to_api_schema (program_membership_service.py:290), and item B in the issue was these two drifting apart. A small version_id(membership) helper in the service, called from both places, would stop that from happening again.
There was a problem hiding this comment.
Done in 8c8a00b: ProgramMembershipService.version_id(membership), used by to_api_schema and the If-Match
check, with a test that the two agree.
| 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)) |
There was a problem hiding this comment.
nit: the search_count adds a query to every ambiguous GET/PUT, and only a test reads .count. The message deliberately leaves the count out. len(memberships) > 1 already tells us what we need, so AmbiguousMembershipError() without the count would do.
There was a problem hiding this comment.
Done in 8a87845: AmbiguousMembershipError() no longer takes a count, and a test asserts that search_count
isn't called.
| 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, is_group=beneficiary.startswith("Group/")) |
There was a problem hiding this comment.
Optional: this parses the beneficiary and program references inline, duplicating _parse_beneficiary_reference / _parse_program_reference, which already raise ValidationError on bad formats. They also unquote the system and this copy doesn't. So a percent-encoded system (...%23national_id) resolves in POST/PUT bodies but matches nothing on GET ?beneficiary=. Calling the helpers would remove about 40 lines and that inconsistency.
There was a problem hiding this comment.
Done in 3f7e889. search() calls _parse_beneficiary_reference / _parse_program_reference and re-raises their
ValidationError as the existing filter messages. Tests cover the percent-encoded system on GET, and each malformed
filter shape still returning 400 with the expected format.
| } | ||
|
|
||
|
|
||
| def assert_identifier_free(env, id_type, value, exclude_partner=None): |
There was a problem hiding this comment.
nit: no caller passes exclude_partner. Updates pop reg_ids, so only creates get here, and the parameter makes it look as if update paths are checked when they aren't. Likewise, ProgramMembershipService.find_by_identifier (program_membership_service.py:137) has no production caller now that the router goes through _resolve_membership; only tests call it. I'd drop both, or at least the parameter.
There was a problem hiding this comment.
Both dropped in 4c7a533. The five tests that called ProgramMembershipService.find_by_identifier now go through
find_beneficiary + find_for_beneficiary, the two steps the router uses, with the same assertions.
| start_date=request.start_date, | ||
| ) | ||
| except ValidationError as ve: | ||
| if "already a member" in str(ve).lower(): |
There was a problem hiding this comment.
The status here depends on the message text, but add_member raises it through _(). With a non-English user language, a duplicate $add-member becomes 422 instead of 409, and in update_member (line 715) an unknown member becomes 422 instead of 404. The same string check is repeated in the except Exception that follows. Dedicated exception subclasses, as you did with DuplicateMembershipError, would fix both and remove the string matching.
There was a problem hiding this comment.
Fixed in 8dff435. AlreadyMemberError / NotMemberError are ValidationError subclasses, raised by add_member,
update_member and remove_member. The router catches them by type; that includes $remove-member, which had the
same string check in its except Exception. Tests raise them with French messages and assert 409/404.
$split still picks its status from substrings. It has four checks, one of them "head" in error_msg, and it's the
same class of bug. I've left it to #574 (item 9) rather than widen this PR further.
…#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.
…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.
…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.
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.
…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.
… 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.
…nting 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.
…e 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=.
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.
…ext (#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.
…view) 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.
…argeted tests after what they call
|
Thanks Ken. I've addressed all ten, one commit each, plus the changelog. Local suites are green (spp_api_v2 762, Beyond what you asked:
Left for later, with reasons in the threads:
On the create-scope question: yes, it's trusted to learn identifier existence from the 409 (details in the |
…g 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.
Fixes #554. It covers the REST API v2 defects found while building the OpenFn
@openfn/language-opensppv4 adaptor, which uses/api/v2/spp. Item C (RFC 9457 error bodies) is a follow-up, not part of this PR.Each item is its own pair of commits: a failing test, then the fix. You can review it one item at a time.
Items
spp_api_v2_programsGET/PUT /ProgramMembership/{id}no longer act on an arbitrary program's membership. A new optional?program=Program/{system}|{value}selects the membership. Without it, a beneficiary with several memberships returns 409.PUTcan no longer move a membership to another program or beneficiary (422).POSTLocationis URL-encoded and carries?program=.spp_api_v2_programsIf-MatchonPUT /ProgramMembershipcompares against the same microsecondversionIdas theETag. It used to reject every request.spp_api_v2identifier/group/gender/birthdate/_lastUpdated/membervalues return 400. Unknown groups, roles, genders or members match nothing, instead of returning the whole registry. Multi-condition filters (group=,membership-role=,identifier=,member=) apply to one related row (any).spp_api_v2Group.member[],$add-member/$remove-memberresponses, membership history,Individual.groupMembership) use the ID type's code URI (…#code), so following them works.spp_api_v2$remove-memberwithoutendedDate, and member moves in merge/split, end memberships on the ORM clock (fields.Datetime.now()). The storedis_ended/statusare therefore correct immediately, not after the repair cron runs.POST /Individual,/Group,$split, bundle creates) refuses an identifier already live on another registrant (409). Lookups never pick one of several matches (409). Soft-removed IDs no longer resolve and are no longer listed. Lookups resolve by kind (individual/group).spp_api_v2GET /Groupapplies_offset. The group search ignored it, so every page and everynextlink returned the first page again. When consent filtering skipped records, the page was refilled from the start of the results, which returned an empty page or repeated groups. Found after the issue was filed, while reviewing search for the OpenFn adaptor.spp_api_v2GET /Individual?group=…&membership-role=…requires the role on the membership of that group. Someone who held the role in another group was also returned (getGroupMembers(G, {role})).spp_api_v2_programsGET /ProgramMembershipfilters fail closed: a malformedbeneficiary=/program=returns 400 instead of every membership.spp_api_v2(+ programs)/Individual,/Groupand/ProgramMembership:nextno longer skips rows fetched but not examined;_count> 50 no longer stops at the per-query cap; a page cut short by the 3x over-fetch limit keeps itsnextlink while rows remain (clients follownextuntil null; a page can be short or empty).spp_api_v2PATCH /Individualgender works (it returned 422: the vocabulary lookup ran as the public user without sudo). Unknown codes, and codes from a vocabulary other than ISO 5218, return 422 on create and PATCH.spp_api_v2_programsPOST /ProgramMembershipreturns 409 "already a member" (pre-check, with the UNIQUE constraint as the race backstop). It returned 422 with PostgreSQL text including internal record ids. Unexpected create/PUT/search errors return a generic message and are only logged.spp_api_v2$add-memberand the member PATCH reject an unknown role code with 422 naming it (it was silently dropped); other validation errors on these endpoints now return their message.spp_api_v2_programsGET /Programwithout scope returns 403 (it returned 500: thestatusquery parameter shadowed FastAPI'sstatus).spp_api_v2(+ programs)meta.totalis the page size on every page (page_total_and_next). It was hidden only when the page met a hidden record, so?identifier=…&_offset=1returned the raw count, an existence oracle bypassing item I's 403. J had made this reachable on/Group. Also: ProgramMembership links URL-encoded;_offsetbounded (422).K–Q were found by a coverage check of the adaptor's calls against this branch and confirmed with HTTP probes; S1 and the rest came from the adversarial staff review of J–Q. Items A–J were already in this PR.
Versions:
spp_api_v219.0.2.1.1 → 19.0.2.2.0,spp_api_v2_programs19.0.1.0.0 → 19.0.1.1.0. HISTORY fragments list every client-visible change. There is no schema change, so no migration.Design decisions
spp.deduplication.manager.id_dedupexists to find them, and aUNIQUE(id_type_id, value)index would fail to build on databases that already have duplicates. The API refuses to create a clash, and refuses to guess on lookup. Caveat: the create check is check-then-insert, so two concurrent POSTs can still create a duplicate.ConsentService.filter_response).access_deniedin bulk export.invalid) IDs: they neither resolve nor block reuse. They are hidden fromidentifier[]and fromidentifier=searches. References andLocationuse a live ID.fields.Datetime.now()at four sites.spp_registry._is_ended_as_ofis unchanged, because it is shared with SQL legs and cron domains.Tests
test_consent_paging(K–M, S1: unit tests forfetch_with_consent/page_total_and_next+ HTTP), K/L/N/O/P/Q tests intest_search_filters_fail_closed,test_patch_api,test_group_api,test_individual_api,test_program_membership_api(incl.TestProgramMembershipPagingAPI),test_program_api,test_scope_enforcement_program;test_search_groups_offset,test_search_offset_pages_through_results,test_search_page_filled_past_consent_denied_groups(J),test_search_filters_fail_closed,test_references_resolvable,test_membership_end_now,test_identifier_ambiguity,test_identifier_ambiguity_paths(spp_api_v2);test_program_membership_identity(spp_api_v2_programs).spp_api_v2739/739,spp_api_v2_programs134/134,spp_studio_api_v2163/163. The last one extends the changed services.test_parse_identifier_paramnow asserts the stricter single-rowanydomain.test_read_program_membership_not_foundandtest_update_program_membership_not_found_returns_404now use a legal-basis client for the 404, astest_read_individual_not_foundalready does. Consent clients get 403, which a new test covers.test_search_with_invalid_beneficiary_format/test_search_with_invalid_program_format(programs) now expect 400 instead of 200: item L's intended contract change.Behaviour changes for API clients
GET /Group?member=lists only current groups.GET /Individual/{id}no longer returns a group.identifier[].?program=, with 403 for unknown beneficiaries to consent clients.meta.totalis the page size; follownextuntil null (a page may be short or empty).beneficiary=/program=onGET /ProgramMembership; 409 for duplicate enrollment; 422 for unknown roles, unknown/foreign gender codes and out-of-range_offset; 403 (not 500) onGET /Programwithout scope.Each change is listed in HISTORY.
Follow-ups (not in this PR)
PUT /ProgramMembership).If-Matchoptional althoughapi-design.mdrequires it.#in Individual/Group/$splitLocationheaders.GET /Group?type=accepted but never applied._create_memberslogs identifier values.Known remaining issues affecting the adaptor (not in this PR)
removeFromGroupthenaddToGroup→ 422 "Duplication of Member"); spp_registry counts ended memberships. Needs a design decision.nextoffsets are database positions, so a consent-filtered client can still count hidden rows (the D10 trade-off); fix is an opaque cursor.exitReasondropped, staleexitDate, no workflow guard) and GroupgroupTypenot stored./_searchpaging,prevlinks, Program cursor,odoo.httperror-log noise, PII in logs,change_requestleaks).POST /Individual|Group/_searchhas no scope check and the old total rule; today it returns nothing because it runs without sudo, so the scope check must land before or with any sudo fix.End-to-end check
The adaptor's QA job (
packages/openspp/tmp/qa-openspp.js) against a local stack on this branch: 50 passed, 0 failed, 0 warnings (on released 19.0: 49 passed, 2 warnings for G and I). Three tests first failed because the QA job adds a member with rolemember, which isn't a vocabulary code; item P now rejects it instead of silently dropping it. Fixed on the adaptor side.