fix(employee): list a non-admin's colleagues instead of refusing the members read

Merged
EV
18d ago
+521-47

Problem

pm.gauzy.co (the Plane UI) shows no members for non-admins. Project member pages are empty, assignee pickers are empty, and "Add member" finds nobody. The browser gets:

403 {"message":"You can only list the members of a team you belong to.","error":"Forbidden"}

#68c39ed92c (2026-10-10, in prod since the 2026-10-26 release) scoped GET /employee/members and the employeeMembers GraphQL field. A caller without ALL_ORG_VIEW or CHANGE_SELECTED_EMPLOYEE must name a team they belong to, otherwise the request is refused. The Plane proxy loads a workspace's members with GET /employee/members?organizationId=…, with no team. Project members and every picker render from that list.

In the prod PM tenant only the 2 SUPER_ADMINs can see members. 42 EMPLOYEE users and 1 MANAGER see none. That includes the owner's day-to-day account ruslan@ever.co, which is EMPLOYEE (checked with a read-only DB query).

Fix (owner decision: "shared team/project")

The privacy intent of 68c39ed92c is kept: a non-admin never lists the whole organisation. Instead of a 403, they get the people they work with.

Caller without an organisation-wide viewBeforeAfter
No team or project named403the people they share an active team or project with, themselves included
Their own team namedthat teamunchanged
Another team named403unchanged (403)
Project named, no team403that project's members if they belong to it, else 403
No employee record403unchanged (403)
  • Organisation-wide viewers (SUPER_ADMIN, ADMIN, CHANGE_SELECTED_EMPLOYEE): unchanged.
  • Ever Teams: unaffected, because it sends its active team.
  • One rule: ManagedEmployeeService.scopeMembersRead is shared by the REST route and the GraphQL field. New helpers: isMemberOfProject and findColleagueEmployeeIds. Both are tenant-scoped, use the same nullable-flag "active membership" predicate as isMemberOfTeam, and fail closed ([NO_ACCESSIBLE_EMPLOYEE_ID]) when the tenant is unknown.
  • findMembers(options, employeeIds?): the colleague ids are an internal argument, not part of FindMembersInputDTO, so a client cannot pass them. An empty list matches no one and is never read as "no filter".
  • The 403 message now reads "…a team or project you belong to."

What prod users would see (read-only query, Ever Tech organisation, 19 active employees):

  • ruslan@ever.co: 12 people.
  • Non-admins: 1–16 people, median 7.
  • 3 non-admins with no team or project see only themselves.
  • Project member lists are complete, because every project member shares that project with the viewer.

Tests

  • New managed-employee.service.members-scope.spec.ts covers:
    • the colleague union, de-duplicated with the caller included;
    • the full tenant and active-flag predicates;
    • no second read when the caller has no memberships;
    • a named team or project being caller-only, and never widened;
    • team taking precedence over project;
    • refusal for a caller without an employee record;
    • fail-closed when the tenant is unknown.
  • New employee.controller.members-scope.spec.ts:
    • route: colleagues when no team is named, an own team unnarrowed, refusals before any read, and the org-wide control;
    • findMembers id filter: present when ids are given, NO_ACCESSIBLE for an empty list, absent otherwise.
  • employee.resolver.spec.ts: the field's scope tests are updated to the new behaviour.
  • Fails before the fix: the resolver's "names no team → colleagues" and narrowing tests fail on develop's code, and the controller spec doesn't compile against the old findMembers signature.
  • Result: node --experimental-vm-modules node_modules/jest/bin/jest.js -c packages/core/jest.config.ts --runInBand on the employee specs: 7 suites, 134 tests pass, all type-checked by ts-jest.

🤖 Generated with Claude Code


Summary by cubic

Fixes member listings for non-admins in the Plane UI (pm.gauzy.co), where project member pages and assignee pickers were empty because GET /employee/members refused any read without a named team. A non-admin who names neither a team nor a project now gets the people they share an active team or project with, themselves included, instead of a 403.

Scoping rules

  • A named team must still be the caller's own; a named project is allowed only if the caller belongs to it.
  • Only memberships in effect count: teams and projects that are active, not archived, and not deleted; a named-project read is narrowed to the project's memberships in effect.
  • A caller without an employee record is still refused, and org-wide viewers see the whole org as before.
  • Ever Teams is unaffected because it sends its active team.
  • The scope lives in ManagedEmployeeService.scopeMembersRead, shared by the REST route and the employeeMembers GraphQL field; findMembers takes colleague ids as an internal argument, so clients can't pass them and an empty list matches no one.