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

447 lines
19 KiB
Markdown

# 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<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)