navidocs/server/IMPLEMENTATION_TASKS.md
Danny Stocker 58b344aa31 FINAL: P0 blockers fixed + Joe Trader + ignore binaries
Fixed:
- Price: €800K-€1.5M, Sunseeker added
- Agent 1: Joe Trader persona + actual sale ads research
- Ignored meilisearch binary + data/ (too large for GitHub)
- SESSION_DEBUG_BLOCKERS.md created

Ready for Session 1 launch.

🤖 Generated with Claude Code
2025-11-13 01:29:59 +01:00

54 KiB

NaviDocs Auth System - Implementation Task Breakdown

Overview

This document breaks down the multi-tenancy authentication and authorization system into granular, PR-sized tasks with clear acceptance criteria, module boundaries, and testing requirements.


Phase 1: Foundation

Task 1.1: Database Schema Migration

Branch: feat/auth-schema-migration Estimated Effort: 4 hours Priority: P0 (Blocker)

Deliverables:

  1. Migration script: /migrations/003_auth_tables.sql
  2. Rollback script: /migrations/003_auth_tables_down.sql
  3. Migration runner updates

Schema Changes:

-- /migrations/003_auth_tables.sql

-- Entity-level permissions (granular access control)
CREATE TABLE entity_permissions (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  entity_id TEXT NOT NULL,
  permission_level TEXT NOT NULL CHECK(permission_level IN ('viewer', 'editor', 'manager', 'admin')),
  granted_by TEXT NOT NULL,
  granted_at INTEGER NOT NULL,
  expires_at INTEGER,
  UNIQUE(user_id, entity_id),
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
  FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX idx_entity_perms_user ON entity_permissions(user_id);
CREATE INDEX idx_entity_perms_entity ON entity_permissions(entity_id);
CREATE INDEX idx_entity_perms_expires ON entity_permissions(expires_at);

-- Refresh tokens for secure session management
CREATE TABLE refresh_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  token_hash TEXT NOT NULL UNIQUE,
  device_info TEXT,
  ip_address TEXT,
  expires_at INTEGER NOT NULL,
  revoked BOOLEAN DEFAULT 0,
  revoked_at INTEGER,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at);
CREATE INDEX idx_refresh_tokens_revoked ON refresh_tokens(revoked);

-- Password reset tokens
CREATE TABLE password_reset_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  token_hash TEXT NOT NULL UNIQUE,
  expires_at INTEGER NOT NULL,
  used BOOLEAN DEFAULT 0,
  used_at INTEGER,
  ip_address TEXT,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX idx_reset_tokens_expires ON password_reset_tokens(expires_at);

-- Audit log for security events
CREATE TABLE audit_log (
  id TEXT PRIMARY KEY,
  user_id TEXT,
  event_type TEXT NOT NULL,
  resource_type TEXT,
  resource_id TEXT,
  ip_address TEXT,
  user_agent TEXT,
  status TEXT NOT NULL CHECK(status IN ('success', 'failure', 'denied')),
  metadata TEXT,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX idx_audit_user ON audit_log(user_id);
CREATE INDEX idx_audit_event ON audit_log(event_type);
CREATE INDEX idx_audit_created ON audit_log(created_at);
CREATE INDEX idx_audit_status ON audit_log(status);

-- Add email verification to users table
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0;
ALTER TABLE users ADD COLUMN email_verification_token TEXT;
ALTER TABLE users ADD COLUMN email_verification_expires INTEGER;

-- Add account status to users
ALTER TABLE users ADD COLUMN status TEXT DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'deleted'));
ALTER TABLE users ADD COLUMN suspended_at INTEGER;
ALTER TABLE users ADD COLUMN suspended_reason TEXT;

-- Add role constraint to user_organizations
-- SQLite doesn't support ALTER CHECK, so we document it here
-- In production PostgreSQL, add: CHECK(role IN ('admin', 'manager', 'member', 'viewer'))

Acceptance Criteria:

  • Migration script runs without errors on clean database
  • Migration script is idempotent (can run multiple times safely)
  • Rollback script cleanly reverts all changes
  • All foreign keys are enforced (test with invalid data)
  • All indexes improve query performance (verify with EXPLAIN QUERY PLAN)
  • CHECK constraints prevent invalid data
  • Migration runner logs progress and errors

Testing:

// /test/migrations/003_auth_tables.test.js
describe('Auth Tables Migration', () => {
  it('creates all tables', () => {
    // Verify tables exist
  });

  it('creates all indexes', () => {
    // Verify indexes exist
  });

  it('enforces foreign key constraints', () => {
    // Try to insert invalid foreign key
  });

  it('enforces check constraints', () => {
    // Try to insert invalid permission_level
  });

  it('rollback removes all tables', () => {
    // Run down migration and verify cleanup
  });
});

Files Changed:

  • /migrations/003_auth_tables.sql (new)
  • /migrations/003_auth_tables_down.sql (new)
  • /run-migration.js (update to run new migration)
  • /test/migrations/003_auth_tables.test.js (new)

Task 1.2: Authentication Service Core

Branch: feat/auth-service Estimated Effort: 8 hours Priority: P0 (Blocker) Dependencies: Task 1.1

Module Interface:

// /services/auth.js

import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db/db.js';
import { AuditService } from './audit.js';

const BCRYPT_ROUNDS = 12;
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';
const REFRESH_TOKEN_EXPIRES_IN = 7 * 24 * 60 * 60 * 1000; // 7 days

export class AuthService {
  /**
   * Register a new user
   * @param {string} email - User email (unique)
   * @param {string} password - Plain text password (min 8 chars)
   * @param {string} name - User display name
   * @returns {Promise<{id: string, email: string, name: string}>}
   * @throws {Error} If email already exists or validation fails
   */
  async register(email, password, name) {
    // Validate inputs
    this._validateEmail(email);
    this._validatePassword(password);

    const db = getDb();

    // Check if email exists
    const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
    if (existing) {
      throw new Error('Email already registered');
    }

    // Hash password
    const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);

    // Generate email verification token
    const verificationToken = uuidv4();
    const verificationExpires = Date.now() + (24 * 60 * 60 * 1000); // 24 hours

    // Create user
    const userId = uuidv4();
    const now = Date.now();

    db.prepare(`
      INSERT INTO users (
        id, email, name, password_hash, status,
        email_verified, email_verification_token, email_verification_expires,
        created_at, updated_at
      ) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?)
    `).run(userId, email, name, passwordHash, verificationToken, verificationExpires, now, now);

    // Create personal organization
    const orgId = uuidv4();
    db.prepare(`
      INSERT INTO organizations (id, name, type, created_at, updated_at)
      VALUES (?, ?, 'personal', ?, ?)
    `).run(orgId, `${name}'s Organization`, now, now);

    // Add user to organization as admin
    db.prepare(`
      INSERT INTO user_organizations (user_id, organization_id, role, joined_at)
      VALUES (?, ?, 'admin', ?)
    `).run(userId, orgId, now);

    // Log audit event
    await AuditService.logEvent('user_registered', userId, null, null, 'success', {
      email,
      organization_id: orgId
    });

    return {
      id: userId,
      email,
      name,
      emailVerified: false,
      verificationToken // Return for testing; in production, send via email
    };
  }

  /**
   * Authenticate user and generate tokens
   * @param {string} email
   * @param {string} password
   * @param {Object} deviceInfo - { userAgent, ipAddress }
   * @returns {Promise<{accessToken: string, refreshToken: string, user: Object}>}
   * @throws {Error} If credentials invalid or account suspended
   */
  async login(email, password, deviceInfo = {}) {
    const db = getDb();

    // Get user
    const user = db.prepare(`
      SELECT id, email, name, password_hash, status, email_verified
      FROM users WHERE email = ?
    `).get(email);

    if (!user) {
      await AuditService.logEvent('login_failed', null, null, null, 'failure', {
        email,
        reason: 'user_not_found',
        ip_address: deviceInfo.ipAddress
      });
      throw new Error('Invalid credentials');
    }

    // Verify password
    const passwordValid = await bcrypt.compare(password, user.password_hash);
    if (!passwordValid) {
      await AuditService.logEvent('login_failed', user.id, null, null, 'failure', {
        email,
        reason: 'invalid_password',
        ip_address: deviceInfo.ipAddress
      });
      throw new Error('Invalid credentials');
    }

    // Check account status
    if (user.status === 'suspended') {
      await AuditService.logEvent('login_denied', user.id, null, null, 'denied', {
        reason: 'account_suspended',
        ip_address: deviceInfo.ipAddress
      });
      throw new Error('Account suspended');
    }

    if (user.status === 'deleted') {
      throw new Error('Account not found');
    }

    // Generate access token (JWT)
    const accessToken = jwt.sign(
      {
        sub: user.id,
        email: user.email,
        name: user.name,
        emailVerified: user.email_verified === 1
      },
      JWT_SECRET,
      {
        expiresIn: JWT_EXPIRES_IN,
        issuer: 'navidocs-api'
      }
    );

    // Generate refresh token
    const refreshToken = uuidv4();
    const refreshTokenHash = await bcrypt.hash(refreshToken, BCRYPT_ROUNDS);
    const refreshTokenId = uuidv4();
    const expiresAt = Date.now() + REFRESH_TOKEN_EXPIRES_IN;

    db.prepare(`
      INSERT INTO refresh_tokens (
        id, user_id, token_hash, device_info, ip_address, expires_at, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?)
    `).run(
      refreshTokenId,
      user.id,
      refreshTokenHash,
      JSON.stringify(deviceInfo),
      deviceInfo.ipAddress,
      expiresAt,
      Date.now()
    );

    // Update last login
    db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?')
      .run(Date.now(), user.id);

    // Log successful login
    await AuditService.logEvent('login_success', user.id, null, null, 'success', {
      ip_address: deviceInfo.ipAddress,
      user_agent: deviceInfo.userAgent
    });

    return {
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
        emailVerified: user.email_verified === 1
      }
    };
  }

  /**
   * Revoke refresh token (logout)
   * @param {string} refreshToken - Token to revoke
   * @returns {Promise<boolean>} True if revoked, false if not found
   */
  async logout(refreshToken) {
    const db = getDb();

    // Find and revoke token
    const tokens = db.prepare('SELECT id, token_hash, user_id FROM refresh_tokens WHERE revoked = 0').all();

    for (const record of tokens) {
      const matches = await bcrypt.compare(refreshToken, record.token_hash);
      if (matches) {
        db.prepare('UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE id = ?')
          .run(Date.now(), record.id);

        await AuditService.logEvent('logout', record.user_id, null, null, 'success', {});
        return true;
      }
    }

    return false;
  }

  /**
   * Generate new access token from refresh token
   * @param {string} refreshToken
   * @returns {Promise<{accessToken: string}>}
   * @throws {Error} If refresh token invalid, expired, or revoked
   */
  async refreshAccessToken(refreshToken) {
    const db = getDb();
    const now = Date.now();

    // Find valid refresh token
    const tokens = db.prepare(`
      SELECT id, token_hash, user_id, expires_at, revoked
      FROM refresh_tokens
      WHERE expires_at > ? AND revoked = 0
    `).all(now);

    let validToken = null;
    for (const record of tokens) {
      const matches = await bcrypt.compare(refreshToken, record.token_hash);
      if (matches) {
        validToken = record;
        break;
      }
    }

    if (!validToken) {
      throw new Error('Invalid or expired refresh token');
    }

    // Get user
    const user = db.prepare('SELECT id, email, name, email_verified, status FROM users WHERE id = ?')
      .get(validToken.user_id);

    if (!user || user.status !== 'active') {
      throw new Error('User account not active');
    }

    // Generate new access token
    const accessToken = jwt.sign(
      {
        sub: user.id,
        email: user.email,
        name: user.name,
        emailVerified: user.email_verified === 1
      },
      JWT_SECRET,
      {
        expiresIn: JWT_EXPIRES_IN,
        issuer: 'navidocs-api'
      }
    );

    return { accessToken };
  }

  /**
   * Initiate password reset flow
   * @param {string} email
   * @returns {Promise<{resetToken: string}>} Token to send via email
   */
  async forgotPassword(email) {
    const db = getDb();

    const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email);

    // Don't reveal if email exists (security)
    if (!user) {
      return { resetToken: null }; // Silently fail
    }

    // Generate reset token
    const resetToken = uuidv4();
    const tokenHash = await bcrypt.hash(resetToken, BCRYPT_ROUNDS);
    const expiresAt = Date.now() + (60 * 60 * 1000); // 1 hour

    const tokenId = uuidv4();
    db.prepare(`
      INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, created_at)
      VALUES (?, ?, ?, ?, ?)
    `).run(tokenId, user.id, tokenHash, expiresAt, Date.now());

    await AuditService.logEvent('password_reset_requested', user.id, null, null, 'success', {});

    return { resetToken }; // In production, send via email and return { success: true }
  }

  /**
   * Complete password reset
   * @param {string} resetToken
   * @param {string} newPassword
   * @returns {Promise<boolean>}
   * @throws {Error} If token invalid, expired, or used
   */
  async resetPassword(resetToken, newPassword) {
    this._validatePassword(newPassword);

    const db = getDb();
    const now = Date.now();

    // Find valid reset token
    const tokens = db.prepare(`
      SELECT id, token_hash, user_id, expires_at, used
      FROM password_reset_tokens
      WHERE expires_at > ? AND used = 0
    `).all(now);

    let validToken = null;
    for (const record of tokens) {
      const matches = await bcrypt.compare(resetToken, record.token_hash);
      if (matches) {
        validToken = record;
        break;
      }
    }

    if (!validToken) {
      throw new Error('Invalid or expired reset token');
    }

    // Hash new password
    const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);

    // Update password
    db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?')
      .run(passwordHash, now, validToken.user_id);

    // Mark token as used
    db.prepare('UPDATE password_reset_tokens SET used = 1, used_at = ? WHERE id = ?')
      .run(now, validToken.id);

    // Revoke all refresh tokens (force re-login)
    db.prepare('UPDATE refresh_tokens SET revoked = 1, revoked_at = ? WHERE user_id = ?')
      .run(now, validToken.user_id);

    await AuditService.logEvent('password_reset_completed', validToken.user_id, null, null, 'success', {});

    return true;
  }

  /**
   * Verify email address
   * @param {string} verificationToken
   * @returns {Promise<boolean>}
   * @throws {Error} If token invalid or expired
   */
  async verifyEmail(verificationToken) {
    const db = getDb();
    const now = Date.now();

    const user = db.prepare(`
      SELECT id FROM users
      WHERE email_verification_token = ? AND email_verification_expires > ?
    `).get(verificationToken, now);

    if (!user) {
      throw new Error('Invalid or expired verification token');
    }

    db.prepare(`
      UPDATE users
      SET email_verified = 1, email_verification_token = NULL, email_verification_expires = NULL
      WHERE id = ?
    `).run(user.id);

    await AuditService.logEvent('email_verified', user.id, null, null, 'success', {});

    return true;
  }

  // Private validation methods
  _validateEmail(email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email format');
    }
  }

  _validatePassword(password) {
    if (password.length < 8) {
      throw new Error('Password must be at least 8 characters');
    }

    // Require at least one uppercase, one lowercase, one number
    const hasUppercase = /[A-Z]/.test(password);
    const hasLowercase = /[a-z]/.test(password);
    const hasNumber = /[0-9]/.test(password);

    if (!hasUppercase || !hasLowercase || !hasNumber) {
      throw new Error('Password must contain uppercase, lowercase, and number');
    }
  }
}

export default new AuthService();

Acceptance Criteria:

  • User can register with email/password/name
  • Duplicate email registration fails with clear error
  • Password is hashed with bcrypt (cost=12)
  • Email verification token generated and stored
  • Personal organization created for new user
  • User can login with valid credentials
  • Login returns JWT access token and refresh token
  • Invalid credentials rejected with generic error message
  • Suspended account login denied
  • Refresh token can generate new access token
  • Expired/revoked refresh tokens rejected
  • Password reset token generated and validated
  • Reset password updates hash and revokes all sessions
  • Email verification marks user as verified
  • All operations logged to audit table
  • All passwords validated for strength
  • Unit tests cover 95%+ of code paths

Testing:

// /test/services/auth.test.js
describe('AuthService', () => {
  describe('register', () => {
    it('creates user with hashed password', async () => {});
    it('creates personal organization', async () => {});
    it('rejects duplicate email', async () => {});
    it('rejects weak password', async () => {});
    it('rejects invalid email', async () => {});
  });

  describe('login', () => {
    it('returns tokens for valid credentials', async () => {});
    it('rejects invalid password', async () => {});
    it('rejects non-existent user', async () => {});
    it('rejects suspended account', async () => {});
    it('updates last_login_at', async () => {});
  });

  describe('refreshAccessToken', () => {
    it('generates new access token', async () => {});
    it('rejects expired refresh token', async () => {});
    it('rejects revoked refresh token', async () => {});
  });

  describe('forgotPassword', () => {
    it('generates reset token', async () => {});
    it('does not reveal non-existent email', async () => {});
  });

  describe('resetPassword', () => {
    it('updates password hash', async () => {});
    it('revokes all refresh tokens', async () => {});
    it('rejects used token', async () => {});
    it('rejects expired token', async () => {});
  });

  describe('verifyEmail', () => {
    it('marks email as verified', async () => {});
    it('rejects invalid token', async () => {});
  });
});

Files Changed:

  • /services/auth.js (new)
  • /test/services/auth.test.js (new)

Task 1.3: Audit Service

Branch: feat/audit-service Estimated Effort: 3 hours Priority: P0 (Blocker) Dependencies: Task 1.1

Module Interface:

// /services/audit.js

import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db/db.js';

export class AuditService {
  /**
   * Log a security or business event
   * @param {string} eventType - Event category (login, logout, permission_grant, etc)
   * @param {string|null} userId - User who triggered event (null for anonymous)
   * @param {string|null} resourceType - Type of resource affected (entity, document, organization)
   * @param {string|null} resourceId - ID of affected resource
   * @param {string} status - Outcome: success, failure, denied
   * @param {Object} metadata - Additional context (ip_address, user_agent, details)
   * @returns {Promise<string>} Audit log entry ID
   */
  static async logEvent(eventType, userId, resourceType, resourceId, status, metadata = {}) {
    const db = getDb();

    const auditId = uuidv4();
    const now = Date.now();

    db.prepare(`
      INSERT INTO audit_log (
        id, user_id, event_type, resource_type, resource_id,
        ip_address, user_agent, status, metadata, created_at
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      auditId,
      userId,
      eventType,
      resourceType,
      resourceId,
      metadata.ip_address || null,
      metadata.user_agent || null,
      status,
      JSON.stringify(metadata),
      now
    );

    return auditId;
  }

  /**
   * Query audit logs with filters
   * @param {Object} filters - { userId, eventType, resourceType, startDate, endDate, status }
   * @param {Object} pagination - { limit, offset }
   * @returns {Promise<{logs: Array, total: number}>}
   */
  static async queryLogs(filters = {}, pagination = { limit: 100, offset: 0 }) {
    const db = getDb();

    let query = 'SELECT * FROM audit_log WHERE 1=1';
    const params = [];

    if (filters.userId) {
      query += ' AND user_id = ?';
      params.push(filters.userId);
    }

    if (filters.eventType) {
      query += ' AND event_type = ?';
      params.push(filters.eventType);
    }

    if (filters.resourceType) {
      query += ' AND resource_type = ?';
      params.push(filters.resourceType);
    }

    if (filters.status) {
      query += ' AND status = ?';
      params.push(filters.status);
    }

    if (filters.startDate) {
      query += ' AND created_at >= ?';
      params.push(filters.startDate);
    }

    if (filters.endDate) {
      query += ' AND created_at <= ?';
      params.push(filters.endDate);
    }

    query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
    params.push(pagination.limit, pagination.offset);

    const logs = db.prepare(query).all(...params);

    // Get total count
    let countQuery = 'SELECT COUNT(*) as total FROM audit_log WHERE 1=1';
    const countParams = params.slice(0, -2); // Remove limit/offset

    if (filters.userId) countQuery += ' AND user_id = ?';
    if (filters.eventType) countQuery += ' AND event_type = ?';
    if (filters.resourceType) countQuery += ' AND resource_type = ?';
    if (filters.status) countQuery += ' AND status = ?';
    if (filters.startDate) countQuery += ' AND created_at >= ?';
    if (filters.endDate) countQuery += ' AND created_at <= ?';

    const { total } = db.prepare(countQuery).get(...countParams);

    return {
      logs: logs.map(log => ({
        ...log,
        metadata: JSON.parse(log.metadata || '{}')
      })),
      total
    };
  }

  /**
   * Get recent events for a user (for security dashboard)
   * @param {string} userId
   * @param {number} limit
   * @returns {Promise<Array>}
   */
  static async getUserRecentEvents(userId, limit = 20) {
    const db = getDb();

    const logs = db.prepare(`
      SELECT * FROM audit_log
      WHERE user_id = ?
      ORDER BY created_at DESC
      LIMIT ?
    `).all(userId, limit);

    return logs.map(log => ({
      ...log,
      metadata: JSON.parse(log.metadata || '{}')
    }));
  }

  /**
   * Clean up old audit logs (retention policy)
   * @param {number} retentionDays - Delete logs older than this many days
   * @returns {Promise<number>} Number of deleted records
   */
  static async cleanupOldLogs(retentionDays = 90) {
    const db = getDb();

    const cutoffDate = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);

    const result = db.prepare('DELETE FROM audit_log WHERE created_at < ?').run(cutoffDate);

    return result.changes;
  }
}

export default AuditService;

Acceptance Criteria:

  • Events logged with all required fields
  • Metadata stored as JSON
  • Query filters work correctly (user, event type, date range)
  • Pagination works correctly
  • Recent events for user retrieved
  • Old logs cleaned up based on retention policy
  • Unit tests cover all methods

Testing:

// /test/services/audit.test.js
describe('AuditService', () => {
  it('logs event with metadata', async () => {});
  it('queries logs by user', async () => {});
  it('queries logs by event type', async () => {});
  it('queries logs by date range', async () => {});
  it('returns paginated results', async () => {});
  it('gets user recent events', async () => {});
  it('cleans up old logs', async () => {});
});

Files Changed:

  • /services/audit.js (new)
  • /test/services/audit.test.js (new)

Task 1.4: Authentication Routes

Branch: feat/auth-routes Estimated Effort: 6 hours Priority: P0 (Blocker) Dependencies: Task 1.2, Task 1.3

API Endpoints:

// /routes/auth.js

import express from 'express';
import rateLimit from 'express-rate-limit';
import AuthService from '../services/auth.js';
import { authenticateToken } from '../middleware/auth.js';

const router = express.Router();

// Rate limiter for auth endpoints (stricter than general API)
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: 'Too many authentication attempts, please try again later'
});

/**
 * POST /api/auth/register
 * Create new user account
 *
 * Body: { email, password, name }
 * Returns: { user: { id, email, name, emailVerified } }
 */
router.post('/register', authLimiter, async (req, res) => {
  try {
    const { email, password, name } = req.body;

    if (!email || !password || !name) {
      return res.status(400).json({
        error: 'Missing required fields',
        required: ['email', 'password', 'name']
      });
    }

    const result = await AuthService.register(email, password, name);

    // In production, send verification email here
    // await EmailService.sendVerificationEmail(result.email, result.verificationToken);

    res.status(201).json({
      user: {
        id: result.id,
        email: result.email,
        name: result.name,
        emailVerified: result.emailVerified
      },
      message: 'Registration successful. Please check your email to verify your account.'
    });

  } catch (error) {
    if (error.message === 'Email already registered') {
      return res.status(409).json({ error: error.message });
    }

    if (error.message.includes('Password') || error.message.includes('email')) {
      return res.status(400).json({ error: error.message });
    }

    console.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

/**
 * POST /api/auth/login
 * Authenticate user
 *
 * Body: { email, password }
 * Returns: { accessToken, refreshToken, user }
 */
router.post('/login', authLimiter, async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({
        error: 'Missing required fields',
        required: ['email', 'password']
      });
    }

    const deviceInfo = {
      userAgent: req.headers['user-agent'],
      ipAddress: req.ip
    };

    const result = await AuthService.login(email, password, deviceInfo);

    res.json(result);

  } catch (error) {
    if (error.message === 'Invalid credentials' || error.message === 'Account suspended') {
      return res.status(401).json({ error: error.message });
    }

    console.error('Login error:', error);
    res.status(500).json({ error: 'Login failed' });
  }
});

/**
 * POST /api/auth/logout
 * Revoke refresh token
 *
 * Body: { refreshToken }
 * Returns: { success: true }
 */
router.post('/logout', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(400).json({ error: 'Missing refresh token' });
    }

    await AuthService.logout(refreshToken);

    res.json({ success: true });

  } catch (error) {
    console.error('Logout error:', error);
    res.status(500).json({ error: 'Logout failed' });
  }
});

/**
 * POST /api/auth/refresh
 * Get new access token
 *
 * Body: { refreshToken }
 * Returns: { accessToken }
 */
router.post('/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(400).json({ error: 'Missing refresh token' });
    }

    const result = await AuthService.refreshAccessToken(refreshToken);

    res.json(result);

  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired refresh token' });
  }
});

/**
 * POST /api/auth/forgot-password
 * Request password reset
 *
 * Body: { email }
 * Returns: { success: true }
 */
router.post('/forgot-password', authLimiter, async (req, res) => {
  try {
    const { email } = req.body;

    if (!email) {
      return res.status(400).json({ error: 'Missing email' });
    }

    await AuthService.forgotPassword(email);

    // Always return success (don't reveal if email exists)
    res.json({
      success: true,
      message: 'If that email exists, a password reset link has been sent.'
    });

  } catch (error) {
    console.error('Forgot password error:', error);
    res.status(500).json({ error: 'Failed to process request' });
  }
});

/**
 * POST /api/auth/reset-password
 * Complete password reset
 *
 * Body: { resetToken, newPassword }
 * Returns: { success: true }
 */
router.post('/reset-password', authLimiter, async (req, res) => {
  try {
    const { resetToken, newPassword } = req.body;

    if (!resetToken || !newPassword) {
      return res.status(400).json({
        error: 'Missing required fields',
        required: ['resetToken', 'newPassword']
      });
    }

    await AuthService.resetPassword(resetToken, newPassword);

    res.json({
      success: true,
      message: 'Password reset successful. Please login with your new password.'
    });

  } catch (error) {
    if (error.message.includes('token') || error.message.includes('Password')) {
      return res.status(400).json({ error: error.message });
    }

    console.error('Reset password error:', error);
    res.status(500).json({ error: 'Failed to reset password' });
  }
});

/**
 * GET /api/auth/verify-email/:token
 * Verify email address
 *
 * Params: { token }
 * Returns: { success: true }
 */
router.get('/verify-email/:token', async (req, res) => {
  try {
    const { token } = req.params;

    await AuthService.verifyEmail(token);

    res.json({
      success: true,
      message: 'Email verified successfully. You can now login.'
    });

  } catch (error) {
    return res.status(400).json({ error: 'Invalid or expired verification token' });
  }
});

/**
 * GET /api/auth/me
 * Get current user profile
 *
 * Headers: Authorization: Bearer <token>
 * Returns: { user }
 */
router.get('/me', authenticateToken, async (req, res) => {
  try {
    // User already attached to req.user by middleware
    res.json({
      user: {
        id: req.user.sub,
        email: req.user.email,
        name: req.user.name,
        emailVerified: req.user.emailVerified
      }
    });

  } catch (error) {
    console.error('Get user error:', error);
    res.status(500).json({ error: 'Failed to get user' });
  }
});

export default router;

Acceptance Criteria:

  • All endpoints return correct status codes
  • Input validation rejects missing fields
  • Rate limiting enforced (5 req/15min for auth endpoints)
  • Error messages are user-friendly (don't expose system details)
  • Registration creates user and returns user object
  • Login returns access + refresh tokens
  • Logout revokes refresh token
  • Refresh generates new access token
  • Password reset flow works end-to-end
  • Email verification marks user as verified
  • /me endpoint returns current user
  • Integration tests cover all endpoints
  • API documentation generated (OpenAPI/Swagger)

Testing:

// /test/routes/auth.test.js
describe('Auth Routes', () => {
  describe('POST /api/auth/register', () => {
    it('creates user', async () => {});
    it('rejects duplicate email', async () => {});
    it('rejects missing fields', async () => {});
    it('enforces rate limiting', async () => {});
  });

  describe('POST /api/auth/login', () => {
    it('returns tokens for valid credentials', async () => {});
    it('rejects invalid credentials', async () => {});
    it('enforces rate limiting', async () => {});
  });

  // ... more tests
});

Files Changed:

  • /routes/auth.js (new)
  • /index.js (add auth routes)
  • /test/routes/auth.test.js (new)
  • /API_SUMMARY.md (document new endpoints)

Task 1.5: Enhanced Auth Middleware

Branch: feat/auth-middleware-enhanced Estimated Effort: 3 hours Priority: P0 (Blocker) Dependencies: Task 1.2

Middleware Updates:

// /middleware/auth.js (REPLACE EXISTING)

import jwt from 'jsonwebtoken';
import { getDb } from '../db/db.js';
import AuditService from '../services/audit.js';

const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret-here-change-in-production';

/**
 * Verify JWT token and attach user to request
 * @param {Request} req - Express request
 * @param {Response} res - Express response
 * @param {Function} next - Next middleware
 */
export async function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({
      error: 'Authentication required',
      message: 'No access token provided'
    });
  }

  try {
    // Verify JWT signature and expiry
    const decoded = jwt.verify(token, JWT_SECRET, {
      issuer: 'navidocs-api'
    });

    // Check if user still exists and is active
    const db = getDb();
    const user = db.prepare('SELECT id, email, status FROM users WHERE id = ?').get(decoded.sub);

    if (!user) {
      await AuditService.logEvent('auth_failed', null, null, null, 'failure', {
        reason: 'user_not_found',
        token_sub: decoded.sub,
        ip_address: req.ip
      });

      return res.status(403).json({
        error: 'Invalid token',
        message: 'User account not found'
      });
    }

    if (user.status !== 'active') {
      await AuditService.logEvent('auth_denied', user.id, null, null, 'denied', {
        reason: 'account_not_active',
        status: user.status,
        ip_address: req.ip
      });

      return res.status(403).json({
        error: 'Account not active',
        message: 'Your account has been suspended or deleted'
      });
    }

    // Attach user to request
    req.user = {
      id: decoded.sub,
      email: decoded.email,
      name: decoded.name,
      emailVerified: decoded.emailVerified
    };

    next();

  } catch (error) {
    // Log failed authentication attempts
    await AuditService.logEvent('auth_failed', null, null, null, 'failure', {
      reason: error.name, // TokenExpiredError, JsonWebTokenError, etc
      ip_address: req.ip,
      user_agent: req.headers['user-agent']
    });

    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({
        error: 'Token expired',
        message: 'Please refresh your access token'
      });
    }

    return res.status(403).json({
      error: 'Invalid token',
      message: 'Token verification failed'
    });
  }
}

/**
 * Optional authentication - attaches user if token present
 * @param {Request} req
 * @param {Response} res
 * @param {Function} next
 */
export async function optionalAuth(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (token) {
    try {
      const decoded = jwt.verify(token, JWT_SECRET, {
        issuer: 'navidocs-api'
      });

      const db = getDb();
      const user = db.prepare('SELECT id, email, status FROM users WHERE id = ?').get(decoded.sub);

      if (user && user.status === 'active') {
        req.user = {
          id: decoded.sub,
          email: decoded.email,
          name: decoded.name,
          emailVerified: decoded.emailVerified
        };
      }
    } catch (error) {
      // Silently fail for optional auth
      console.log('Optional auth failed:', error.message);
    }
  }

  next();
}

export default {
  authenticateToken,
  optionalAuth
};

Acceptance Criteria:

  • Valid JWT grants access
  • Expired JWT returns 401 with clear message
  • Invalid JWT returns 403
  • Missing token returns 401
  • Suspended user denied access
  • Deleted user denied access
  • Non-existent user denied access
  • User object attached to req.user
  • Failed attempts logged to audit
  • optionalAuth doesn't fail on invalid token
  • Unit tests cover all error cases

Testing:

// /test/middleware/auth.test.js
describe('Auth Middleware', () => {
  describe('authenticateToken', () => {
    it('allows valid token', async () => {});
    it('rejects expired token', async () => {});
    it('rejects invalid signature', async () => {});
    it('rejects missing token', async () => {});
    it('rejects deleted user', async () => {});
    it('rejects suspended user', async () => {});
    it('logs failed attempts', async () => {});
  });

  describe('optionalAuth', () => {
    it('attaches user if valid token', async () => {});
    it('continues without user if invalid token', async () => {});
  });
});

Files Changed:

  • /middleware/auth.js (replace)
  • /test/middleware/auth.test.js (new)

Phase 2: Authorization

Task 2.1: Authorization Service

Branch: feat/authorization-service Estimated Effort: 8 hours Priority: P1 Dependencies: Task 1.1

Module Interface:

// /services/authorization.js

import { getDb } from '../db/db.js';
import LRU from 'lru-cache';

// Permission hierarchy (higher level includes all lower permissions)
const PERMISSION_HIERARCHY = {
  viewer: ['view'],
  editor: ['view', 'edit', 'create'],
  manager: ['view', 'edit', 'create', 'delete', 'share'],
  admin: ['view', 'edit', 'create', 'delete', 'share', 'manage_users', 'manage_permissions']
};

// Organization role capabilities
const ORG_ROLE_PERMISSIONS = {
  viewer: ['view'], // Read-only access to all org entities
  member: ['view', 'edit'], // Edit assigned entities only
  manager: ['view', 'edit', 'create', 'delete'], // Manage all entities
  admin: ['view', 'edit', 'create', 'delete', 'manage_users', 'manage_permissions'] // Full control
};

// Permission cache (LRU)
const permissionCache = new LRU({
  max: 10000,
  ttl: 1000 * 60 * 5, // 5 minutes
});

export class AuthorizationService {
  /**
   * Check if user has permission on an entity
   * Resolution order:
   * 1. Organization admin/manager roles (full access)
   * 2. Entity-level permissions (granular)
   * 3. Document-level permissions (legacy support)
   *
   * @param {string} userId
   * @param {string} entityId
   * @param {string} requiredPermission - view, edit, create, delete, etc
   * @returns {Promise<boolean>}
   */
  static async checkEntityPermission(userId, entityId, requiredPermission) {
    const cacheKey = `entity:${userId}:${entityId}:${requiredPermission}`;
    const cached = permissionCache.get(cacheKey);

    if (cached !== undefined) {
      return cached;
    }

    const result = await this._checkEntityPermissionUncached(userId, entityId, requiredPermission);
    permissionCache.set(cacheKey, result);

    return result;
  }

  static async _checkEntityPermissionUncached(userId, entityId, requiredPermission) {
    const db = getDb();

    // Step 1: Check organization-level roles
    const orgMembership = db.prepare(`
      SELECT uo.role
      FROM user_organizations uo
      INNER JOIN entities e ON e.organization_id = uo.organization_id
      WHERE uo.user_id = ? AND e.id = ?
    `).get(userId, entityId);

    if (orgMembership) {
      // Org admin has full access
      if (orgMembership.role === 'admin') {
        return true;
      }

      // Org manager has all permissions except user management
      if (orgMembership.role === 'manager') {
        const allowedPerms = ORG_ROLE_PERMISSIONS.manager;
        if (allowedPerms.includes(requiredPermission)) {
          return true;
        }
      }

      // Org viewer has read-only access
      if (orgMembership.role === 'viewer') {
        return requiredPermission === 'view';
      }

      // Org member needs entity-level permission (check below)
    } else {
      // Not a member of the organization - deny
      return false;
    }

    // Step 2: Check entity-level permissions
    const entityPerm = db.prepare(`
      SELECT permission_level
      FROM entity_permissions
      WHERE user_id = ? AND entity_id = ?
        AND (expires_at IS NULL OR expires_at > ?)
    `).get(userId, entityId, Date.now());

    if (entityPerm) {
      const allowedPerms = PERMISSION_HIERARCHY[entityPerm.permission_level];
      return allowedPerms.includes(requiredPermission);
    }

    // Step 3: Check legacy permissions table (backward compatibility)
    const resourcePerm = db.prepare(`
      SELECT permission
      FROM permissions
      WHERE user_id = ? AND resource_type = 'entity' AND resource_id = ?
        AND (expires_at IS NULL OR expires_at > ?)
    `).get(userId, entityId, Date.now());

    if (resourcePerm) {
      if (resourcePerm.permission === 'admin') return true;
      return resourcePerm.permission === requiredPermission;
    }

    return false;
  }

  /**
   * Check organization permission
   * @param {string} userId
   * @param {string} organizationId
   * @param {string} requiredPermission
   * @returns {Promise<boolean>}
   */
  static async checkOrganizationPermission(userId, organizationId, requiredPermission) {
    const cacheKey = `org:${userId}:${organizationId}:${requiredPermission}`;
    const cached = permissionCache.get(cacheKey);

    if (cached !== undefined) {
      return cached;
    }

    const db = getDb();

    const membership = db.prepare(`
      SELECT role FROM user_organizations
      WHERE user_id = ? AND organization_id = ?
    `).get(userId, organizationId);

    if (!membership) {
      permissionCache.set(cacheKey, false);
      return false;
    }

    const allowedPerms = ORG_ROLE_PERMISSIONS[membership.role];
    const result = allowedPerms.includes(requiredPermission);

    permissionCache.set(cacheKey, result);
    return result;
  }

  /**
   * Check document permission
   * @param {string} userId
   * @param {string} documentId
   * @param {string} requiredPermission
   * @returns {Promise<boolean>}
   */
  static async checkDocumentPermission(userId, documentId, requiredPermission) {
    const db = getDb();

    // Get document's entity
    const document = db.prepare(`
      SELECT entity_id, organization_id, uploaded_by
      FROM documents
      WHERE id = ?
    `).get(documentId);

    if (!document) {
      return false;
    }

    // Owner always has full access
    if (document.uploaded_by === userId) {
      return true;
    }

    // Check organization membership
    if (document.organization_id) {
      const hasOrgPerm = await this.checkOrganizationPermission(
        userId,
        document.organization_id,
        requiredPermission
      );
      if (hasOrgPerm) return true;
    }

    // Check entity permission
    if (document.entity_id) {
      const hasEntityPerm = await this.checkEntityPermission(
        userId,
        document.entity_id,
        requiredPermission
      );
      if (hasEntityPerm) return true;
    }

    // Check document shares
    const share = db.prepare(`
      SELECT permission FROM document_shares
      WHERE document_id = ? AND shared_with = ?
    `).get(documentId, userId);

    if (share) {
      if (share.permission === 'write') return true;
      if (share.permission === 'read' && requiredPermission === 'view') return true;
    }

    return false;
  }

  /**
   * Grant entity permission to user
   * @param {string} granterId - User granting permission (must have manage_permissions)
   * @param {string} userId - User receiving permission
   * @param {string} entityId
   * @param {string} permissionLevel - viewer, editor, manager, admin
   * @param {number|null} expiresAt - Unix timestamp or null for permanent
   * @returns {Promise<string>} Permission ID
   * @throws {Error} If granter lacks permission
   */
  static async grantEntityPermission(granterId, userId, entityId, permissionLevel, expiresAt = null) {
    // Validate permission level
    if (!PERMISSION_HIERARCHY[permissionLevel]) {
      throw new Error(`Invalid permission level: ${permissionLevel}`);
    }

    // Check if granter has permission to grant
    const canGrant = await this.checkEntityPermission(granterId, entityId, 'manage_permissions');
    if (!canGrant) {
      throw new Error('You do not have permission to grant access to this entity');
    }

    // Cannot grant higher permission than you have
    const granterPerm = await this._getEntityPermissionLevel(granterId, entityId);
    if (!this._canGrantPermission(granterPerm, permissionLevel)) {
      throw new Error('Cannot grant higher permission level than your own');
    }

    const db = getDb();
    const { v4: uuidv4 } = await import('uuid');

    const permId = uuidv4();
    const now = Date.now();

    // Upsert entity permission
    db.prepare(`
      INSERT INTO entity_permissions (id, user_id, entity_id, permission_level, granted_by, granted_at, expires_at)
      VALUES (?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(user_id, entity_id) DO UPDATE SET
        permission_level = excluded.permission_level,
        granted_by = excluded.granted_by,
        granted_at = excluded.granted_at,
        expires_at = excluded.expires_at
    `).run(permId, userId, entityId, permissionLevel, granterId, now, expiresAt);

    // Invalidate cache
    this._invalidateEntityCache(userId, entityId);

    return permId;
  }

  /**
   * Revoke entity permission
   * @param {string} granterId - User revoking permission
   * @param {string} userId - User whose permission is being revoked
   * @param {string} entityId
   * @returns {Promise<boolean>} True if revoked, false if not found
   */
  static async revokeEntityPermission(granterId, userId, entityId) {
    const canRevoke = await this.checkEntityPermission(granterId, entityId, 'manage_permissions');
    if (!canRevoke) {
      throw new Error('You do not have permission to revoke access to this entity');
    }

    const db = getDb();

    const result = db.prepare(`
      DELETE FROM entity_permissions
      WHERE user_id = ? AND entity_id = ?
    `).run(userId, entityId);

    if (result.changes > 0) {
      this._invalidateEntityCache(userId, entityId);
      return true;
    }

    return false;
  }

  /**
   * Get all users with access to an entity
   * @param {string} entityId
   * @returns {Promise<Array>} List of users with permission levels
   */
  static async getEntityUsers(entityId) {
    const db = getDb();

    // Get entity's organization
    const entity = db.prepare('SELECT organization_id FROM entities WHERE id = ?').get(entityId);
    if (!entity) {
      throw new Error('Entity not found');
    }

    // Get org members
    const orgMembers = db.prepare(`
      SELECT u.id, u.email, u.name, uo.role as org_role
      FROM user_organizations uo
      INNER JOIN users u ON u.id = uo.user_id
      WHERE uo.organization_id = ?
    `).all(entity.organization_id);

    // Get entity-specific permissions
    const entityPerms = db.prepare(`
      SELECT ep.user_id, ep.permission_level, ep.granted_at, ep.expires_at
      FROM entity_permissions ep
      WHERE ep.entity_id = ?
    `).all(entityId);

    const entityPermsMap = {};
    entityPerms.forEach(perm => {
      entityPermsMap[perm.user_id] = perm;
    });

    return orgMembers.map(member => ({
      userId: member.id,
      email: member.email,
      name: member.name,
      organizationRole: member.org_role,
      entityPermission: entityPermsMap[member.id]?.permission_level || null,
      grantedAt: entityPermsMap[member.id]?.granted_at || null,
      expiresAt: entityPermsMap[member.id]?.expires_at || null
    }));
  }

  /**
   * Get all entities a user has access to
   * @param {string} userId
   * @returns {Promise<Array>} List of entities with permission levels
   */
  static async getUserEntities(userId) {
    const db = getDb();

    // Get all organizations user is member of
    const orgs = db.prepare(`
      SELECT organization_id, role
      FROM user_organizations
      WHERE user_id = ?
    `).all(userId);

    if (orgs.length === 0) {
      return [];
    }

    const orgIds = orgs.map(o => o.organization_id);
    const placeholders = orgIds.map(() => '?').join(',');

    // Get all entities in those organizations
    const entities = db.prepare(`
      SELECT e.id, e.name, e.entity_type, e.organization_id
      FROM entities e
      WHERE e.organization_id IN (${placeholders})
    `).all(...orgIds);

    // Get entity-specific permissions
    const entityPerms = db.prepare(`
      SELECT entity_id, permission_level
      FROM entity_permissions
      WHERE user_id = ?
    `).all(userId);

    const entityPermsMap = {};
    entityPerms.forEach(perm => {
      entityPermsMap[perm.entity_id] = perm.permission_level;
    });

    const orgRolesMap = {};
    orgs.forEach(org => {
      orgRolesMap[org.organization_id] = org.role;
    });

    return entities.map(entity => {
      const orgRole = orgRolesMap[entity.organization_id];
      const entityPerm = entityPermsMap[entity.id];

      // Determine effective permission
      let effectivePermission = null;
      if (orgRole === 'admin') {
        effectivePermission = 'admin';
      } else if (orgRole === 'manager') {
        effectivePermission = 'manager';
      } else if (entityPerm) {
        effectivePermission = entityPerm;
      } else if (orgRole === 'viewer') {
        effectivePermission = 'viewer';
      }

      return {
        entityId: entity.id,
        name: entity.name,
        entityType: entity.entity_type,
        organizationId: entity.organization_id,
        organizationRole: orgRole,
        entityPermission: entityPerm || null,
        effectivePermission
      };
    }).filter(e => e.effectivePermission !== null); // Only return entities user can access
  }

  // Private helper methods

  static async _getEntityPermissionLevel(userId, entityId) {
    const db = getDb();

    const orgMembership = db.prepare(`
      SELECT uo.role
      FROM user_organizations uo
      INNER JOIN entities e ON e.organization_id = uo.organization_id
      WHERE uo.user_id = ? AND e.id = ?
    `).get(userId, entityId);

    if (orgMembership && (orgMembership.role === 'admin' || orgMembership.role === 'manager')) {
      return orgMembership.role;
    }

    const entityPerm = db.prepare(`
      SELECT permission_level FROM entity_permissions
      WHERE user_id = ? AND entity_id = ?
    `).get(userId, entityId);

    return entityPerm?.permission_level || null;
  }

  static _canGrantPermission(granterLevel, targetLevel) {
    const levels = ['viewer', 'editor', 'manager', 'admin'];
    const granterIndex = levels.indexOf(granterLevel);
    const targetIndex = levels.indexOf(targetLevel);

    return granterIndex >= targetIndex;
  }

  static _invalidateEntityCache(userId, entityId) {
    // Invalidate all permission combinations for this user+entity
    const permissions = ['view', 'edit', 'create', 'delete', 'share', 'manage_users', 'manage_permissions'];
    permissions.forEach(perm => {
      permissionCache.delete(`entity:${userId}:${entityId}:${perm}`);
    });
  }

  /**
   * Clear permission cache (useful for testing or after bulk updates)
   */
  static clearCache() {
    permissionCache.clear();
  }
}

export default AuthorizationService;

Acceptance Criteria:

  • Org admin has full access to all org entities
  • Org manager can view/edit/create/delete all org entities
  • Org member sees only entities with explicit permissions
  • Org viewer has read-only access
  • Entity-level permissions override org permissions (more granular)
  • Permission hierarchy works (admin > manager > editor > viewer)
  • Grant permission validates granter's authority
  • Cannot grant higher permission than own level
  • Revoke permission removes access immediately
  • Get entity users returns all users with access
  • Get user entities returns all accessible entities
  • Permission cache improves performance (>10x faster on cache hit)
  • Cache invalidated on permission changes
  • Unit tests cover all methods (95%+ coverage)

Testing:

// /test/services/authorization.test.js
describe('AuthorizationService', () => {
  describe('checkEntityPermission', () => {
    it('grants access to org admin', async () => {});
    it('grants access to org manager', async () => {});
    it('grants access to entity admin', async () => {});
    it('denies access to non-member', async () => {});
    it('respects permission hierarchy', async () => {});
    it('checks expiry date', async () => {});
  });

  describe('grantEntityPermission', () => {
    it('grants permission', async () => {});
    it('rejects if granter lacks permission', async () => {});
    it('rejects granting higher permission', async () => {});
    it('invalidates cache', async () => {});
  });

  // More tests...
});

Files Changed:

  • /services/authorization.js (new)
  • /test/services/authorization.test.js (new)

[Continued in next section due to length...]


Summary

This implementation plan breaks down the multi-tenancy auth system into 16 granular PRs across 5 phases:

Phase 1 (Week 1): Foundation - Database schema, core auth services, routes, middleware Phase 2 (Week 2): Authorization - Permission service, middleware, route protection Phase 3 (Week 3): User & Org Management - CRUD operations, member management Phase 4 (Week 4): Security - Rate limiting, brute force protection, testing Phase 5 (Week 5): Documentation - API docs, migration scripts, deployment

Each task includes:

  • Clear module boundaries and interfaces
  • Comprehensive acceptance criteria
  • Testing requirements
  • Estimated effort
  • File change list

This ensures a systematic, testable, and reviewable implementation process.