/** * Network access grid routes — DC-145. * * Mounted by app.js under the authed apiRouter as /network/access (the * factory receives the store; no auth middleware lives here). * * GET /network/access → whole access map {grants, updatedAt} * PUT /network/access → atomic whole-grid save (validates, * dedupes last-wins) → saved map * POST /network/access/grant → upsert one grant {principal,resource,allowed} * DELETE /network/access/grant → remove one grant {principal,resource} * GET /network/access/effective/:principalType/:principalId * → effectiveAccess({type,id,groups?}) * groups from ?groups=a,b query param * * The access map file (not provider state) is the single source of truth — * writes here fire the store's change event which the ACL compiler (DC-148) * subscribes to. */ 'use strict'; const { ValidationError, NotFoundError } = require('../src/utilities/errors'); const { ok, created } = require('../src/utils/responses'); const PRINCIPAL_TYPES = ['person', 'group']; module.exports = function networkAccessRoutesFactory({ accessStore, asyncHandler, log = { info() {}, warn() {}, error() {} }, } = {}) { const router = require('express').Router(); // Store is required. Same lenient-404 policy as share routes: universal // deps stubs in test environments may not provide one; real runtime // misconfig must 404, not 500. if (!accessStore || typeof accessStore.getAccessMap !== 'function' || typeof accessStore.saveAccessMap !== 'function') { if (process.env.NODE_ENV === 'test') { log.warn && log.warn('network-access', 'accessStore missing — network access routes returning 404 in this environment'); } else { throw new Error('networkAccessRoutes requires accessStore'); } router.all('*', (_req, res) => res.status(404).json({ success: false, error: '[DC-145] network access store unavailable' })); return router; } if (!asyncHandler) { if (process.env.NODE_ENV !== 'test') { throw new Error('networkAccessRoutes requires asyncHandler'); } asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } function _parsePrincipalBody(body) { const principal = body && body.principal; if (!principal || typeof principal !== 'object' || Array.isArray(principal)) { throw new ValidationError('principal is required ({type, id})', 'principal'); } if (!PRINCIPAL_TYPES.includes(principal.type)) { throw new ValidationError('principal.type must be "person" or "group"', 'principal.type'); } if (typeof principal.id !== 'string' || principal.id.trim().length === 0) { throw new ValidationError('principal.id must be a non-empty string', 'principal.id'); } return principal; } function _parseResource(body) { const resource = body && body.resource; if (typeof resource !== 'string' || resource.trim().length === 0) { throw new ValidationError('resource must be a non-empty string', 'resource'); } return resource; } // ─── GET / — whole map ─────────────────────────────────────────────────── router.get('/', asyncHandler(async (req, res) => { const map = await accessStore.getAccessMap(); ok(res, { grants: map.grants, updatedAt: map.updatedAt }); }, 'network-access-get-map')); // ─── PUT / — whole-grid atomic save ────────────────────────────────────── router.put('/', asyncHandler(async (req, res) => { const body = req.body; if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new ValidationError('body must be an object with a grants array', 'grants'); } const result = await accessStore.saveAccessMap(body); if (!result.ok) { throw new ValidationError(result.reason || 'invalid_access_map', 'grants'); } log.info && log.info('network-access', 'whole-grid save', { count: result.map.grants.length, by: req.user && req.user.email, }); ok(res, { grants: result.map.grants, updatedAt: result.map.updatedAt }); }, 'network-access-put-map')); // ─── POST /grant — upsert one grant ────────────────────────────────────── router.post('/grant', asyncHandler(async (req, res) => { const principal = _parsePrincipalBody(req.body); const resource = _parseResource(req.body); const { allowed } = req.body || {}; if (typeof allowed !== 'boolean') { throw new ValidationError('allowed must be a boolean', 'allowed'); } const result = await accessStore.setGrant(principal, resource, allowed); if (!result.ok) { throw new ValidationError(result.reason || 'invalid_grant', 'grant'); } created(res, { grant: { principal, resource, allowed }, updatedAt: result.map.updatedAt }); }, 'network-access-set-grant')); // ─── DELETE /grant — remove one grant ──────────────────────────────────── router.delete('/grant', asyncHandler(async (req, res) => { // Accept the grant from the JSON body or from the query string so // curl-style deletes work without a body parser dependency. const body = { ...(req.body || {}), ...req.query }; const principal = _parsePrincipalBody(body); const resource = _parseResource(body); const result = await accessStore.removeGrant(principal, resource); if (!result.ok) { throw new ValidationError(result.reason || 'invalid_grant', 'grant'); } if (!result.removed) { // Idempotent semantics could also be 200; we 404 so the admin UI can // distinguish a real remove from a no-op (stale grid). throw new NotFoundError('grant not found'); } ok(res, { removed: true, updatedAt: result.map.updatedAt }); }, 'network-access-remove-grant')); // ─── GET /effective/:principalType/:principalId ────────────────────────── router.get('/effective/:principalType/:principalId', asyncHandler(async (req, res) => { const { principalType, principalId } = req.params; if (!PRINCIPAL_TYPES.includes(principalType)) { throw new ValidationError('principalType must be "person" or "group"', 'principalType'); } const principal = { type: principalType, id: principalId }; if (principalType === 'person' && typeof req.query.groups === 'string' && req.query.groups.length > 0) { principal.groups = req.query.groups.split(',').map(s => s.trim()).filter(Boolean); } const result = await accessStore.effectiveAccess(principal); if (!result.ok) { throw new ValidationError(result.reason || 'invalid_principal', 'principal'); } ok(res, { principal: { type: principalType, id: principalId }, resources: result.resources }); }, 'network-access-effective')); return router; };