feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
|
|
|
// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a
|
|
|
|
|
// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
2026-04-07 21:58:27 -04:00
|
|
|
import express from 'express';
|
|
|
|
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
|
|
|
|
const CAPTURE_URL = process.env.CAPTURE_URL || 'http://capture:3001';
|
|
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
async function proxyRequest(method, path, body = null) {
|
2026-04-07 21:58:27 -04:00
|
|
|
const options = {
|
|
|
|
|
method,
|
2026-05-20 13:55:06 -04:00
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
signal: AbortSignal.timeout(8000),
|
2026-04-07 21:58:27 -04:00
|
|
|
};
|
2026-05-20 13:55:06 -04:00
|
|
|
if (body) options.body = JSON.stringify(body);
|
2026-04-07 21:58:27 -04:00
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
const response = await fetch(`${CAPTURE_URL}${path}`, options);
|
|
|
|
|
const text = await response.text();
|
2026-04-07 21:58:27 -04:00
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
let data;
|
2026-04-07 21:58:27 -04:00
|
|
|
try {
|
2026-05-20 13:55:06 -04:00
|
|
|
data = JSON.parse(text);
|
|
|
|
|
} catch {
|
|
|
|
|
// Capture service returned non-JSON (HTML error page, plain text, etc.)
|
|
|
|
|
data = { message: text.slice(0, 300) || '(empty response)' };
|
2026-04-07 21:58:27 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
return { status: response.status, data };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// POST /start
|
2026-04-07 21:58:27 -04:00
|
|
|
router.post('/start', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { status, data } = await proxyRequest('POST', '/start', req.body);
|
|
|
|
|
res.status(status).json(data);
|
2026-05-20 13:55:06 -04:00
|
|
|
} catch (err) { next(err); }
|
2026-04-07 21:58:27 -04:00
|
|
|
});
|
|
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
// POST /stop
|
2026-04-07 21:58:27 -04:00
|
|
|
router.post('/stop', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { status, data } = await proxyRequest('POST', '/stop', req.body);
|
|
|
|
|
res.status(status).json(data);
|
2026-05-20 13:55:06 -04:00
|
|
|
} catch (err) { next(err); }
|
2026-04-07 21:58:27 -04:00
|
|
|
});
|
|
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
// GET /status
|
2026-04-07 21:58:27 -04:00
|
|
|
router.get('/status', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { status, data } = await proxyRequest('GET', '/status');
|
|
|
|
|
res.status(status).json(data);
|
2026-05-20 13:55:06 -04:00
|
|
|
} catch (err) { next(err); }
|
2026-04-07 21:58:27 -04:00
|
|
|
});
|
|
|
|
|
|
2026-05-20 13:55:06 -04:00
|
|
|
// GET /devices
|
2026-04-07 21:58:27 -04:00
|
|
|
router.get('/devices', async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { status, data } = await proxyRequest('GET', '/devices');
|
|
|
|
|
res.status(status).json(data);
|
2026-05-20 13:55:06 -04:00
|
|
|
} catch (err) { next(err); }
|
2026-04-07 21:58:27 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|