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.
list_members) queries every TenantUser row for the tenant, ordered by created_at, then joins user_roles → role_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
Avatarwith the member’s initials (first letter of up to their first two name words, computed client-side fromfull_name, falling back to email whenfull_nameis 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_nameor 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” (
oktone) or “Inactive” (neutraltone) fromis_active.
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 anemailand arole_profile_key(defaulting toorg_admin) and enqueues an invitation email;GET /admin/tenants/{tenant_id}/invitationslists invitations with theirstatus(pendingby default,acceptedonce redeemed —models.py’sTenantInvitationandauth/tenant_auth.py’s accept flow never set any other value) plus per-invitationdelivery_status/delivery_attemptsfrom the email delivery job. Both are staff portal operations, not part of the tenant console’s/memberssurface — see Invitations and impersonation. - Changing a member’s role profile, or deactivating a member.
list_membersnever writes anything; there is noPATCH/PUT/DELETEinmembers_api.pyat all. - Self-service acceptance beyond
POST /accept-invite— an invited user redeems their invitation token throughauth_api.py’s accept-invite flow, which creates theTenantUser/UserRolerows this endpoint later reads, but that flow lives in authentication, not here.
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_roles → role_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.