Auth & RBAC
SipSop uses Better Auth with the organizations plugin for authentication and multi-tenancy. This page documents the data model, the four top-level roles, MSP sub-roles, permissions, and how to create or manage users.
Architecture overview
Better Auth organization (1 org = 1 tenant)
└── member rows (userId + organizationId + role)
└── invitation rows (pending org invitations)
user.role (global role — stored on the user row)
→ 'admin_msp' — Sopinf Tech LLC team
→ 'client_admin' — Tenant owner/billing contact
→ 'client_user' — Employee at tenant
→ 'seller' — External commission rep (seller portal only)
user.msp_roles (array — only meaningful when user.role = 'admin_msp')
→ ['super_admin', 'billing_clerk', 'technician', 'sales_rep']Top-level roles
admin_msp
Sopinf Tech LLC internal team. Can access the /admin/* section of the portal. Required to reach any adminProcedure endpoint in the API.
- Set by: manually in the DB (
UPDATE "user" SET role = 'admin_msp' WHERE email = 'x') or via the MSP team invitation flow (msp_invitationstable). - Not org-scoped —
admin_mspusers can see all tenants cross-org. - Sub-roles (
msp_rolesarray) further restrict which admin actions they can perform.
client_admin
The primary contact/owner for a tenant. Full access to their tenant's portal: CDR, billing, team management, settings. Can invite client_user members via the Better Auth org invitation flow.
- Created during tenant onboarding.
member.role = 'owner'or'admin'in the Better Authmembertable.
client_user
An employee at a tenant. Read-only access to dashboard and CDR. Cannot manage billing or team.
- Invited by
client_adminvia the portal team page. member.role = 'member'in the Better Authmembertable.
seller
External sales rep with access to the seller portal (/seller/* routes) only. No access to the main portal or admin section.
- Granted via magic-link invitation (see Seller invitations below).
- Has a corresponding row in
commission_repslinked viacommission_reps.user_id. - Auth checked by
sellerProcedure: requiresuser.role = 'seller'AND an activecommission_repsrow.
MSP sub-roles (admin_msp only)
MSP sub-roles are stored as a text array in user.msp_roles. A super_admin can hold all roles. Sub-roles are stored in msp_role_definitions and mapped to permissions via msp_role_permissions.
| MSP sub-role | Typical permissions |
|---|---|
super_admin | All permissions. Can change carrier mode, manage all admin functions. |
billing_clerk | CRUD on fees, compliance dashboard, quarterly reports, invoice line items. |
sales_rep | Read tenants, read leads, manage commissions. |
technician | Read tenants, manage PBX/inventory (Fase 11). |
Sub-roles are checked in trpc.ts via makeMspRoleProcedure(allowed):
export const billingClerkProcedure = makeMspRoleProcedure(['super_admin', 'billing_clerk'])
export const salesProcedure = makeMspRoleProcedure(['super_admin', 'sales_rep'])
export const technicianProcedure = makeMspRoleProcedure(['super_admin', 'technician'])
export const superAdminProcedure = adminProcedure.use(async ({ ctx, next }) => {
if (!ctx.mspRoles.includes('super_admin')) throw new TRPCError(...)
...
})Permission system
Permissions are loaded in createContext as a Set<string> (ctx.mspPermissions). They are computed from the union of all permissions granted to the user's MSP sub-roles.
Known permissions (from navigation config):
| Permission key | Used by |
|---|---|
tenants.read | View tenant list and detail |
leads.read | View leads |
tenant_requests.read | View and manage tenant requests |
impersonation.read_log | View impersonation log |
team.read | View and manage MSP team |
roles.read | View and manage MSP roles |
commissions.manage | Full commissions admin access |
Permissions are checked in the API via requirePermission(permissionKey):
export function requirePermission(permissionKey: string) {
return adminProcedure.use(async ({ ctx, next }) => {
if (!ctx.mspPermissions.has(permissionKey)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'MISSING_PERMISSION' })
}
return next({ ctx })
})
}tRPC procedure types
| Procedure | Auth requirement | Tenant gate |
|---|---|---|
publicProcedure | None | None |
protectedProcedure | Valid session | Yes — blocks suspended/cancelled |
billingProcedure | Valid session | No — allows suspended tenants to pay |
adminProcedure | Session + role = 'admin_msp' | No (admin is cross-tenant) |
superAdminProcedure | adminProcedure + 'super_admin' in msp_roles | No |
billingClerkProcedure | adminProcedure + billing_clerk or super_admin | No |
salesProcedure | adminProcedure + sales_rep or super_admin | No |
technicianProcedure | adminProcedure + technician or super_admin | No |
sellerProcedure | protectedProcedure + role=seller + active commission_rep | Seller-specific |
tenantAdminProcedure | protectedProcedure + activeMembershipRole=client_admin | Yes |
impersonationAwareProcedure | protectedProcedure + NOT in impersonation session | Yes |
Database tables
user (Better Auth managed)
id — text PK (Better Auth generates this)
name — display name
email — unique
email_verified — boolean
role — 'admin_msp' | 'client_admin' | 'client_user' | 'seller'
msp_roles — text[] (sub-roles for admin_msp users)
is_active — boolean (soft delete)
deactivated_at — timestamp (when deactivated)organization (Better Auth managed)
id — text PK
name — tenant display name
slug — unique URL-safe slugmember (Better Auth managed)
organization_id — FK → organization
user_id — FK → user
role — 'owner' | 'admin' | 'member' (Better Auth roles within org)msp_role_definitions
key — PK: 'super_admin' | 'billing_clerk' | 'sales_rep' | 'technician'
label — display label
description — description
color — badge color in UI
is_system — boolean (system roles cannot be deleted)msp_permissions
key — PK: 'tenants.read', 'commissions.manage', etc.
category — groups permissions in the role editor UI
label — display labelmsp_role_permissions
role_key — FK → msp_role_definitions.key
permission_key — FK → msp_permissions.key(Composite PK on both columns)
Tenant gate
checkTenantGate(tenantStatus) in apps/api/src/middleware/tenant-gate.ts:
| Tenant status | Effect |
|---|---|
active | Full access |
past_due | Access granted, isPastDue = true. Portal shows a red banner. CDR and billing are accessible. |
suspended | TenantGateError — protectedProcedure throws FORBIDDEN. Only billingProcedure (for payment) works. Portal shows full-screen payment block. |
cancelled | Same as suspended |
Creating a new admin_msp user
- Register the user through the portal login (email + password).
- Connect to the database and run:
UPDATE "user"
SET role = 'admin_msp',
msp_roles = ARRAY['billing_clerk']::text[]
WHERE email = 'newstaff@sopinf.com';Or give full super_admin access:
UPDATE "user"
SET role = 'admin_msp',
msp_roles = ARRAY['super_admin']::text[]
WHERE email = 'newstaff@sopinf.com';- The user can now see the admin section in the portal.
Alternatively, use the portal's /admin/team → Invite flow, which creates a row in msp_invitations and sends an email. The invitee clicks the link and completes registration.
Creating a client_admin for a new tenant
- Create the tenant in
/admin/tenants/new. - The new tenant creates a Better Auth
organization. Note theorganization.id. - Register the client user through the portal.
- Set their role:
UPDATE "user"
SET role = 'client_admin'
WHERE email = 'owner@clientcompany.com';- Create the org membership:
-- Better Auth member table
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (gen_random_uuid()::text, '<org_id>', '<user_id>', 'owner', NOW());Or use the Better Auth API (POST to /api/auth/organization/add-member) — this is the preferred approach to stay within Better Auth's session management.
Seller invitations
Sellers (commission reps) access the portal via a magic-link invitation. The flow:
- An
admin_mspuser withcommissions.managecreates a commission rep in/admin/commissions/vendedores. - From the rep detail view, click "Invite to portal".
- The API calls
inviteRepToPortal()inseller-invitations.service.ts:- Invalidates any existing pending invitations for this rep.
- Generates a 64-character random token (32 bytes → hex).
- Creates a row in
seller_invitationswithexpires_at = now + 7 days. - Returns the token.
- The invitation email is sent with a link to
/seller/setup?token=<token>. - The seller clicks the link, the token is validated and consumed atomically in
consumeInvitationToken(). - A new user is created with
role = 'seller'via Better Auth. linkUserToRep()setscommission_reps.user_id = newUser.id.
Re-inviting a seller: calling "Invite to portal" again invalidates existing pending tokens before creating a new one (safe to re-invite if the previous link expired).
Revoking seller access: revokeAccess() sets commission_reps.user_id = null and invalidates pending invitations. The user account remains but sellerProcedure will block them since they no longer have an active rep row.
Resetting a password
Better Auth handles password reset via email token. If email delivery is broken or a manual reset is needed:
-- Option 1: find the account row and update the hashed password
-- (Better Auth uses argon2 by default)
SELECT id FROM "user" WHERE email = 'user@example.com';
-- Then use Better Auth admin API to trigger a password reset email,
-- or update the 'account' table's password field with a new argon2 hash.
-- Easier: use Better Auth's admin endpoint (if configured):
-- POST /api/auth/admin/set-password
-- { userId: '...', newPassword: '...' }See Runbooks for the step-by-step procedure.
Session management
- Sessions are stored in the
sessiontable with anexpires_attimestamp. active_organization_idon the session tracks which org the user is currently viewing (for multi-tenantclient_adminusers).- Session tokens are stored in Redis with a matching TTL for fast auth verification.
-- List active sessions for a user
SELECT id, expires_at, ip_address, user_agent
FROM session
WHERE user_id = '<user_id>'
AND expires_at > NOW()
ORDER BY created_at DESC;
-- Invalidate all sessions for a user (force logout)
DELETE FROM session WHERE user_id = '<user_id>';