Skip to content

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

Merged
gonzalesedwin1123 merged 54 commits into
19.0from
fix-554-api-v2-openfn-defects
Oct 5, 2026
Merged

gonzalesedwin1123 merged 54 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:
    • 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 96.25000% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.45%. Comparing base (612b99b) to head (8efece5).

Files with missing lines Patch % Lines
spp_api_v2/routers/group.py 85.18% 8 Missing ⚠️
spp_api_v2/services/bundle_service.py 88.88% 4 Missing ⚠️
spp_api_v2/routers/individual.py 95.23% 1 Missing ⚠️
spp_api_v2/services/group_service.py 97.22% 1 Missing ⚠️
spp_api_v2/services/registrant_resolver.py 97.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   77.04%   77.45%   +0.41%     
==========================================
  Files         727      755      +28     
  Lines       47165    48172    +1007     
==========================================
+ Hits        36338    37314     +976     
- Misses      10827    10858      +31     
Flag Coverage Δ
spp_api_v2 81.62% <95.26%> (+1.63%) ⬆️
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.55% <98.59%> (+2.33%) ⬆️
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% <ø> (+0.02%) ⬆️
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 90.24% <100.00%> (+1.67%) ⬆️
spp_api_v2/routers/bulk.py 100.00% <100.00%> (ø)
spp_api_v2/routers/dependencies.py 94.73% <100.00%> (+10.52%) ⬆️
spp_api_v2/services/auth_service.py 52.50% <100.00%> (+3.75%) ⬆️
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%) ⬆️
... and 8 more

... and 27 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).

@kneckinator kneckinator left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}/groups and membership history;
  • bulk export (access_denied for 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: IdentifierInUseError keeps 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")

@kneckinator kneckinator Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread spp_api_v2/routers/group.py Outdated
start_date=request.start_date,
)
except ValidationError as ve:
if "already a member" in str(ve).lower():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Thanks Ken. I've addressed all ten, one commit each, plus the changelog. Local suites are green (spp_api_v2 762,
spp_api_v2_programs 142, spp_studio_api_v2 163, spp_dci_server 332), CI is green on 632476a (34 pass, Trivy skipped) and a staff review of the
round has been done; its one in-scope finding (pydantic errors in bundle logs) is fixed in 89e8602.

Beyond what you asked:

  • The consent predicate (1) is fixed everywhere it was split, check_access included, not just the ProgramMembership
    branch.
  • Bundle logs no longer carry identifier values for any client error, not just the in-use one (4).
  • $remove-member also uses the new exception types (10).

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
thread). Ready for another look.

…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.
@gonzalesedwin1123
gonzalesedwin1123 merged commit 82ee163 into 19.0 Oct 5, 2026
60 of 81 checks passed
@gonzalesedwin1123
gonzalesedwin1123 deleted the fix-554-api-v2-openfn-defects branch October 5, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants