Project Members

Assignment of org users to projects. Members can be added from existing org users or imported directly from the Microsoft Entra directory. Access on a project is decided by the member’s role on that project: a role set for them there, else their company-wide role.

One picker, two kinds of person. The Add-Member dialog used to be two tabs over two fetches — provisioned users behind one, an on-demand Entra pull behind the other. It is now a single search box over a single list: GET /v1/orgs/{orgId}/users with include_directory=true returns both halves in one paginated union, directory people carrying source: "directory" and sitting under a separator. Which of the two flows below runs is decided by the row the admin presses Add on, not by a tab they picked first.

Data Model

ColumnTypeDescription
idUUIDPrimary key
project_idUUIDForeign key to projects
user_idUUIDForeign key to users
org_idUUIDForeign key to organizations
created_atTIMESTAMPTZAuto-set on insert
updated_atTIMESTAMPTZAuto-updated via trigger

Unique constraint on (project_id, user_id) prevents duplicate assignments. The ON CONFLICT DO NOTHING pattern in the AddProjectMember query silently handles re-assignment attempts.

Add Existing User

Assigns an org user who already exists in the users table.

  1. Admin presses Add on a merged-list row already provisioned in this org (no source: "directory")
  2. Server Action calls POST /v1/orgs/{orgId}/projects/{projectId}/members
  3. Go handler verifies admin role, inserts via AddProjectMember query
  4. Duplicate assignments are silently ignored (ON CONFLICT DO NOTHING)

Add from Entra Directory

Imports a user from the organization’s Microsoft Entra directory and assigns them to the project in one step.

Flow

  1. Admin opens the member assignment dialog
  2. Directory people arrive already merged in the same GET /v1/orgs/{orgId}/users?include_directory=true response that carries the provisioned ones — there is no second, on-demand fetch behind a tab
  3. That directory half is served from a server-side snapshot refreshed in the background (not a live Graph call on dialog open). If the snapshot cannot be loaded the request still succeeds with the provisioned users and sets one of two fields: directory_error when the fault is in the customer’s own Entra, directory_system_error when the fault is Lacroo’s. A refreshed snapshot reaches open dialogs over the org WebSocket
  4. Admin presses Add on a row carrying source: "directory"
  5. Server Action calls POST /v1/orgs/{orgId}/projects/{projectId}/members/from-entra
  6. Go handler:
    • Creates a domain user record via CreateUserFromEntra query (role: unassigned, status: pending)
    • If the user already exists (same email + org), updates their entra_object_id via ON CONFLICT upsert
    • Adds the user as a project member via AddProjectMember
    • (TODO) Triggers a Temporal MemberInviteNotification workflow to send an invitation email
  7. New user appears in the members list with pending status

CreateUserFromEntra Query

INSERT INTO users (org_id, entra_object_id, name, email, role, status)
VALUES ($1, $2, $3, $4, 'unassigned', 'pending')
ON CONFLICT (org_id, email) DO UPDATE SET entra_object_id = EXCLUDED.entra_object_id
RETURNING *;

This ensures idempotent user creation — re-importing the same Entra user updates their object ID without creating duplicates.

Remove Member

  1. Admin clicks remove on a member row
  2. Server Action calls DELETE /v1/orgs/{orgId}/projects/{projectId}/members/{userId}
  3. Go handler deletes the project_members record. The same statement also removes any role set for them on this project, so adding them back later starts from the role picked then. Their company-wide role and their roles on other projects stay.
  4. Member removed from list; their project count decremented

Non-Admin Visibility

Non-admin users can only see projects they are members of. The ListProjectsByUserAssignment query uses INNER JOIN project_members to filter the project list. The GetProject endpoint checks IsUserProjectMember before returning project detail.

API Endpoints

MethodPathDescription
GET/v1/orgs/{orgId}/projects/{projectId}/membersList project members
GET/v1/orgs/{orgId}/projects/{projectId}/members/optionsMembers as a person picker shows them: id, name, email, role
POST/v1/orgs/{orgId}/projects/{projectId}/membersAdd existing user
POST/v1/orgs/{orgId}/projects/{projectId}/members/from-entraAdd from Entra directory
DELETE/v1/orgs/{orgId}/projects/{projectId}/members/{userId}Remove member
GET/v1/orgs/{orgId}/users?include_directory=trueThe dialog’s one people fetch — provisioned users and directory candidates in a single union

Every person picker (supervisor, notify, raised-by, custom-field person fields, on web and mobile) reads /members/options, not the full list. The Members tab, the add-member dialog, roll-call and the management screens read /members. The project detail (GET /projects/{projectId}) carries no member list.

Members are listed A–Z by name, ignoring case. A member with no name is listed by their email. Members with the same name are listed by email.

Search members (above the table on web, under the title on mobile) narrows the list by name, email or role as you type. When nobody matches, the list says so. The org’s Edit Project → Members tab has the same search.

GET …/members and GET …/members/options return members in this order (ListProjectMembers), so person pickers are A–Z too.

Members Table Columns

Name, Email, Role.

The Role column is the member’s role on this project: the role set for them here, else their company-wide role. This is assigned_role_* on each member (owner ruling 2026-09-24).

ScreenWhat it shows
Project admin (Config, Members; mobile too)The role on this project, and only that
Org admin (Edit Project, Members)The role on this project. When their company-wide role is different, “Company-wide: X” under it
Org admin (Users, Field Users)The company-wide role. Under it, “X on Project” for each project where their role is different
Project admin (All Org Field Users; mobile too)Each person’s role on this project (owner ruling K, 2026-09-25). Nothing about other projects

“Different” is decided once, in roleassign.RoleOnProject. The member list sends company_role_* only when it is different, and the Users lists send project_roles only for those projects (limited to the projects the viewer can see). No screen compares roles itself.

role on each member is the legacy key of that same role on this project (roleassign.LegacyRoleOnProject; a custom role maps to the closest preset for its avenues). Pickers that still filter by role read it.

Everywhere else follows the role on the project

A role set on a project replaces the company-wide role there, for permissions and for every list that asks what somebody is on that project:

  • Supervisor and engineer pickers (GET …/eligible-members, the associated-supervisor check, docket import name matching).
  • The mobile supervisor lists (start shift, QR sign-on, raise issue, dockets, voice diary) and role-filtered member pickers, through role above.
  • The Alerts report and its supervisor picker.
  • Field users due for emergency details, ERP export alert recipients, and crew-eligible workers.
  • The project’s Field Users list and its role filter, and the active-project field role on mobile (membership_role on GET /me/permissions).
  • Whether assigning a worker to a per-day rate line makes it a machine (only an Operator on this project does).
  • The role in the Add Member response.

So a Labour worker made Supervisor on one project is offered as a supervisor there and nowhere else, and is not one of that project’s field users. A Supervisor made Labour on one project is not offered there.

These all read the role the same way: the role set for somebody on the project, and their company-wide role only when nothing is set there. Picking “the first role, the one set here first” is not the same thing when the query also filters by avenue: a Supervisor role set here would drop out and the company-wide Labour role would answer. TestEveryProjectScopedRoleReader_ReadsTheRoleOnThatProject fails any project query that reads the company-wide role any other way.

Changing a Member’s Role

On a project’s member list, a role change is always a change to the member’s role on this project. Their company-wide role and every other project stay as they are. The company-wide role is changed only on the org admin’s Users screen.

Every row calls PUT /v1/orgs/{orgId}/role-assignments/{userId}/projects/{projectId} (setUserProjectRoles). It is written as a per-project override under the member’s own org. users.role is untouched.

role_scope still comes from roleassign.ScopeForProjectMember (the member’s home org compared with the project’s org). It now decides only which roles the picker offers:

role_scopeWhoRoles offered
orgHome org is the project’s orgEvery role the admin may grant of the same kind as their company-wide role
projectHome org is another org (e.g. a supplier’s worker on site)The field-worker presets the admin may grant (plus their current)

For project scope the API refuses an office role or a custom role (400, “Only field roles can be given to someone from another company.”). For both it refuses more than one role (400, “Pick one role.”). A user who is not on the project gets 404. DELETE on the same path removes the override, so they hold their company-wide role here again.

Picking the member’s company-wide role on the row removes the role set here (the DELETE above). They then hold their company-wide role here and follow it when it changes. This is what Add Member does when that role is picked.

The web row refuses to change somebody from this company who already holds more than one role on this project, and points at Users. One pick would replace them all.

Office and field roles never swap

Owner ruling H (2026-09-25). A role on a project is the same kind as the person’s company-wide role. An office role can be changed to another office role on a project, never to a field role, and a field role only to another field role. The kind is the avenue split the field pickers already use: a role with any of web_org, web_admin, mobile_office or web_client is office, every other role is field (roleassign.KindOfRole).

  • Every writer of a role on a project checks it first (roleassign.CheckProjectRolesAgainstCompanyRole): this row, Add Member, the New Project wizard, the project Field Users edit and the org’s per-project editor. A role of the other kind is a 400 and nothing is written: “This person has an office role. Pick an office role for this project.” or “This person has a field role. Pick a field role for this project.”
  • Every picker offers only the same kind (sameKindRoles on web).
  • When the company-wide role changes to the other kind, their project roles of the old kind are removed in the same write, so they hold the new company-wide role there. This includes a temporary role change and its revert.
  • A role equal to the company-wide role is never stored on a project (owner ruling I). Picking it clears the project’s role, so they follow the company-wide role there.
  • TestEveryProjectRoleWriterChecksTheCompanyRoleFirst fails any new writer that skips either rule.

On prod on 2026-09-25 nobody held a project role of the other kind (0 of 148). The 105 project roles that were copies of the company-wide role are removed by migration 20260924192410_i_remove_project_roles_same_as_company_role.sql, with one audit row each.

Adding with a role

The Add Member dialog sends the picked role as project_role_id. The server adds the member and, when the role differs from their company-wide role, sets it as their role on this project, in the same transaction and under the same escalation guard. Picking their company-wide role writes nothing. role_id still changes the company-wide role. Sending both is a 400 (“Pick one role.”).

The org admin’s New Project wizard sends project_role_id too, for somebody already in Lacroo (owner ruling J, 2026-09-25). The picker is labelled “Role on this project” and offers only roles of the same kind as their company-wide role. Somebody new from the SSO directory is created with the role picked, as their company-wide role.

Security

  • Permissions: Adding and removing members needs projects.manage_members. Changing a role in the row needs users.assign_roles and reach to the project. Every grant, including a role picked on add, runs the escalation guard: nobody grants above their own role.
  • Tenant isolation — org_id column on project_members prevents cross-tenant access. All queries include org check.
  • Unique constraint — Database prevents duplicate (project, user) assignments.
  • Entra secret handling — Client secrets decrypted in-memory only during Graph API calls, never logged or returned.
  • Membership verification — Non-admin project access verified via IsUserProjectMember query.

Key Files

FilePurpose
backend/db/queries/project_members.sqlsqlc queries: add, remove, list, membership check
backend/db/queries/users.sqlCreateUserFromEntra query (upsert)
backend/api/internal/project/handler.goMember HTTP handlers (list, options, add, add from Entra, remove)
backend/api/internal/project/service.goMember management and Entra Graph API integration
apps/web/src/actions/projects.tsaddProjectMember, addProjectMemberFromEntra, removeProjectMember