← back to NationalPaperHangers

lib/auth/policies.js

90 lines

// lib/auth/policies.js
//
// Role-based access middleware factories. Reads req.session.userId (which
// Phase 2 will start setting; Phase 1 still uses installerId).
//
// Phase 1: NOT yet imported from server.js or any route. Exported for
// Phase 2 cutover. lib/auth.js (legacy installer-only auth) is left
// completely untouched.
//
// Design notes:
//   - Middleware factories only — no module-level DB calls.
//   - Each request hits `roles` once per call. Acceptable for Phase 2;
//     if it becomes hot we can stash roles on req.user during attachUser.
//   - JSON vs HTML response chosen via req.accepts('html'), matching the
//     existing requireInstaller pattern in lib/auth.js.

'use strict';

const usersService = require('../services/users');

// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────

function rejectAuth(req, res) {
  if (req.accepts && req.accepts('html')) {
    return res.redirect('/login?next=' + encodeURIComponent(req.originalUrl || '/'));
  }
  return res.status(401).json({ error: 'auth_required' });
}

function rejectForbidden(req, res, role) {
  if (req.accepts && req.accepts('html')) {
    return res.status(403).render('public/error', {
      title: 'Forbidden',
      message: `This page requires the ${role} role.`
    });
  }
  return res.status(403).json({ error: 'forbidden', required_role: role });
}

// ─────────────────────────────────────────────────────────────────────────
// requireRole(role | [roles])
//
// Allows the request through if the session user holds ANY of the listed
// roles. Decorates req.user with { id, roles[] } on success.
// ─────────────────────────────────────────────────────────────────────────

function requireRole(roleOrRoles) {
  const wanted = Array.isArray(roleOrRoles) ? roleOrRoles : [roleOrRoles];

  return async function policyMiddleware(req, res, next) {
    try {
      const userId = req.session && req.session.userId;
      if (!userId) return rejectAuth(req, res);

      const roles = await usersService.userRoles(userId);
      const allowed = wanted.some(r => roles.includes(r));
      if (!allowed) return rejectForbidden(req, res, wanted.join('|'));

      // Decorate request for downstream handlers — cheap, scoped to one req.
      req.user = req.user || { id: userId };
      req.user.roles = roles;
      return next();
    } catch (e) {
      return next(e);
    }
  };
}

// ─────────────────────────────────────────────────────────────────────────
// Convenience policies
// ─────────────────────────────────────────────────────────────────────────

const requireAdmin = requireRole('admin');
const requireOps   = requireRole(['admin', 'ops']);
const requireInstallerOwner = requireRole(['admin', 'installer_owner']);

// requireInstallerMember — owner OR member counts; useful for /admin/bookings
// where any seat on the installer team should be allowed.
const requireInstallerMember = requireRole(['admin', 'installer_owner', 'installer_member']);

module.exports = {
  requireRole,
  requireAdmin,
  requireOps,
  requireInstallerOwner,
  requireInstallerMember
};