Merge branch 'dc/DC-145' into dc/DC-142
This commit is contained in:
4 files changed
+1264
No files matched your search
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
Reference in new issue
Block a user