Skip to content

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_invitations table).
  • Not org-scoped — admin_msp users can see all tenants cross-org.
  • Sub-roles (msp_roles array) 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 Auth member table.

client_user

An employee at a tenant. Read-only access to dashboard and CDR. Cannot manage billing or team.

  • Invited by client_admin via the portal team page.
  • member.role = 'member' in the Better Auth member table.

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_reps linked via commission_reps.user_id.
  • Auth checked by sellerProcedure: requires user.role = 'seller' AND an active commission_reps row.

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-roleTypical permissions
super_adminAll permissions. Can change carrier mode, manage all admin functions.
billing_clerkCRUD on fees, compliance dashboard, quarterly reports, invoice line items.
sales_repRead tenants, read leads, manage commissions.
technicianRead tenants, manage PBX/inventory (Fase 11).

Sub-roles are checked in trpc.ts via makeMspRoleProcedure(allowed):

ts
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 keyUsed by
tenants.readView tenant list and detail
leads.readView leads
tenant_requests.readView and manage tenant requests
impersonation.read_logView impersonation log
team.readView and manage MSP team
roles.readView and manage MSP roles
commissions.manageFull commissions admin access

Permissions are checked in the API via requirePermission(permissionKey):

ts
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

ProcedureAuth requirementTenant gate
publicProcedureNoneNone
protectedProcedureValid sessionYes — blocks suspended/cancelled
billingProcedureValid sessionNo — allows suspended tenants to pay
adminProcedureSession + role = 'admin_msp'No (admin is cross-tenant)
superAdminProcedureadminProcedure + 'super_admin' in msp_rolesNo
billingClerkProcedureadminProcedure + billing_clerk or super_adminNo
salesProcedureadminProcedure + sales_rep or super_adminNo
technicianProcedureadminProcedure + technician or super_adminNo
sellerProcedureprotectedProcedure + role=seller + active commission_repSeller-specific
tenantAdminProcedureprotectedProcedure + activeMembershipRole=client_adminYes
impersonationAwareProcedureprotectedProcedure + NOT in impersonation sessionYes

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 slug

member (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 label

msp_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 statusEffect
activeFull access
past_dueAccess granted, isPastDue = true. Portal shows a red banner. CDR and billing are accessible.
suspendedTenantGateErrorprotectedProcedure throws FORBIDDEN. Only billingProcedure (for payment) works. Portal shows full-screen payment block.
cancelledSame as suspended

Creating a new admin_msp user

  1. Register the user through the portal login (email + password).
  2. Connect to the database and run:
sql
UPDATE "user"
SET role = 'admin_msp',
    msp_roles = ARRAY['billing_clerk']::text[]
WHERE email = 'newstaff@sopinf.com';

Or give full super_admin access:

sql
UPDATE "user"
SET role = 'admin_msp',
    msp_roles = ARRAY['super_admin']::text[]
WHERE email = 'newstaff@sopinf.com';
  1. 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

  1. Create the tenant in /admin/tenants/new.
  2. The new tenant creates a Better Auth organization. Note the organization.id.
  3. Register the client user through the portal.
  4. Set their role:
sql
UPDATE "user"
SET role = 'client_admin'
WHERE email = 'owner@clientcompany.com';
  1. Create the org membership:
sql
-- 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:

  1. An admin_msp user with commissions.manage creates a commission rep in /admin/commissions/vendedores.
  2. From the rep detail view, click "Invite to portal".
  3. The API calls inviteRepToPortal() in seller-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_invitations with expires_at = now + 7 days.
    • Returns the token.
  4. The invitation email is sent with a link to /seller/setup?token=<token>.
  5. The seller clicks the link, the token is validated and consumed atomically in consumeInvitationToken().
  6. A new user is created with role = 'seller' via Better Auth.
  7. linkUserToRep() sets commission_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:

sql
-- 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 session table with an expires_at timestamp.
  • active_organization_id on the session tracks which org the user is currently viewing (for multi-tenant client_admin users).
  • Session tokens are stored in Redis with a matching TTL for fast auth verification.
sql
-- 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>';

SipSop documentation. Product operated by Sopinf Tech LLC.