# 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: ```json { "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/\*) ```typescript // 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 ```typescript 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 ```typescript function withPortScope(query: DrizzleQuery, 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(query: DrizzleQuery, 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)