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