Skip to main content
services/api/members_api.py is a deliberately small router: one endpoint, read-only. Its own module docstring says plainly what it is — “Tenant-facing member directory: real users rows for the current session’s tenant, each with the role_profile label(s) they hold. Read-only today.” There is no tenant-facing invite, deactivate, or role-change endpoint anywhere in this router.

GET /members

The only route (members_router, prefixed /members), and the only member operation in services/api/product_app.py’s allowlist (MEMBER_OPERATIONS = frozenset({("GET", "/members")})). Requires an authenticated tenant session, nothing more — no special permission beyond being logged in.
The handler (list_members) queries every TenantUser row for the tenant, ordered by created_at, then joins user_rolesrole_profiles to build a roles: list[str] per user from each RoleProfile.label they hold — a user can hold more than one role profile, so roles can have more than one entry (or be empty, if no role profile has been assigned). MemberSummary’s fields are exactly the six above: id, email, full_name (nullable — falls back to email for display), roles, is_active, created_at. Nothing else about a member is exposed here — no password hash, no session state, no permission list, only the human-readable role labels.

How the console shows members

SettingsView.tsx’s “Members” tab (MembersPanel) is the only consumer of listTenantMembers() (src/lib/api/tenantMembers.ts, a thin apiGet<TenantMember[]>("/members") wrapper). It renders one row per member:
  • An Avatar with the member’s initials (first letter of up to their first two name words, computed client-side from full_name, falling back to email when full_name is empty) and a rotating color variant ([undefined, 4, 2, 3][index % 4]) so a member list doesn’t render every avatar in the same color.
  • Name (full_name or email) with email as the subtitle underneath.
  • Role profiles as a joined, comma-separated string (member.roles.join(", ")), or the literal text “No role profile” when the array is empty.
  • A status chip — “Active” (ok tone) or “Inactive” (neutral tone) from is_active.
Directly beneath the list, the panel prints a fixed disclosure: “Invitations and role changes remain staff-managed in the MVP.” That line is not a placeholder pending a future feature flag — it accurately describes the current product boundary (below).

What doesn’t exist here

There is no tenant-facing surface — API or console — for:
  • Inviting a new member. Inviting a tenant user is exclusively a staff (employee-authenticated) action: POST /admin/tenants/{tenant_id}/invite (services/api/onboarding_api.py), which takes an email and a role_profile_key (defaulting to org_admin) and enqueues an invitation email; GET /admin/tenants/{tenant_id}/invitations lists invitations with their status (pending by default, accepted once redeemed — models.py’s TenantInvitation and auth/tenant_auth.py’s accept flow never set any other value) plus per-invitation delivery_status/delivery_attempts from the email delivery job. Both are staff portal operations, not part of the tenant console’s /members surface — see Invitations and impersonation.
  • Changing a member’s role profile, or deactivating a member. list_members never writes anything; there is no PATCH/PUT/DELETE in members_api.py at all.
  • Self-service acceptance beyond POST /accept-invite — an invited user redeems their invitation token through auth_api.py’s accept-invite flow, which creates the TenantUser/UserRole rows this endpoint later reads, but that flow lives in authentication, not here.
In other words: today’s member surface answers “who is on this tenant’s roster, and what are they allowed to do” for display purposes only. Every write that changes that roster — inviting, assigning a role profile, deactivating an account — is a staff action taken from the onboarding/admin portal, on behalf of the tenant, not something a tenant user can trigger from their own console.

Underlying schema

/members is a straightforward join across three tenant-schema tables (services/api/db/models.py), all reachable only inside a tenant_uow() session (see Tenancy and data model for how schema-per-tenant plus row-level security keeps one tenant’s roster from ever leaking into another’s query): list_members reads all three in one tenant_uow() block: every TenantUser ordered by created_at, then a single joined user_rolesrole_profiles query for every (user_id, label) pair in the tenant, folded client-side into a dict[UUID, list[str]] before the response is built — not one role query per user.

How a member gets here

A row only appears in /members once a real TenantUser exists, and that only happens through the staff-driven invite → accept flow: POST /admin/tenants/{tenant_id}/invite (staff/employee-authenticated) takes email and role_profile_key (defaulting to "org_admin"), and enqueues a delivery job rather than sending mail inline. POST /accept-invite (auth_api.py’s tenant_accept_invite) is the only route that actually inserts the users/user_roles rows this directory reads — it takes the workspace slug, the raw invite token, a new password (minimum 8 characters), and an optional full_name, and on success sets both the session cookie and the CSRF cookie so the newly-created member is immediately signed in.

Role profiles

roles on a MemberSummary are RoleProfile.label values — human-readable labels for the tenant’s seeded role_profiles (org_admin, risk_admin, analyst, viewer). The console’s own client-side permission gates (canReview/canOperate in src/lib/session.ts) operate on a separate WorkspaceRole rank (Auditor < Analyst < Loop Owner < Risk Admin) tied to the authenticated session, not directly on this endpoint’s roles array — /members is a read-only directory view of the roster, not the mechanism that gates what the current user can do in the console. RoleProfile.permissions (the JSONB array backing server-side checks like caps.write, sources.manage, exports.create) is likewise never serialized into MemberSummary — the directory shows who has which named role, not the raw permission strings behind it.