Files
pn-new-crm/10-AUTH-AND-PERMISSIONS.md
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00

19 KiB

Port Nimara CRM — Authentication & Permission Flows

Compiled: 2026-03-11 Auth Library: Better Auth (integrated into Next.js) Session: httpOnly secure cookies with CSRF protection


1. Authentication Flows

1.1 Login Flow

User enters email + password on /login
  → POST /api/auth/login { email, password }
  → Better Auth validates credentials (argon2/bcrypt hash comparison)
  → On success:
      → Create session in PostgreSQL (via Better Auth)
      → Set httpOnly secure cookie with session ID
      → Load user_port_roles to determine accessible ports
      → If single port: redirect to dashboard for that port
      → If multiple ports: redirect to port selector
      → If no ports assigned: show "Contact your administrator" message
  → On failure:
      → Return 401 with generic "Invalid credentials" (no info leak)
      → Rate limit: 5 failed attempts per email per 15 minutes → temporary lockout

1.2 Session Management

Every authenticated request:
  → Middleware reads session cookie
  → Validates session in PostgreSQL (via Better Auth)
  → Extracts user ID from session
  → Loads user_port_roles for current port (from X-Port-Id header or session)
  → Attaches to request context: { userId, portId, role, permissions }
  → If session expired or invalid: return 401

Session configuration:

  • Session duration: 24 hours (configurable)
  • Session refresh: on every request within last 25% of duration
  • Concurrent sessions: allowed (multiple devices)
  • Session revocation: logout destroys session, admin can revoke all sessions for a user

1.3 Password Set (First Login)

Admin creates user account
  → System generates secure token (UUID + HMAC)
  → Token stored in DB with expiry (48 hours)
  → Email sent via Poste.io: "Set your password" with link to /auth/set-password?token=xxx
  → User clicks link → /auth/set-password page
  → POST /api/auth/password/set { token, password, password_confirm }
  → Validate token (not expired, not used)
  → Hash password with argon2
  → Update user record
  → Invalidate token
  → Redirect to /login

1.4 Password Reset

User clicks "Forgot password" on /login
  → POST /api/auth/password/reset-request { email }
  → If email exists: generate token, send email
  → If email doesn't exist: return same success response (no info leak)
  → Email sent via Poste.io: "Reset your password" with link
  → Same flow as 1.3 from the link click onward

1.5 Logout

User clicks logout
  → POST /api/auth/logout
  → Destroy session in PostgreSQL
  → Clear session cookie
  → Redirect to /login

2. Authorization Model

2.1 Three-Tier Access Levels

TIER 1: Super Admin (is_super_admin = true on user_profiles)
  ├── Bypasses ALL permission checks
  ├── Bypasses port scoping (can see all ports)
  ├── Can manage global roles, system settings, all ports
  ├── Actions still logged in audit log
  └── Currently: Matt only

TIER 2: Director (system role "director")
  ├── Operational admin within assigned port(s)
  ├── Can: manage users within their port, view port-scoped audit logs,
  │   configure reminder settings/alerts, manage most operational settings
  ├── Cannot: modify system-level settings, define/modify roles,
  │   access ports they're not assigned to, restore backups
  └── Subject to normal permission checks for non-admin operations

TIER 3: Role-Based (all other users)
  ├── Permissions defined by their role at their current port
  ├── Role is a JSON permission map with boolean flags
  └── Cannot access admin panel (unless role explicitly grants admin permissions)

2.2 Permission Structure

Permissions are stored as a JSON object on each role. Structure:

{
  "clients": {
    "view": true,
    "create": true,
    "edit": true,
    "delete": false,
    "merge": false,
    "export": true
  },
  "interests": {
    "view": true,
    "create": true,
    "edit": true,
    "delete": false,
    "change_stage": true,
    "generate_eoi": true,
    "export": true
  },
  "berths": {
    "view": true,
    "edit": false,
    "import": false,
    "manage_waiting_list": true
  },
  "documents": {
    "view": true,
    "create": true,
    "send_for_signing": true,
    "upload_signed": true,
    "delete": false
  },
  "expenses": {
    "view": true,
    "create": true,
    "edit": true,
    "delete": false,
    "export": true,
    "scan_receipt": true
  },
  "invoices": {
    "view": true,
    "create": true,
    "edit": true,
    "delete": false,
    "send": true,
    "record_payment": true,
    "export": true
  },
  "files": {
    "view": true,
    "upload": true,
    "delete": false,
    "manage_folders": false
  },
  "email": {
    "view": true,
    "send": true,
    "configure_account": true
  },
  "reminders": {
    "view_own": true,
    "view_all": false,
    "create": true,
    "edit_own": true,
    "edit_all": false,
    "assign_others": false
  },
  "calendar": {
    "connect": true,
    "view_events": true
  },
  "reports": {
    "view_dashboard": true,
    "view_analytics": true,
    "export": true
  },
  "document_templates": {
    "view": true,
    "generate": true,
    "manage": false
  },
  "admin": {
    "manage_users": false,
    "view_audit_log": false,
    "manage_settings": false,
    "manage_webhooks": false,
    "manage_reports": false,
    "manage_custom_fields": false,
    "manage_forms": false,
    "manage_tags": true,
    "system_backup": false
  }
}

2.3 Permission Resolution

For any action by user U at port P:

1. If U.is_super_admin → ALLOW (always)

2. Load role R from user_port_roles WHERE user_id = U AND port_id = P
   → If no role found → DENY (user has no access to this port)

3. Load base permissions from roles WHERE id = R
   → permissions = R.permissions

4. Check for port override:
   → Load port_role_overrides WHERE port_id = P AND role_id = R
   → If override exists: deep merge override.permission_overrides INTO permissions
     (override values replace base values for matching keys)

5. Check required permission for the action:
   → e.g., "clients.edit" for editing a client
   → If permissions["clients"]["edit"] === true → ALLOW
   → Otherwise → DENY (403 Forbidden)

2.4 Pre-Built System Roles

Created on system initialization (is_system = true, cannot be deleted):

Role Description Key Permissions
super_admin Full system access All permissions true (redundant with is_super_admin flag, exists for role system completeness)
director Operational admin All operational permissions true, admin.manage_users true, admin.view_audit_log true. No system-level admin.
sales_manager Full sales access All client/interest/document/expense/invoice permissions, reminders.view_all and assign_others, calendar.connect, reports full access
sales_agent Standard sales View/create/edit clients and interests, send documents, manage own reminders, calendar.connect. No delete, no admin.
viewer Read-only access All view permissions true, everything else false

These can be customized (permissions changed) but not deleted. Admins can create additional custom roles.


3. Middleware Implementation

3.1 Auth Middleware (every route except /api/public/*)

// Pseudocode for Next.js middleware
async function authMiddleware(req) {
  // 1. Skip for public routes
  if (req.path.startsWith('/api/public/')) return next();
  if (req.path.startsWith('/auth/')) return next();

  // 2. Validate session
  const session = await betterAuth.getSession(req);
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  // 3. Load user profile
  const profile = await db.query.userProfiles.findFirst({
    where: eq(userProfiles.userId, session.userId),
  });
  if (!profile || !profile.isActive)
    return Response.json({ error: 'Account disabled' }, { status: 403 });

  // 4. Determine port context
  const portId = req.headers.get('X-Port-Id') || session.currentPortId;
  if (!portId && !profile.isSuperAdmin) {
    return Response.json({ error: 'Port context required' }, { status: 400 });
  }

  // 5. Load role for this port (skip for super admin)
  let permissions = null;
  if (!profile.isSuperAdmin) {
    const portRole = await db.query.userPortRoles.findFirst({
      where: and(eq(userPortRoles.userId, session.userId), eq(userPortRoles.portId, portId)),
      with: { role: true },
    });
    if (!portRole) return Response.json({ error: 'No access to this port' }, { status: 403 });

    // 6. Apply port overrides
    permissions = { ...portRole.role.permissions };
    const override = await db.query.portRoleOverrides.findFirst({
      where: and(
        eq(portRoleOverrides.portId, portId),
        eq(portRoleOverrides.roleId, portRole.roleId),
      ),
    });
    if (override) {
      permissions = deepMerge(permissions, override.permissionOverrides);
    }
  }

  // 7. Attach to request context
  req.ctx = {
    userId: session.userId,
    portId,
    isSuperAdmin: profile.isSuperAdmin,
    permissions, // null for super admin (bypasses checks)
    profile,
  };

  return next();
}

3.2 Permission Check Helper

function requirePermission(ctx, resource: string, action: string) {
  if (ctx.isSuperAdmin) return; // always allowed

  const resourcePerms = ctx.permissions?.[resource];
  if (!resourcePerms || !resourcePerms[action]) {
    throw new ForbiddenError(`Missing permission: ${resource}.${action}`);
  }
}

// Usage in API route:
export async function PATCH(req) {
  requirePermission(req.ctx, 'clients', 'edit');
  // ... proceed with edit
}

3.3 Port Scoping Helper

function withPortScope<T>(query: DrizzleQuery<T>, portId: string) {
  // Adds WHERE port_id = portId to any query
  // Used on every database query for port-scoped tables
  return query.where(eq(table.portId, portId));
}

// Super admin cross-port query:
function withOptionalPortScope<T>(query: DrizzleQuery<T>, ctx: RequestContext) {
  if (ctx.isSuperAdmin && !ctx.portId) return query; // no scope
  return withPortScope(query, ctx.portId);
}

4. API Route Protection Map

Route Pattern Auth Permission Required
/api/public/* None None
/api/auth/* None (pre-auth) None
/api/clients GET Session clients.view
/api/clients POST Session clients.create
/api/clients/:id PATCH Session clients.edit
/api/clients/:id DELETE Session clients.delete
/api/clients/merge POST Session clients.merge
/api/interests GET Session interests.view
/api/interests POST Session interests.create
/api/interests/:id PATCH Session interests.edit
/api/interests/:id/stage PATCH Session interests.change_stage
/api/documents/generate-eoi POST Session interests.generate_eoi
/api/berths GET Session berths.view
/api/berths/:id PATCH Session berths.edit
/api/berths/import POST Session berths.import
/api/expenses GET Session expenses.view
/api/expenses POST Session expenses.create
/api/expenses/scan-receipt POST Session expenses.scan_receipt
/api/expenses/export/* POST Session expenses.export
/api/invoices GET Session invoices.view
/api/invoices POST Session invoices.create
/api/invoices/:id/send POST Session invoices.send
/api/invoices/:id/payment PATCH Session invoices.record_payment
/api/files GET Session files.view
/api/files/upload POST Session files.upload
/api/files/:id DELETE Session files.delete
/api/document-templates GET Session document_templates.view
/api/document-templates POST Session document_templates.manage
/api/document-templates/:id PATCH Session document_templates.manage
/api/document-templates/:id DELETE Session document_templates.manage
/api/document-templates/:id/generate* POST Session document_templates.generate
/api/clients/:id/export-pdf POST Session clients.view
/api/berths/:id/export-pdf POST Session berths.view
/api/interests/:id/export-pdf POST Session interests.view
/api/email/* Session email.* (various)
/api/reminders GET Session reminders.view_own (or reminders.view_all for all)
/api/reminders/:id/complete POST Session reminders.edit_own (own) or reminders.edit_all (others')
/api/calendar/* Session calendar.connect (for auth/disconnect), calendar.view_events (for sync/status)
/api/admin/users/* Session admin.manage_users
/api/admin/roles/* Session Super admin only
/api/admin/ports/* Session Super admin only
/api/admin/audit-logs/* Session admin.view_audit_log
/api/admin/audit-logs/:id/revert POST Session Super admin only
/api/admin/settings GET/PATCH Session admin.manage_settings
/api/admin/backup/* Session Super admin only
/api/admin/webhooks/* Session admin.manage_webhooks
/api/admin/reports/* Session admin.manage_reports
/api/admin/custom-fields/* Session admin.manage_custom_fields
/api/admin/health GET Session admin.manage_settings
/api/admin/jobs/* Session Super admin only

5. Security Hardening

5.1 Rate Limiting

  • Login: 5 attempts per email per 15 minutes
  • Public API: 60 requests per minute per IP
  • Authenticated API: 300 requests per minute per user
  • File upload: 10 uploads per minute per user
  • Bulk operations: 5 per minute per user

5.2 Session Security

  • Cookies: httpOnly, secure, SameSite=Strict
  • CSRF token in all state-changing requests
  • Session ID rotated on privilege escalation (login, port switch)

5.3 Password Requirements

  • Minimum 8 characters
  • At least 1 uppercase, 1 lowercase, 1 number
  • Checked against common password list (top 10,000)
  • Hashed with argon2id

5.4 Headers

  • Content-Security-Policy (strict)
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Strict-Transport-Security (HSTS)
  • X-XSS-Protection: 1; mode=block

5.5 Input Validation

  • All inputs validated with Zod schemas before processing
  • SQL injection prevented by Drizzle ORM parameterized queries
  • XSS prevented by React's default escaping + CSP headers
  • File upload: MIME type validation, size limits (configurable, default 50MB)