feat: scope GET /opportunity to an AGENT caller's own agents - #916
feat: scope GET /opportunity to an AGENT caller's own agents#916ivannissimrch wants to merge 28 commits into
Conversation
Code review findingsCorrectness
Test coverage
Simplification / reuse
Minor
🤖 Posted by Claude Code |
… into scope-opportunity-list-to-agent
…ot its volunteers
arturasmckwcz
left a comment
There was a problem hiding this comment.
Automated review findings (Claude Code /code-review). This PR tightens agent/cross-org scoping on opportunities, but the scoping is inconsistently applied — a few gaps re-open the exposure it's meant to close. Inline comments cover issues on lines this PR touches; the following pre-existing code paths are not touched by this diff but are directly relevant to the scoping goal and should be checked in a follow-up:
-
src/server/routes/comment.ts:86—GET /commenthas no ownership/agent-scoping check at all. Any authenticated user (including an AGENT from an unrelated organisation) can read another agent's opportunity comment thread by numericentityId. An AGENT-role caller who this PR now correctly 404s onGET /opportunity/<otherAgentOppId>can still callGET /comment?entityType=opportunity&entityId=<otherAgentOppId>and get the full comment thread (internal notes, potentially PII) for that opportunity — defeating the isolation this PR establishes elsewhere. -
src/server/routes/volunteer/volunteer-opportunity.routes.ts:54—GET /volunteer/opportunityhas no role restriction or agent scoping. It's registered under a parent hook that only requiresfastify.authenticate(), and builds itswherepurely from client query filters, unlike the now-fixedGET /opportunity. An AGENT-role caller can hit this endpoint instead and get an unscoped, cross-organisation opportunity list. -
src/server/routes/opportunity/opportunity.routes.ts:522—contactPersonIdfreezing (agent.agentPerson?.some(ap => ap.personId === opportunity.submittedByPersonId)) uses a plain relation with nostatusfilter, inconsistent with the ACTIVE-only authorization check added in this PR just above it (line 474). A COORDINATOR (who bypasses the AGENT-only membership gate) creating an opportunity withsubmitted_by_idset to a person who only has a PENDING membership in the target agent will permanently freeze that not-yet-approved person in as the opportunity's public-facing contact.
| ? await fastify.db.agentPersonRepository.findOneBy({ | ||
| agentId, | ||
| personId, | ||
| status: AgentMembershipStatus.ACTIVE, |
There was a problem hiding this comment.
correctness (auth bypass): good that this adds an ACTIVE-only status filter, but the personId feeding this check (a couple lines above, not shown in this hunk) is request.authUser?.personId || request.body.submitted_by_id — if the caller's own personId is null/undefined, it silently falls back to the client-supplied submitted_by_id.
Failure scenario: an AGENT-role account with no linked personId sends agent_id: <targetAgent> and submitted_by_id: <activeMemberPersonId> (a personId it knows is an ACTIVE member of the target agent); this ACTIVE-membership check then succeeds using the impersonated id, letting a non-member create an opportunity for an agent they don't belong to.
| await fastify.db.agentPersonRepository.findOneBy({ | ||
| agentId: effectiveAgentId, | ||
| personId: contactLinkId, | ||
| status: AgentMembershipStatus.ACTIVE, |
There was a problem hiding this comment.
correctness / regression: the contactLinkId check now requires status: AgentMembershipStatus.ACTIVE, newly rejecting a PENDING agent member who previously could be set as an opportunity's contact — no test in the diff covers this behavior change, and the resulting error message ("is not registered as a contact") is misleading since the person IS registered, just not yet ACTIVE.
Failure scenario: a coordinator or agent tries to set a freshly-invited, still-PENDING colleague as an opportunity's contact during onboarding; the PATCH now 404s implying no registration exists at all.
| } | ||
|
|
||
| if (request.authUser?.role === UserRole.AGENT) { | ||
| const opportunity = await fastify.db.opportunityRepository.findOne({ |
There was a problem hiding this comment.
efficiency: this new AGENT ownership check issues its own opportunityRepository.findOne({ id, select: agentId }) immediately before opportunityVolunteerRepository.find(...) below, which already joins opportunity.agent via relations — an unnecessary extra DB round trip on every AGENT-role call.
(Note: a naive fix of checking volunteers[0]?.opportunity?.agentId instead would be unsafe on its own — when zero volunteers are linked, volunteers[0] is undefined regardless of ownership, silently turning the intended 404 into a 200 with an empty array.)
| throw new BadRequestError(msg400); | ||
| } | ||
|
|
||
| if (request.authUser?.role === UserRole.AGENT) { |
There was a problem hiding this comment.
simplification: this ownership-check-and-404 block (resolve caller's agentIds, compare to opportunity.agentId, throw NotFoundError) is copy-pasted near-verbatim across three handlers in this PR (opportunity.routes.ts GET /:id, this file, and a list-specific variant), rather than factored into one shared helper. A future change to this security-critical rule would need to be applied by hand in three places — missing one silently reopens the cross-agent exposure this PR is meant to close.
| const agentPersonRepository = fastify.db.agentPersonRepository; | ||
| const memberships = await agentPersonRepository.find({ where: { personId } }); | ||
| memberships.forEach((m) => agentIds.add(m.agentId)); | ||
| const callerAgentIds = await getCallerAgentIds(fastify, personId); |
There was a problem hiding this comment.
efficiency: for AGENT-role callers, getCallerAgentIds now runs twice per request on GET /opportunity/:id, GET /opportunity, and GET /opportunity/:id/volunteer-linked — once in the route handler for the new ownership/scoping check, and again here inside resolveCallerVisibility's PII masking hook wired on the same routes. Consider resolving the caller's agentIds once per request and threading it through.
Also: a few lines below (unchanged by this diff, ~line 77), members = await agentPersonRepository.find({ where: { agentId: In([...agentIds]) } }) — which determines whose PII becomes unmasked for the caller — still has no status filter, contradicting the comment on getCallerAgentIds that a PENDING membership "grants nothing." An ACTIVE caller at Agent X with a PENDING co-applicant Y will still get Y's personId added to the unmasked set.
| return []; | ||
| } | ||
|
|
||
| const memberships = await fastify.db.agentPersonRepository.find({ |
There was a problem hiding this comment.
reuse: this re-implements a query that the existing getActiveAgentMemberships(personId) helper (src/server/utils/data/get-agent-memberships.ts) already performs verbatim (same table, same personId+ACTIVE where-clause) instead of composing on top of it — per the shared-rules "reuse before you create" convention. If the definition of "active membership" ever changes, this duplicate can silently drift out of sync.
…ivannissimrch/be into scope-opportunity-list-to-agent
…ivannissimrch/be into scope-opportunity-list-to-agent
|
@ivannissimrch can we merge this? |
|
@need4deed Most of the review is fixed and pushed. Two comments I haven't addressed yet: getCallerAgentIds resolving twice per request, and the legacy public route. I need help with those two; I'm not confident enough to finish them on my own. |
|
@arturasmckwcz can you help out in guiding here? |
Description
fe 934 asks that NGO users only see opportunities belonging to their own organisation. Today
GET /opportunityapplies no caller-based scoping at all, so an NGO user sees every organisation's.The issue says both "their own operator (their email domain)" and "for their own shelter", which give different lists. Nadav settled it on fe 950: "anyone from the same NGO/agent can see the opportunities there of that NGO/agent". So this scopes by agent.
agent.organization_idis NULL for every agent on local and dev so that operator scoping would return an empty list for everyone today anyway.GET /opportunitynow resolves the caller's agent memberships server-side and restricts the result to those agents when the caller's role is AGENT. Coordinators and admins are unaffected.Only ACTIVE memberships count. A PENDING membership is one a coordinator has not approved yet, so it grants nothing.
fe 950 is the frontend half and needs this to be safe to ship.
Related Issues
fe 934
fe 950
Changes
getCallerAgentIdsinsrc/server/utils/data/: a person's ACTIVE agent memberships, dedupedGET /opportunity: when the caller is an AGENT, constrainwhere.agent.idto those IDs
through to an unfiltered query, so the failure mode is closed, not open
src/test/server/routes/opportunity-agent-scope.routes.test.tsChecklist