154 lines
7.1 KiB
JavaScript
154 lines
7.1 KiB
JavaScript
/**
|
|
* 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;
|
|
};
|