ADR-0007: Org-scoped member search returns person data directly, bypassing progressive matching
| Status |
Accepted |
| Date |
2026-07-21 |
| Deciders | |
| Related |
M03 Member Person Lookup, Feature (Epic #32), |
1. Context
The membership-ui operator needs to find an existing member — someone who holds a prior or active membership in the operator’s organisation — from a few known details (DOB, ID/passport number, phone, email) and then open that person’s record. See M03.
Person data in this system lives entirely as wp_usermeta key/value rows behind the PersonWrapper. There are already several ways to search it:
-
GET /api/people/query?q=(PersonService.query) — a fuzzy single-string search that infers DOB / id-number / name from the input. It is@Deprecated,ADMIN-only, and globally scoped: it searches everywp_usersrow regardless of organisation. -
GET /api/people/findById,GET /api/people/match,POST /api/people/idsearch— identity-detail matchers, also deprecated. -
POST …/progressive-match(ProgressiveMatchResource) — the preferred path the deprecations point to. It applies weighted scoring and returns opaque match tokens and access levels rather than person demographics.
Progressive matching exists to stop an authenticated operator from fishing person data out of the global population — trying candidate ID numbers or dates of birth against every person the system has ever seen, and reading back name / DOB / contact details for strangers. The opaque-token indirection is the control that prevents that enumeration.
The lookup screen this feature needs, however, has a fundamentally narrower population. It must:
-
return person demographics directly — the screen shows first name, last name, DOB, gender, phone, email, and links straight to
/person/:id/view; opaque tokens cannot render that screen; and -
only ever match people who already hold a non-draft membership in the operator’s own organisation — the operator is, by definition, entitled to that person’s data under the existing
OrganizationalSecured+PersonalDataSecureddimensions (SecurityDimensionService).
So the two forces in tension are: reuse the preferred progressive-match path (but it returns the wrong shape and adds indirection this screen cannot use), versus return direct data (but that is exactly what the deprecations warn against — unless the search population is already constrained to data the operator may see).
Example scenario: an HNR (org 8) desk operator has a member on the phone who gives their ID number. The operator types it, the screen must immediately show that member’s name, DOB and contact so the operator can confirm identity and open the record. A stranger’s ID number typed into the same box must return nothing — not because a token was withheld, but because that stranger has no HNR membership.
2. Decision
A search endpoint may return person demographics directly, bypassing progressive matching, if and only if every result is constrained to a population the caller is already entitled to see. Concretely, for the member-search endpoint:
-
Add a new, non-deprecated
POST /api/people/member-searchthat returns a lightweightPersonSummaryDTO(personId, firstName, lastName, dateOfBirth, gender, phone, email) — never the fullPersonDTO(no medical / ICE / billing fields). -
Every result MUST be filtered to persons holding a non-draft membership (
status <> 'D') in the organisation resolved for the request. The org is resolved server-side viaTenantService.getOrganisationId(param)(JWTorgIdfallback) and enforced through the existing security dimensions — the client-suppliedorganisationIdis a hint, never trusted on its own. -
The org+membership constraint is the disclosure control. It replaces progressive matching for this endpoint; do not also gate results behind opaque tokens.
-
This exemption does not extend to any globally scoped person search. The deprecated global endpoints stay deprecated; new global-population search must still use
ProgressiveMatchResource.
A reader deciding whether a future endpoint may return direct person data should apply the bright line: is the result set already restricted to people this caller may see by an independent security dimension? If yes, direct data is acceptable. If the search can reach the global population, it is not.
3. Consequences
3.1. Positive
-
The lookup screen can render name / DOB / gender / phone / email directly and link to the person record — the actual requirement — without a token-resolution round trip.
-
The endpoint is safe by construction: the membership+org filter means it can only ever disclose data the operator is already authorised for, so it is not an enumeration surface over the global population.
-
It avoids reviving or widening the deprecated global
queryendpoint; that path stays closed. -
The rule is reusable: any future "search within my own org’s members/participants" screen can follow the same shape.
3.2. Negative
-
There are now two sanctioned person-search philosophies in the codebase (token-based global, direct-data scoped). Reviewers must understand which applies, guided by the bright line above. Mitigated by this ADR and by keeping the direct-data path physically distinct (its own endpoint +
PersonSummaryDTO). -
The safety guarantee depends entirely on the membership+org filter being applied before results leave the service, and on org being enforced server-side. A bug that returns candidates before the membership intersection, or that trusts the
organisationIdparam, turns this into a global enumeration endpoint. This is the highest-risk line of the feature and MUST be covered by an integration test (a person with no org-8 membership is excluded; a mismatchedorganisationIdparam cannot widen the population).
3.3. Neutral
-
The endpoint reuses the existing
PersonService.queryinference logic (extended with phone + email) as its candidate-generation step; it does not invent a new matching algorithm. -
Progressive matching remains the correct and preferred choice everywhere the population is not independently constrained.
4. Alternatives Considered
4.1. Alternative A: Use ProgressiveMatchResource as-is
Route the lookup through the preferred progressive-match endpoint. Rejected because it returns opaque tokens and access levels, not demographics — the lookup screen must display name/DOB/phone/email and link to the record, which tokens cannot express. Adopting it would mean a second call per result to resolve each token back to data the operator is already entitled to, adding latency and complexity to guard against an enumeration risk that the org+membership constraint has already removed.
4.2. Alternative B: Extend the deprecated global GET /api/people/query
Add phone/email to PersonService.query and reuse its endpoint. Rejected because that endpoint is @Deprecated, ADMIN-only, and globally scoped — reusing it both swims against an explicit deprecation and re-opens the exact global-enumeration surface progressive matching was introduced to close. The heuristic inside query is sound and is lifted/extended; the endpoint is not reused.
4.3. Alternative C: Direct data, but scoped only by the client-supplied organisationId
Trust the organisationId request parameter to scope results. Rejected because a client could pass any org id and read that org’s members. Org must be resolved and enforced server-side through the security dimension; the param is at most a hint for multi-org callers.