Membership
How users are linked to organizations through member records, roles, and the member tRPC router.
A membership links a user to an organization with a specific role. The member table is the central record that authorizedProcedure reads on every request to determine what the user can do.
The Member Table
For the full schema definition, see Data & Storage -- Schema Design. The key columns are:
| Column | Type | Purpose |
|---|---|---|
id | text (PK) | Better Auth-generated ID |
organizationId | text (FK -> organization) | Which organization this membership belongs to |
userId | text (FK -> user) | Which user holds the membership |
role | text (default: "member") | One of owner, admin, or member |
createdAt | timestamp | When the membership was created |
The member table has RLS enabled via tenantPolicies("member", "organization_id"). This means member records are only visible within the context of the organization they belong to. A user querying members from organization A cannot see members from organization B, even if they are a member of both.
Roles
| Role | Created By | Permissions |
|---|---|---|
owner | System (bootstrapping) or Better Auth org creation | Full access (manage all). Cannot be demoted or removed. |
admin | Owner promoting a member | Read/update org, full member management, invitation management |
member | Invitation acceptance or admin assignment | Read-only on org/members/invitations. Full access to research features. |
The role-to-permission mapping is defined in defineAbilityFor in packages/api/src/abilities/define-ability.ts. For the complete matrix, see Identity & Access -- Authorization.
Unknown roles (any string not matching owner, admin, or member) default to read-only access on Organization and Member subjects.
Member Router
Location: packages/api/src/routers/member.ts
The member router provides three endpoints, all using authorizedProcedure:
member.list
Lists all members of the active organization, including basic user information (id, name, email, image).
- CASL check:
ability.cannot("read", "Member")-- all roles can read - RLS: The query runs inside
withTenantContext, so only members of the active org are returned - Includes: Joined
userrecord withid,name,email,image
member.updateRole
Changes a member's role. Validates against two constraints:
- CASL check:
ability.cannot("update", "Member")-- requires admin or owner role - Owner protection: If the target member's current role is
"owner", the mutation is rejected with"Cannot change an owner's role"
The input schema (updateMemberRoleSchema from @repo/validators) restricts the new role to "admin" or "member" -- you cannot promote someone to "owner" through this endpoint.
// packages/validators/src/member.ts
export const updateMemberRoleSchema = z.object({
memberId: idSchema,
role: z.enum(["admin", "member"]),
});
member.remove
Removes a member from the organization. Two paths to removal:
- Self-removal: Any member can remove themselves (
target.userId === ctx.session.user.id), bypassing the CASL check - Admin removal: Users with
deletepermission onMember(admins and owners) can remove other non-owner members
Owner protection: Owners cannot be removed. This prevents an organization from losing its last owner.
How Membership Connects to Authorization
On every authorizedProcedure request, the middleware:
- Looks up the member record for the current user in the active organization
- Reads the organization type
- Builds a CASL ability from
{ userId, role, orgType } - Attaches
ctx.memberandctx.abilityto the context
If no member record is found, the request fails with FORBIDDEN. This means a user who is not a member of an organization cannot make any tenant-scoped requests against it, even if they somehow set activeOrganizationId to that organization's ID.
For the full middleware chain, see Data & Storage -- Procedure Chain.
Invitations (Not Yet Implemented)
The invitation table exists in the schema with RLS enabled, and CASL defines Invitation as a subject, but no invitation router has been built yet. This is deferred until family/company organization creation is added. The schema is in place so that:
- The database migration is already done
- CASL rules already account for invitation permissions
- The
invitationtable is already tenant-scoped viatenantPolicies
When invitations are implemented, they will follow the same pattern as the member router: authorizedProcedure with CASL checks, operating on the tenant-scoped ctx.db.