Skip to content

fix(api_v2): REST API v2 defects found by the OpenFn adaptor (#554) - #555

Open
gonzalesedwin1123 wants to merge 38 commits into
19.0from
fix-554-api-v2-openfn-defects
Open

gonzalesedwin1123 wants to merge 38 commits into
19.0from
fix-554-api-v2-openfn-defects

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Fixes #554. It covers the REST API v2 defects found while building the OpenFn @openfn/language-openspp v4 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

# Module Fix
A spp_api_v2_programs GET/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. PUT can no longer move a membership to another program or beneficiary (422). POST Location is URL-encoded and carries ?program=.
B spp_api_v2_programs If-Match on PUT /ProgramMembership compares against the same microsecond versionId as the ETag. It used to reject every request.
H spp_api_v2 Search filters fail closed. Malformed identifier/group/gender/birthdate/_lastUpdated/member values 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).
F spp_api_v2 Returned references (Group.member[], $add-member/$remove-member responses, membership history, Individual.groupMembership) use the ID type's code URI (…#code), so following them works.
G spp_api_v2 $remove-member without endedDate, and member moves in merge/split, end memberships on the ORM clock (fields.Datetime.now()). The stored is_ended/status are therefore correct immediately, not after the repair cron runs.
I both Identifiers resolve to exactly one registrant. Create (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).
J spp_api_v2 GET /Group applies _offset. The group search ignored it, so every page and every next link 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.
K spp_api_v2 GET /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})).
L spp_api_v2_programs GET /ProgramMembership filters fail closed: a malformed beneficiary=/program= returns 400 instead of every membership.
M spp_api_v2 (+ programs) Consent-filtered paging on /Individual, /Group and /ProgramMembership: next no 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 its next link while rows remain (clients follow next until null; a page can be short or empty).
N spp_api_v2 PATCH /Individual gender 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.
O spp_api_v2_programs Duplicate POST /ProgramMembership returns 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.
P spp_api_v2 $add-member and 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.
Q spp_api_v2_programs GET /Program without scope returns 403 (it returned 500: the status query parameter shadowed FastAPI's status).
S1 spp_api_v2 (+ programs) Security: for a client whose legal basis requires consent, meta.total is the page size on every page (page_total_and_next). It was hidden only when the page met a hidden record, so ?identifier=…&_offset=1 returned the raw count, an existence oracle bypassing item I's 403. J had made this reachable on /Group. Also: ProgramMembership links URL-encoded; _offset bounded (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_v2 19.0.2.1.1 → 19.0.2.2.0, spp_api_v2_programs 19.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

  • Duplicate identifiers are refused at the API, with no DB constraint. The registry allows shared ID values by design: spp.deduplication.manager.id_dedup exists to find them, and a UNIQUE(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.
  • Anti-enumeration (docs/principles/api-error-responses.md):
    • An ambiguous identifier is reported (409) only to a client allowed to read every match. The rule is the same as a normal read (ConsentService.filter_response).
    • Other clients get exactly what "not found" looks like on that endpoint: the jittered 403 on reads, an empty page on searches, access_denied in bulk export.
    • On ProgramMembership, a consent client that can't read the beneficiary gets 403 whatever the reason: unknown beneficiary, not enrolled in that program, or several memberships.
    • The 409 message doesn't reveal how many memberships exist.
  • Archived registrants keep their IDs. Lookups still resolve archived registrants (GET after DELETE works). Invalidate the ID to reuse it.
  • Soft-removed (invalid) IDs: they neither resolve nor block reuse. They are hidden from identifier[] and from identifier= searches. References and Location use a live ID.
  • G's end time uses fields.Datetime.now() at four sites. spp_registry._is_ended_as_of is unchanged, because it is shared with SQL legs and cron domains.

Tests

  • New: test_consent_paging (K–M, S1: unit tests for fetch_with_consent/page_total_and_next + HTTP), K/L/N/O/P/Q tests in test_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).
  • Local results: spp_api_v2 739/739, spp_api_v2_programs 134/134, spp_studio_api_v2 163/163. The last one extends the changed services.
  • Existing tests changed:
    • test_parse_identifier_param now asserts the stricter single-row any domain.
    • test_read_program_membership_not_found and test_update_program_membership_not_found_returns_404 now use a legal-basis client for the 404, as test_read_individual_not_found already 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.
    • No tests were removed.
  • Adversarial staff reviews: one before this PR (A–I), one on J–Q (3 reviewers), and a verification pass on the review-round fixes. All findings introduced on this branch are fixed; pre-existing ones are listed below.

Behaviour changes for API clients

  • 400 for malformed search filters that used to be ignored.
  • GET /Group?member= lists only current groups.
  • 409 on ambiguous or in-use identifiers.
  • GET /Individual/{id} no longer returns a group.
  • Soft-removed IDs vanish from identifier[].
  • Registrants with no live ID are left out of member lists and membership history.
  • ProgramMembership ?program=, with 403 for unknown beneficiaries to consent clients.
  • Consent-filtered clients: meta.total is the page size; follow next until null (a page may be short or empty).
  • 400 for malformed beneficiary=/program= on GET /ProgramMembership; 409 for duplicate enrollment; 422 for unknown roles, unknown/foreign gender codes and out-of-range _offset; 403 (not 500) on GET /Program without scope.

Each change is listed in HISTORY.

Follow-ups (not in this PR)

Known remaining issues affecting the adaptor (not in this PR)

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 role member, which isn't a vocabulary code; item P now rejects it instead of silently dropping it. Fixed on the adaptor side.

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

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

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.63033% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.28%. Comparing base (1a3c591) to head (c219df9).

Files with missing lines Patch % Lines
spp_api_v2/routers/group.py 93.18% 3 Missing ⚠️
spp_api_v2/services/registrant_resolver.py 95.74% 2 Missing ⚠️
spp_api_v2/routers/individual.py 94.73% 1 Missing ⚠️
spp_api_v2/services/group_service.py 96.77% 1 Missing ⚠️
spp_api_v2/services/search_service.py 98.03% 1 Missing ⚠️
spp_api_v2_programs/routers/program_membership.py 98.70% 1 Missing ⚠️
...v2_programs/services/program_membership_service.py 98.33% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #555      +/-   ##
==========================================
+ Coverage   76.91%   77.28%   +0.37%     
==========================================
  Files         704      732      +28     
  Lines       45774    46794    +1020     
==========================================
+ Hits        35205    36163     +958     
- Misses      10569    10631      +62     
Flag Coverage Δ
spp_api_v2 81.05% <97.14%> (+1.06%) ⬆️
spp_api_v2_change_request 73.37% <ø> (ø)
spp_api_v2_cycles 71.03% <ø> (ø)
spp_api_v2_data 77.77% <ø> (ø)
spp_api_v2_entitlements 70.23% <ø> (ø)
spp_api_v2_gis 74.60% <ø> (ø)
spp_api_v2_products 65.86% <ø> (ø)
spp_api_v2_programs 94.21% <98.59%> (+1.98%) ⬆️
spp_api_v2_service_points 71.03% <ø> (ø)
spp_api_v2_simulation 71.19% <ø> (ø)
spp_api_v2_vocabulary 57.75% <ø> (?)
spp_base_common 91.07% <ø> (ø)
spp_dci_client_dr 85.77% <ø> (ø)
spp_dci_client_ibr 92.47% <ø> (?)
spp_dci_compliance 93.01% <ø> (ø)
spp_dci_demo 94.28% <ø> (ø)
spp_dci_indicators 96.23% <ø> (?)
spp_programs 67.58% <ø> (ø)
spp_registry 89.00% <ø> (ø)
spp_security 69.56% <ø> (ø)

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

Files with missing lines Coverage Δ
spp_api_v2/routers/batch.py 89.47% <100.00%> (+0.90%) ⬆️
spp_api_v2/routers/bulk.py 100.00% <100.00%> (ø)
spp_api_v2/services/bundle_service.py 73.77% <100.00%> (+0.54%) ⬆️
spp_api_v2/services/consent_service.py 81.15% <100.00%> (+1.15%) ⬆️
spp_api_v2/services/individual_service.py 72.78% <100.00%> (+1.03%) ⬆️
spp_api_v2/services/membership_utils.py 91.30% <100.00%> (+0.39%) ⬆️
spp_api_v2/utils/pagination.py 100.00% <100.00%> (ø)
spp_api_v2/utils/registrant_lookup.py 100.00% <100.00%> (ø)
spp_api_v2_programs/routers/program.py 92.20% <100.00%> (+1.29%) ⬆️
spp_api_v2/routers/individual.py 82.19% <94.73%> (+4.90%) ⬆️
... and 6 more

... and 26 files with indirect coverage changes

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

@gonzalesedwin1123
gonzalesedwin1123 marked this pull request as ready for review September 25, 2026 04:49
This was referenced Sep 25, 2026
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).
…enrollment is 409 without DB internals (#554 L, O)
…total oracle, A2 links, A4, A7, A9, A11)
…earch totals; encode membership links; bound _offset (#554 review S1, A2, A11)
…straint; shared row cap; role message (#554 review A4, A8, A10)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant