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>
19 KiB
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)