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>
This commit is contained in:
parent
9d6bbf8112
commit
ec026195eb
20 changed files with 879 additions and 58 deletions
|
|
@ -25,6 +25,13 @@ MAM_API_URL=http://mam-api:3000
|
|||
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||||
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
||||
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
|
||||
#
|
||||
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
|
||||
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
|
||||
# user with the access they need — an 'admin' user (full access), or a user with
|
||||
# the right project grants. A non-admin service token with no grants will get
|
||||
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
|
||||
# user is admin, so this only matters once auth is on.
|
||||
AUTH_ENABLED=true
|
||||
|
||||
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||||
|
|
|
|||
90
services/mam-api/src/auth/authz.js
Normal file
90
services/mam-api/src/auth/authz.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Per-project authorization — the single source of truth for "can this user
|
||||
// touch this project?". v1 auth answers "are you logged in?"; this answers
|
||||
// "which projects, and at what level?".
|
||||
//
|
||||
// Model (locked with Zac):
|
||||
// - role 'admin' → global bypass; every project at 'edit'.
|
||||
// - role 'editor'/'viewer' → scoped to projects granted to them directly
|
||||
// (project_access subject_type='user') or via a
|
||||
// group they belong to (subject_type='group').
|
||||
// - grant level 'view' → read-only; 'edit' → read-write.
|
||||
//
|
||||
// A user's effective level on a project is the MAX of every matching grant
|
||||
// (direct + each group). 'edit' outranks 'view'.
|
||||
//
|
||||
// All functions take an optional `db` (defaults to the shared pool) so tests
|
||||
// can inject an isolated test pool.
|
||||
|
||||
import defaultPool from '../db/pool.js';
|
||||
|
||||
const LEVEL_RANK = { view: 1, edit: 2 };
|
||||
|
||||
export function isAdmin(user) {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
|
||||
// Returns the higher of two levels (either may be null/undefined).
|
||||
function maxLevel(a, b) {
|
||||
const ra = LEVEL_RANK[a] || 0;
|
||||
const rb = LEVEL_RANK[b] || 0;
|
||||
if (ra === 0 && rb === 0) return null;
|
||||
return ra >= rb ? a : b;
|
||||
}
|
||||
|
||||
// Resolve every project the user can see, with their effective level.
|
||||
// admin → { all: true, ids: null, levelByProject: null }
|
||||
// else → { all: false, ids: Set<projectId>, levelByProject: Map<projectId, 'view'|'edit'> }
|
||||
export async function accessibleProjectIds(user, db = defaultPool) {
|
||||
if (isAdmin(user)) return { all: true, ids: null, levelByProject: null };
|
||||
|
||||
const levelByProject = new Map();
|
||||
if (!user?.id) return { all: false, ids: new Set(), levelByProject };
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT pa.project_id, pa.level
|
||||
FROM project_access pa
|
||||
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
|
||||
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
||||
SELECT group_id FROM user_groups WHERE user_id = $1
|
||||
))`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
levelByProject.set(r.project_id, maxLevel(levelByProject.get(r.project_id), r.level));
|
||||
}
|
||||
return { all: false, ids: new Set(levelByProject.keys()), levelByProject };
|
||||
}
|
||||
|
||||
// Effective level on a single project: 'edit' | 'view' | null.
|
||||
export async function projectLevel(user, projectId, db = defaultPool) {
|
||||
if (isAdmin(user)) return 'edit';
|
||||
if (!user?.id || !projectId) return null;
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT pa.level
|
||||
FROM project_access pa
|
||||
WHERE pa.project_id = $1
|
||||
AND ( (pa.subject_type = 'user' AND pa.subject_id = $2)
|
||||
OR (pa.subject_type = 'group' AND pa.subject_id IN (
|
||||
SELECT group_id FROM user_groups WHERE user_id = $2
|
||||
)) )`,
|
||||
[projectId, user.id]
|
||||
);
|
||||
|
||||
let level = null;
|
||||
for (const r of rows) level = maxLevel(level, r.level);
|
||||
return level;
|
||||
}
|
||||
|
||||
// Throw a 403-shaped error (caught by errorHandler) unless the user has at
|
||||
// least `need` access on the project. `need` ∈ 'view' | 'edit'.
|
||||
export async function assertProjectAccess(user, projectId, need = 'view', db = defaultPool) {
|
||||
if (isAdmin(user)) return;
|
||||
const have = await projectLevel(user, projectId, db);
|
||||
if (!have || (LEVEL_RANK[have] || 0) < (LEVEL_RANK[need] || 0)) {
|
||||
const err = new Error('forbidden');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
30
services/mam-api/src/db/migrations/026-project-access.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- Migration 026 — per-project access grants (RBAC v2).
|
||||
--
|
||||
-- v1 auth is flat: any logged-in user can do everything. This adds per-project
|
||||
-- scoping. A grant targets either a user or a group (polymorphic subject) and
|
||||
-- carries a level: 'view' (read-only) or 'edit' (read-write). Admins bypass all
|
||||
-- of this in code (authz.js) and need no rows here.
|
||||
--
|
||||
-- subject_id is intentionally NOT a foreign key — it points at either users.id
|
||||
-- or groups.id depending on subject_type. Rows are cleaned up when the project
|
||||
-- is deleted (FK cascade). A deleted user/group leaves an orphan row that
|
||||
-- resolves to nobody (harmless); a later sweep can prune them if desired.
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
|
||||
CREATE TYPE access_level AS ENUM ('view', 'edit');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_access (
|
||||
project_id UUID NOT NULL REFERENCES projects ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user', 'group')),
|
||||
subject_id UUID NOT NULL,
|
||||
level access_level NOT NULL DEFAULT 'view',
|
||||
granted_by UUID REFERENCES users ON DELETE SET NULL,
|
||||
granted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, subject_type, subject_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_access_subject
|
||||
ON project_access (subject_type, subject_id);
|
||||
|
|
@ -8,7 +8,7 @@ import os from 'node:os';
|
|||
import { exec } from 'node:child_process';
|
||||
import pool from './db/pool.js';
|
||||
import { errorHandler } from './middleware/errors.js';
|
||||
import { requireAuth, requireUiHeader } from './middleware/auth.js';
|
||||
import { requireAuth, requireUiHeader, requireAdmin } from './middleware/auth.js';
|
||||
import { loadS3ConfigFromDb } from './s3/client.js';
|
||||
|
||||
import authRouter from './routes/auth.js';
|
||||
|
|
@ -117,8 +117,10 @@ app.use('/api/v1', (req, res, next) => {
|
|||
|
||||
// ── API Routes ────────────────────────────────────────────────────────────────
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/auth/users', usersRouter);
|
||||
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
|
||||
// User and group administration is admin-only (RBAC v2). The auth gate above
|
||||
// already established req.user; requireAdmin rejects non-admins with 403.
|
||||
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
|
||||
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page
|
||||
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||||
app.use('/api/v1/assets', assetsRouter);
|
||||
app.use('/api/v1/projects', projectsRouter);
|
||||
|
|
@ -129,7 +131,7 @@ app.use('/api/v1/upload', uploadRouter);
|
|||
app.use('/api/v1/recorders', recordersRouter);
|
||||
app.use('/api/v1/settings', settingsRouter);
|
||||
app.use('/api/v1/ampp', amppRouter);
|
||||
app.use('/api/v1/groups', groupsRouter);
|
||||
app.use('/api/v1/groups', requireAdmin, groupsRouter);
|
||||
app.use('/api/v1/sequences', sequencesRouter);
|
||||
app.use('/api/v1/system', systemRouter);
|
||||
app.use('/api/v1/cluster', clusterRouter);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { parseBearer, hashToken } from '../auth/tokens.js';
|
|||
// Stable UUID matching migration 023's seeded dev user.
|
||||
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
||||
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
|
||||
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
|
||||
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
|
||||
// RBAC v2 gates — matches migration 023's seeded dev row.
|
||||
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
|
||||
|
||||
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
||||
const IDLE_MS = 1 * 3600 * 1000;
|
||||
|
|
@ -73,6 +75,14 @@ export async function requireAuth(req, res, next) {
|
|||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
// Gate a route to admins only. requireAuth must run first (it sets req.user).
|
||||
// 401 when unauthenticated, 403 when authenticated but not an admin.
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||
return next();
|
||||
}
|
||||
|
||||
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
||||
// cookie sends, but a custom header that no <form> can produce hardens
|
||||
// against the edge cases. Applied to mutating verbs only.
|
||||
|
|
|
|||
|
|
@ -7,9 +7,36 @@ import pool from '../db/pool.js';
|
|||
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
|
||||
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Every /:id asset route is scoped to the asset's project. The param handler
|
||||
// validates the UUID, resolves the owning project_id, and asserts at least
|
||||
// 'view' access (the baseline for touching an asset at all). Mutating routes
|
||||
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
|
||||
// 404 here rather than leaking existence to users without access.
|
||||
router.param('id', async (req, res, next) => {
|
||||
validateUuid('id')(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
req.assetProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.assetProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Route-level guard for mutating /:id endpoints — escalates the param handler's
|
||||
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
|
||||
async function requireAssetEdit(req, res, next) {
|
||||
try {
|
||||
await assertProjectAccess(req.user, req.assetProjectId, 'edit');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// BullMQ queue connection (mirrors worker/src/index.js)
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -66,6 +93,15 @@ router.get('/', async (req, res, next) => {
|
|||
const params = [];
|
||||
let paramCount = 1;
|
||||
|
||||
// Scope to projects the caller can access (admins are unfiltered). Without
|
||||
// this, a granted user would see every asset across every project.
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
if (!access.all) {
|
||||
if (access.ids.size === 0) return res.json({ assets: [], total: 0 });
|
||||
query += ` AND a.project_id = ANY($${paramCount++}::uuid[])`;
|
||||
params.push([...access.ids]);
|
||||
}
|
||||
|
||||
// Exclude archived unless explicitly requested — independent of status filter
|
||||
if (include_archived !== 'true') {
|
||||
query += ` AND a.status <> 'archived'`;
|
||||
|
|
@ -132,6 +168,9 @@ router.post('/', async (req, res, next) => {
|
|||
return res.status(400).json({ error: 'projectId and clipName are required' });
|
||||
}
|
||||
|
||||
// Registering an asset writes into a project — require edit access there.
|
||||
await assertProjectAccess(req.user, projectId, 'edit');
|
||||
|
||||
const durationNum = duration !== undefined && duration !== null ? Number(duration) : null;
|
||||
if (durationNum !== null && !Number.isFinite(durationNum)) {
|
||||
return res.status(400).json({ error: 'duration must be a finite number (seconds)' });
|
||||
|
|
@ -220,8 +259,8 @@ router.post('/', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /cleanup-live
|
||||
router.post('/cleanup-live', async (req, res, next) => {
|
||||
// POST /cleanup-live — cross-project maintenance, admin only.
|
||||
router.post('/cleanup-live', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const maxAgeHours = Math.max(1, parseInt(req.query.max_age_hours || '4', 10));
|
||||
const result = await pool.query(
|
||||
|
|
@ -234,8 +273,8 @@ router.post('/cleanup-live', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /cleanup-live-orphans
|
||||
router.post('/cleanup-live-orphans', async (_req, res, next) => {
|
||||
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
|
||||
router.post('/cleanup-live-orphans', requireAdmin, async (_req, res, next) => {
|
||||
try {
|
||||
const liveRoot = process.env.LIVE_DIR || '/live';
|
||||
let entries;
|
||||
|
|
@ -277,7 +316,7 @@ router.get('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// PATCH /:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { display_name, tags, notes, bin_id } = req.body;
|
||||
|
|
@ -299,7 +338,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/copy
|
||||
router.post('/:id/copy', async (req, res, next) => {
|
||||
router.post('/:id/copy', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { binId, projectId } = req.body;
|
||||
|
|
@ -346,7 +385,7 @@ router.post('/:id/copy', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/mark-empty
|
||||
router.post('/:id/mark-empty', async (req, res, next) => {
|
||||
router.post('/:id/mark-empty', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Bug #66: first check the asset exists and what status it is in
|
||||
|
|
@ -384,7 +423,7 @@ router.post('/:id/mark-empty', async (req, res, next) => {
|
|||
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
|
||||
// flips it out of 'live', records duration + S3 keys, and kicks off the
|
||||
// proxy -> thumbnail -> filmstrip job chain.
|
||||
router.post('/:id/finalize', async (req, res, next) => {
|
||||
router.post('/:id/finalize', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { hiresKey, proxyKey, duration } = req.body;
|
||||
|
|
@ -436,7 +475,7 @@ router.post('/:id/finalize', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/generate-proxy
|
||||
router.post('/:id/generate-proxy', async (req, res, next) => {
|
||||
router.post('/:id/generate-proxy', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
|
|
@ -452,8 +491,8 @@ router.post('/:id/generate-proxy', async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /backfill-proxies
|
||||
router.post('/backfill-proxies', async (_req, res, next) => {
|
||||
// POST /backfill-proxies — cross-project maintenance, admin only.
|
||||
router.post('/backfill-proxies', requireAdmin, async (_req, res, next) => {
|
||||
try {
|
||||
const targets = await pool.query(
|
||||
`SELECT id, original_s3_key FROM assets
|
||||
|
|
@ -477,7 +516,7 @@ router.post('/backfill-proxies', async (_req, res, next) => {
|
|||
|
||||
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
|
||||
// Force-requeue a processing job regardless of current asset status.
|
||||
router.post('/:id/reprocess', async (req, res, next) => {
|
||||
router.post('/:id/reprocess', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const type = req.query.type || 'proxy';
|
||||
|
|
@ -528,7 +567,7 @@ router.get('/:id/filmstrip', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/retry
|
||||
router.post('/:id/retry', async (req, res, next) => {
|
||||
router.post('/:id/retry', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
|
|
@ -547,7 +586,7 @@ router.post('/:id/retry', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireAssetEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { hard } = req.query;
|
||||
|
|
@ -908,6 +947,15 @@ router.post('/batch-trim', async (req, res, next) => {
|
|||
return res.status(400).json({ error: 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' });
|
||||
}
|
||||
}
|
||||
// Authorize every source asset's project (edit) before queuing any work.
|
||||
const trimAssetIds = [...new Set(clips.map(c => c.assetId))];
|
||||
const owning = await pool.query('SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])', [trimAssetIds]);
|
||||
const projById = new Map(owning.rows.map(r => [r.id, r.project_id]));
|
||||
for (const aid of trimAssetIds) {
|
||||
const pid = projById.get(aid);
|
||||
if (!pid) return res.status(404).json({ error: 'Asset not found: ' + aid });
|
||||
await assertProjectAccess(req.user, pid, 'edit');
|
||||
}
|
||||
const jobId = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await pool.query(
|
||||
|
|
|
|||
|
|
@ -1,25 +1,60 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// GET / - List bins. Filter by project_id when supplied; otherwise return
|
||||
// every bin across every project so the Library / asset-context-menu can
|
||||
// present a global "move to bin" picker.
|
||||
// Resolve the owning project for a /:id bin, assert 'view' baseline, stash the
|
||||
// project_id for mutating routes to escalate to 'edit'.
|
||||
router.param('id', async (req, res, next) => {
|
||||
validateUuid('id')(req, res, () => {});
|
||||
if (res.headersSent) return;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT project_id FROM bins WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Bin not found' });
|
||||
req.binProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.binProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
async function requireBinEdit(req, res, next) {
|
||||
try {
|
||||
await assertProjectAccess(req.user, req.binProjectId, 'edit');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// GET / - List bins. When project_id is supplied, scope to it (after an access
|
||||
// check); otherwise return bins across every project the caller can access.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (project_id) {
|
||||
where = 'WHERE b.project_id = $1';
|
||||
params.push(project_id);
|
||||
await assertProjectAccess(req.user, project_id, 'view');
|
||||
const result = await pool.query(
|
||||
`SELECT b.*, p.name AS project_name,
|
||||
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||
FROM bins b
|
||||
LEFT JOIN projects p ON p.id = b.project_id
|
||||
WHERE b.project_id = $1
|
||||
ORDER BY b.created_at DESC`,
|
||||
[project_id]
|
||||
);
|
||||
return res.json(result.rows);
|
||||
}
|
||||
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
let where = '';
|
||||
const params = [];
|
||||
if (!access.all) {
|
||||
if (access.ids.size === 0) return res.json([]);
|
||||
where = 'WHERE b.project_id = ANY($1::uuid[])';
|
||||
params.push([...access.ids]);
|
||||
}
|
||||
const result = await pool.query(
|
||||
`SELECT b.*, p.name AS project_name,
|
||||
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
|
||||
|
|
@ -29,14 +64,13 @@ router.get('/', async (req, res, next) => {
|
|||
ORDER BY b.created_at DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create bin
|
||||
// POST / - Create bin (requires edit on the target project).
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { project_id, name, parent_id } = req.body;
|
||||
|
|
@ -44,6 +78,7 @@ router.post('/', async (req, res, next) => {
|
|||
if (!project_id || !name) {
|
||||
return res.status(400).json({ error: 'project_id and name are required' });
|
||||
}
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
|
||||
const id = uuidv4();
|
||||
|
||||
|
|
@ -61,7 +96,7 @@ router.post('/', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// PATCH /:id - Update bin
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, parent_id } = req.body;
|
||||
|
|
@ -107,7 +142,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete bin
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -126,8 +161,8 @@ router.delete('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /:id/assets - Add asset to bin
|
||||
router.post('/:id/assets', async (req, res, next) => {
|
||||
// POST /:id/assets - Add asset to bin (requires edit on the bin's project).
|
||||
router.post('/:id/assets', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { asset_id } = req.body;
|
||||
|
|
@ -158,8 +193,8 @@ router.post('/:id/assets', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin
|
||||
router.delete('/:id/assets/:assetId', async (req, res, next) => {
|
||||
// DELETE /:id/assets/:assetId - Remove asset from bin (requires edit).
|
||||
router.delete('/:id/assets/:assetId', requireBinEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id, assetId } = req.params;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// TODO(authz): per-project scoping not yet enforced. Capture sessions carry a
|
||||
// project_id; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
// Asset-scoped comments for the Asset Detail page.
|
||||
//
|
||||
// TODO(authz): per-project scoping not yet enforced. Comments hang off an asset
|
||||
// (resolve project via the asset); adopt assertProjectAccess (auth/authz.js).
|
||||
//
|
||||
// Mounted at /api/v1/assets/:assetId/comments via app.use('/api/v1/assets/:assetId/comments', router).
|
||||
// Express's :assetId param flows through from the parent mount.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
// External media imports — currently YouTube only.
|
||||
//
|
||||
// TODO(authz): per-project scoping not yet enforced. Imports target a project_id;
|
||||
// adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
||||
//
|
||||
// The flow mirrors upload.js: create the asset row up front with a placeholder
|
||||
// filename (the worker fills in the real title once yt-dlp prints metadata),
|
||||
// then enqueue a BullMQ job. The worker downloads, lands the file in S3 at the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { requireAdmin } from '../middleware/auth.js';
|
||||
import { accessibleProjectIds, assertProjectAccess } from '../auth/authz.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -16,18 +18,29 @@ const slugify = (str) => {
|
|||
.replace(/-+/g, '-');
|
||||
};
|
||||
|
||||
// GET / - List all projects
|
||||
// GET / - List projects the caller can access (admins see all).
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
if (access.all) {
|
||||
const result = await pool.query('SELECT * FROM projects ORDER BY created_at DESC');
|
||||
return res.json(result.rows);
|
||||
}
|
||||
if (access.ids.size === 0) return res.json([]);
|
||||
const ids = [...access.ids];
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM projects WHERE id = ANY($1::uuid[]) ORDER BY created_at DESC`,
|
||||
[ids]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST / - Create project
|
||||
router.post('/', async (req, res, next) => {
|
||||
// POST / - Create project (admin only; new projects have no grants, so a
|
||||
// scoped user could never reach one they just made).
|
||||
router.post('/', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
|
|
@ -51,10 +64,11 @@ router.post('/', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /:id - Single project with asset count
|
||||
// GET /:id - Single project with asset count (requires view access).
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'view');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
|
|
@ -76,10 +90,11 @@ router.get('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PATCH /:id - Update project
|
||||
// PATCH /:id - Update project (requires edit access).
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await assertProjectAccess(req.user, id, 'edit');
|
||||
const { name, description } = req.body;
|
||||
|
||||
const updates = [];
|
||||
|
|
@ -122,8 +137,9 @@ router.patch('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /:id - Delete project and cascade
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
// DELETE /:id - Delete project and cascade (admin only — destructive, wipes
|
||||
// every asset/bin/recorder under it).
|
||||
router.delete('/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -143,4 +159,78 @@ router.delete('/:id', async (req, res, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── Per-project access grants (admin only) ──────────────────────────────────
|
||||
// GET /:id/access — list grants with resolved user/group display names.
|
||||
router.get('/:id/access', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pa.subject_type, pa.subject_id, pa.level, pa.granted_at,
|
||||
CASE pa.subject_type
|
||||
WHEN 'user' THEN u.display_name
|
||||
WHEN 'group' THEN g.name
|
||||
END AS subject_name,
|
||||
CASE pa.subject_type
|
||||
WHEN 'user' THEN u.username
|
||||
ELSE NULL
|
||||
END AS username
|
||||
FROM project_access pa
|
||||
LEFT JOIN users u ON pa.subject_type = 'user' AND u.id = pa.subject_id
|
||||
LEFT JOIN groups g ON pa.subject_type = 'group' AND g.id = pa.subject_id
|
||||
WHERE pa.project_id = $1
|
||||
ORDER BY pa.subject_type, subject_name`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /:id/access { subject_type, subject_id, level } — grant or update.
|
||||
router.post('/:id/access', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { subject_type, subject_id, level } = req.body || {};
|
||||
if (!['user', 'group'].includes(subject_type)) {
|
||||
return res.status(400).json({ error: "subject_type must be 'user' or 'group'" });
|
||||
}
|
||||
if (!subject_id) return res.status(400).json({ error: 'subject_id required' });
|
||||
const lvl = level || 'view';
|
||||
if (!['view', 'edit'].includes(lvl)) {
|
||||
return res.status(400).json({ error: "level must be 'view' or 'edit'" });
|
||||
}
|
||||
|
||||
// Validate the subject actually exists so we don't create dead grants.
|
||||
const tbl = subject_type === 'user' ? 'users' : 'groups';
|
||||
const exists = await pool.query(`SELECT 1 FROM ${tbl} WHERE id = $1`, [subject_id]);
|
||||
if (exists.rows.length === 0) {
|
||||
return res.status(404).json({ error: subject_type + ' not found' });
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, subject_type, subject_id)
|
||||
DO UPDATE SET level = EXCLUDED.level, granted_by = EXCLUDED.granted_by, granted_at = NOW()
|
||||
RETURNING project_id, subject_type, subject_id, level, granted_at`,
|
||||
[req.params.id, subject_type, subject_id, lvl, req.user?.id || null]
|
||||
);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /:id/access/:subjectType/:subjectId — revoke a grant.
|
||||
router.delete('/:id/access/:subjectType/:subjectId', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const { id, subjectType, subjectId } = req.params;
|
||||
if (!['user', 'group'].includes(subjectType)) {
|
||||
return res.status(400).json({ error: "subjectType must be 'user' or 'group'" });
|
||||
}
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM project_access
|
||||
WHERE project_id = $1 AND subject_type = $2 AND subject_id = $3`,
|
||||
[id, subjectType, subjectId]
|
||||
);
|
||||
if (rowCount === 0) return res.status(404).json({ error: 'grant not found' });
|
||||
res.status(204).end();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// TODO(authz): per-project scoping not yet enforced. Recorders carry project_id;
|
||||
// adopt assertProjectAccess/accessibleProjectIds (auth/authz.js) — see bins.js.
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// services/mam-api/src/routes/sequences.js
|
||||
// TODO(authz): per-project scoping not yet enforced. Sequences belong to a
|
||||
// project; adopt assertProjectAccess (auth/authz.js) — see bins.js pattern.
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
|
|
|
|||
125
services/mam-api/test/auth/authz.test.js
Normal file
125
services/mam-api/test/auth/authz.test.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
import {
|
||||
isAdmin,
|
||||
accessibleProjectIds,
|
||||
projectLevel,
|
||||
assertProjectAccess,
|
||||
} from '../../src/auth/authz.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
// ── isAdmin (pure, no DB) ───────────────────────────────────────────────────
|
||||
test('isAdmin true only for role admin', () => {
|
||||
assert.equal(isAdmin({ role: 'admin' }), true);
|
||||
assert.equal(isAdmin({ role: 'editor' }), false);
|
||||
assert.equal(isAdmin({ role: 'viewer' }), false);
|
||||
assert.equal(isAdmin(null), false);
|
||||
assert.equal(isAdmin(undefined), false);
|
||||
});
|
||||
|
||||
// Seed helpers shared across the DB-backed cases.
|
||||
async function seed(pool) {
|
||||
const proj = async (name) =>
|
||||
(await pool.query(
|
||||
`INSERT INTO projects (name, s3_prefix) VALUES ($1, $1) RETURNING id`, [name]
|
||||
)).rows[0].id;
|
||||
const user = async (username, role) =>
|
||||
(await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', $2) RETURNING id`,
|
||||
[username, role]
|
||||
)).rows[0].id;
|
||||
const group = async (name) =>
|
||||
(await pool.query(`INSERT INTO groups (name) VALUES ($1) RETURNING id`, [name])).rows[0].id;
|
||||
const grantUser = (pid, uid, level) =>
|
||||
pool.query(
|
||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
||||
VALUES ($1, 'user', $2, $3)`, [pid, uid, level]);
|
||||
const grantGroup = (pid, gid, level) =>
|
||||
pool.query(
|
||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level)
|
||||
VALUES ($1, 'group', $2, $3)`, [pid, gid, level]);
|
||||
const addToGroup = (uid, gid) =>
|
||||
pool.query(`INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2)`, [uid, gid]);
|
||||
return { proj, user, group, grantUser, grantGroup, addToGroup };
|
||||
}
|
||||
|
||||
test('admin sees all projects, every project at edit', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
try {
|
||||
const s = await seed(pool);
|
||||
await s.proj('Alpha'); await s.proj('Beta');
|
||||
const admin = { id: await s.user('adm', 'admin'), role: 'admin' };
|
||||
|
||||
const acc = await accessibleProjectIds(admin, pool);
|
||||
assert.equal(acc.all, true);
|
||||
assert.equal(await projectLevel(admin, '00000000-0000-4000-8000-000000000001', pool), 'edit');
|
||||
await assertProjectAccess(admin, '00000000-0000-4000-8000-000000000001', 'edit', pool); // no throw
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('direct user grant scopes access and respects level', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
try {
|
||||
const s = await seed(pool);
|
||||
const alpha = await s.proj('Alpha');
|
||||
const beta = await s.proj('Beta');
|
||||
const u = { id: await s.user('bob', 'editor'), role: 'editor' };
|
||||
await s.grantUser(alpha, u.id, 'view');
|
||||
|
||||
const acc = await accessibleProjectIds(u, pool);
|
||||
assert.equal(acc.all, false);
|
||||
assert.deepEqual([...acc.ids], [alpha]);
|
||||
assert.equal(await projectLevel(u, alpha, pool), 'view');
|
||||
assert.equal(await projectLevel(u, beta, pool), null);
|
||||
|
||||
await assertProjectAccess(u, alpha, 'view', pool); // ok
|
||||
await assert.rejects(() => assertProjectAccess(u, alpha, 'edit', pool), e => e.status === 403);
|
||||
await assert.rejects(() => assertProjectAccess(u, beta, 'view', pool), e => e.status === 403);
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('group grant flows through membership', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
try {
|
||||
const s = await seed(pool);
|
||||
const alpha = await s.proj('Alpha');
|
||||
const u = { id: await s.user('carol', 'viewer'), role: 'viewer' };
|
||||
const g = await s.group('broadcasters');
|
||||
await s.addToGroup(u.id, g);
|
||||
await s.grantGroup(alpha, g, 'edit');
|
||||
|
||||
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
||||
const acc = await accessibleProjectIds(u, pool);
|
||||
assert.deepEqual([...acc.ids], [alpha]);
|
||||
await assertProjectAccess(u, alpha, 'edit', pool); // ok via group
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('effective level is the max of direct + group grants', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
try {
|
||||
const s = await seed(pool);
|
||||
const alpha = await s.proj('Alpha');
|
||||
const u = { id: await s.user('dan', 'editor'), role: 'editor' };
|
||||
const g = await s.group('team');
|
||||
await s.addToGroup(u.id, g);
|
||||
await s.grantUser(alpha, u.id, 'view'); // direct: view
|
||||
await s.grantGroup(alpha, g, 'edit'); // group: edit → wins
|
||||
|
||||
assert.equal(await projectLevel(u, alpha, pool), 'edit');
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('user with no grants sees nothing', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
try {
|
||||
const s = await seed(pool);
|
||||
await s.proj('Alpha');
|
||||
const u = { id: await s.user('eve', 'viewer'), role: 'viewer' };
|
||||
|
||||
const acc = await accessibleProjectIds(u, pool);
|
||||
assert.equal(acc.ids.size, 0);
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
79
services/mam-api/test/routes/assets-access.test.js
Normal file
79
services/mam-api/test/routes/assets-access.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Regression test: GET /api/v1/assets must be scoped to the caller's accessible
|
||||
// projects. A pre-fix bug returned every asset across every project to any
|
||||
// authenticated user, defeating RBAC v2.
|
||||
//
|
||||
// Like project-access.test.js, the assets router uses the singleton pool, so we
|
||||
// point DATABASE_URL at TEST_DATABASE_URL and seed through the same pool.
|
||||
// Skips when TEST_DATABASE_URL is unset.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
async function appWithAssets(user) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
// Importing the assets router constructs BullMQ queues; they connect lazily,
|
||||
// and the list route only touches Postgres, so no Redis is needed here.
|
||||
const { default: assetsRouter } = await import('../../src/routes/assets.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.user = user; next(); });
|
||||
app.use('/api/v1/assets', assetsRouter);
|
||||
return new Promise(r => {
|
||||
const srv = app.listen(0, '127.0.0.1', () =>
|
||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
||||
});
|
||||
}
|
||||
|
||||
async function seed(pool) {
|
||||
const proj = async (n) => (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ($1,$1) RETURNING id`, [n])).rows[0].id;
|
||||
const alpha = await proj('Alpha');
|
||||
const beta = await proj('Beta');
|
||||
const asset = (pid, name) => pool.query(
|
||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status)
|
||||
VALUES ($1, $2, $2, 'video', 'ready')`, [pid, name]);
|
||||
await asset(alpha, 'a1'); await asset(alpha, 'a2'); await asset(beta, 'b1');
|
||||
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
|
||||
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
||||
await pool.query(
|
||||
`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`,
|
||||
[alpha, scoped.id]);
|
||||
return { alpha, beta, admin, scoped };
|
||||
}
|
||||
|
||||
test('GET /assets returns only assets in granted projects for scoped users', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { admin, scoped } = await seed(pool);
|
||||
|
||||
// Admin sees all three.
|
||||
let a = await appWithAssets(admin);
|
||||
let body = await (await fetch(a.baseUrl + '/api/v1/assets')).json();
|
||||
assert.equal(body.assets.length, 3, 'admin should see every asset');
|
||||
await a.close();
|
||||
|
||||
// Scoped viewer (granted only Alpha) sees the two Alpha assets, not Beta's.
|
||||
let s = await appWithAssets(scoped);
|
||||
body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
|
||||
assert.equal(body.assets.length, 2, 'scoped user should see only granted-project assets');
|
||||
assert.ok(body.assets.every(x => x.display_name !== 'b1'), 'must not leak ungranted-project asset');
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('GET /assets returns nothing for a user with no grants', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await seed(pool);
|
||||
const nobody = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('no','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
||||
const s = await appWithAssets(nobody);
|
||||
const body = await (await fetch(s.baseUrl + '/api/v1/assets')).json();
|
||||
assert.deepEqual(body, { assets: [], total: 0 });
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
125
services/mam-api/test/routes/project-access.test.js
Normal file
125
services/mam-api/test/routes/project-access.test.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Integration test for per-project RBAC: the grant-management API on the
|
||||
// projects router + scoped enforcement on GET /projects and GET /projects/:id.
|
||||
//
|
||||
// NOTE: the routers use the singleton pool (src/db/pool.js), which reads
|
||||
// DATABASE_URL. We point DATABASE_URL at the throwaway TEST_DATABASE_URL and
|
||||
// seed through that same singleton pool so the router and the test share one
|
||||
// database. Skips cleanly when TEST_DATABASE_URL is unset.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
// Build an app that injects a chosen user as req.user (simulating requireAuth),
|
||||
// then mounts the real projects router with the same admin gate index.js uses.
|
||||
async function appWithProjects(user) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
const { default: projectsRouter } = await import('../../src/routes/projects.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.user = user; next(); });
|
||||
app.use('/api/v1/projects', projectsRouter);
|
||||
return new Promise(r => {
|
||||
const srv = app.listen(0, '127.0.0.1', () =>
|
||||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }));
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBaseline(pool) {
|
||||
const alpha = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Alpha','alpha') RETURNING id`)).rows[0].id;
|
||||
const beta = (await pool.query(`INSERT INTO projects (name, s3_prefix) VALUES ('Beta','beta') RETURNING id`)).rows[0].id;
|
||||
const admin = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('adm','x','admin') RETURNING id`)).rows[0].id, role: 'admin' };
|
||||
const scoped = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
||||
return { alpha, beta, admin, scoped };
|
||||
}
|
||||
|
||||
test('admin grants a project, scoped user then sees only it; revoke removes access', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, beta, admin, scoped } = await seedBaseline(pool);
|
||||
|
||||
// Scoped viewer initially sees nothing.
|
||||
let s = await appWithProjects(scoped);
|
||||
let list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
||||
assert.equal(list.length, 0, 'scoped user should see no projects before any grant');
|
||||
// And cannot read Alpha directly.
|
||||
let direct = await fetch(s.baseUrl + '/api/v1/projects/' + alpha);
|
||||
assert.equal(direct.status, 403);
|
||||
await s.close();
|
||||
|
||||
// Admin grants the scoped user 'view' on Alpha.
|
||||
const a = await appWithProjects(admin);
|
||||
const grant = await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'view' }),
|
||||
});
|
||||
assert.equal(grant.status, 201);
|
||||
const grantList = await (await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access')).json();
|
||||
assert.equal(grantList.length, 1);
|
||||
assert.equal(grantList[0].subject_id, scoped.id);
|
||||
await a.close();
|
||||
|
||||
// Scoped viewer now sees exactly Alpha (not Beta), can GET it, cannot PATCH (view-only).
|
||||
s = await appWithProjects(scoped);
|
||||
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
||||
assert.deepEqual(list.map(p => p.id), [alpha]);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + alpha)).status, 200);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/projects/' + beta)).status, 403);
|
||||
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: 'hacked' }),
|
||||
});
|
||||
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
|
||||
await s.close();
|
||||
|
||||
// Admin revokes; scoped viewer goes back to seeing nothing.
|
||||
const a2 = await appWithProjects(admin);
|
||||
const del = await fetch(a2.baseUrl + '/api/v1/projects/' + alpha + '/access/user/' + scoped.id, { method: 'DELETE' });
|
||||
assert.equal(del.status, 204);
|
||||
await a2.close();
|
||||
|
||||
s = await appWithProjects(scoped);
|
||||
list = await (await fetch(s.baseUrl + '/api/v1/projects')).json();
|
||||
assert.equal(list.length, 0);
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('non-admin cannot reach the grant-management API', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, scoped } = await seedBaseline(pool);
|
||||
const s = await appWithProjects(scoped);
|
||||
// requireAdmin sits on the access sub-routes; a viewer is 403.
|
||||
const r = await fetch(s.baseUrl + '/api/v1/projects/' + alpha + '/access');
|
||||
assert.equal(r.status, 403);
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('edit-level grant allows PATCH', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, admin, scoped } = await seedBaseline(pool);
|
||||
const a = await appWithProjects(admin);
|
||||
await fetch(a.baseUrl + '/api/v1/projects/' + alpha + '/access', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject_type: 'user', subject_id: scoped.id, level: 'edit' }),
|
||||
});
|
||||
await a.close();
|
||||
|
||||
const s = await appWithProjects(scoped);
|
||||
const patch = await fetch(s.baseUrl + '/api/v1/projects/' + alpha, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ description: 'updated by editor' }),
|
||||
});
|
||||
assert.equal(patch.status, 200);
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
|
@ -97,11 +97,18 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
// Admin-only destinations. Non-admins who reach one (deep link, keyboard
|
||||
// router, stale tab) get bounced home instead of a broken/forbidden page.
|
||||
// The API enforces the same rules — this is just UX.
|
||||
const ADMIN_ROUTES = new Set(['users', 'containers', 'cluster', 'settings']);
|
||||
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
||||
const effectiveRoute = (ADMIN_ROUTES.has(route) && !isAdmin) ? 'home' : route;
|
||||
|
||||
let content;
|
||||
if (openAsset) {
|
||||
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
|
||||
} else {
|
||||
switch (route) {
|
||||
switch (effectiveRoute) {
|
||||
case 'home': content = <Home navigate={navigate} />; break;
|
||||
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
|
||||
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
|
||||
|
|
|
|||
|
|
@ -258,12 +258,26 @@ function Users() {
|
|||
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
||||
|
||||
{tab === 'policies' && (
|
||||
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<Icon name="lock" size={24} />
|
||||
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
|
||||
(admin / editor / viewer) is enforced API-wide.
|
||||
<div className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Icon name="lock" size={16} />
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong style={{ color: 'var(--text-2)' }}>admin</strong> — full access to every
|
||||
project plus user, group, cluster, and system administration.
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> — see only the
|
||||
projects they've been granted. A <em>view</em> grant is read-only; an
|
||||
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
|
||||
</div>
|
||||
<div>
|
||||
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
|
||||
→ a project's <em>Manage access…</em> menu. Group membership is managed on the
|
||||
Groups tab above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ function Projects({ onOpenProject, navigate }) {
|
|||
const [showNew, setShowNew] = React.useState(false);
|
||||
const [menuFor, setMenuFor] = React.useState(null);
|
||||
const [renamingProject, setRenamingProject] = React.useState(null);
|
||||
const [accessProject, setAccessProject] = React.useState(null);
|
||||
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
||||
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
|
||||
|
||||
const refresh = React.useCallback(() => {
|
||||
window.ZAMPP_API.fetch('/projects')
|
||||
|
|
@ -122,8 +125,10 @@ function Projects({ onOpenProject, navigate }) {
|
|||
key={p.id}
|
||||
project={p}
|
||||
assets={ASSETS}
|
||||
canManageAccess={isAdmin}
|
||||
onOpen={() => onOpenProject(p)}
|
||||
onRename={() => renameProject(p)}
|
||||
onManageAccess={() => manageAccess(p)}
|
||||
onDelete={() => deleteProject(p)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -148,6 +153,7 @@ function Projects({ onOpenProject, navigate }) {
|
|||
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
|
||||
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename…</button>
|
||||
{isAdmin && <button onClick={() => manageAccess(p)}><Icon name="users" size={11} />Manage access…</button>}
|
||||
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -165,6 +171,140 @@ function Projects({ onOpenProject, navigate }) {
|
|||
onSaved={() => { setRenamingProject(null); refresh(); }}
|
||||
/>
|
||||
)}
|
||||
{accessProject && (
|
||||
<ProjectAccessModal
|
||||
project={accessProject}
|
||||
onClose={() => setAccessProject(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin-only: grant/revoke per-project access to users and groups.
|
||||
// Backed by GET/POST/DELETE /api/v1/projects/:id/access.
|
||||
function ProjectAccessModal({ project, onClose }) {
|
||||
const [grants, setGrants] = React.useState([]);
|
||||
const [users, setUsers] = React.useState([]);
|
||||
const [groups, setGroups] = React.useState([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [err, setErr] = React.useState(null);
|
||||
|
||||
// Add-grant form state.
|
||||
const [subjType, setSubjType] = React.useState('user');
|
||||
const [subjId, setSubjId] = React.useState('');
|
||||
const [level, setLevel] = React.useState('view');
|
||||
|
||||
const loadGrants = React.useCallback(() => {
|
||||
return window.ZAMPP_API.fetch('/projects/' + project.id + '/access')
|
||||
.then(list => setGrants(list || []))
|
||||
.catch(e => setErr(e.message));
|
||||
}, [project.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadGrants(),
|
||||
window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])),
|
||||
window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])),
|
||||
]).finally(() => setLoading(false));
|
||||
}, [loadGrants]);
|
||||
|
||||
const addGrant = () => {
|
||||
if (!subjId) return;
|
||||
setErr(null);
|
||||
window.ZAMPP_API.fetch('/projects/' + project.id + '/access', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }),
|
||||
})
|
||||
.then(() => { setSubjId(''); return loadGrants(); })
|
||||
.catch(e => setErr(e.message || 'Failed to add grant'));
|
||||
};
|
||||
|
||||
const revoke = (g) => {
|
||||
window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' })
|
||||
.then(loadGrants)
|
||||
.catch(e => setErr(e.message || 'Failed to revoke'));
|
||||
};
|
||||
|
||||
// Candidates for the picker — exclude subjects that already have a grant.
|
||||
const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id));
|
||||
const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id));
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" style={{ width: 520 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Manage access · {project.name}</div>
|
||||
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||
Admins always have full access. Grant specific users or groups view (read-only) or
|
||||
edit (read-write) access to this project.
|
||||
</div>
|
||||
|
||||
{/* Add-grant row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '90px 1fr 90px auto', gap: 8, alignItems: 'end', marginBottom: 14 }}>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">Type</label>
|
||||
<select className="field-input" value={subjType} style={{ appearance: 'auto' }}
|
||||
onChange={e => { setSubjType(e.target.value); setSubjId(''); }}>
|
||||
<option value="user">User</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">{subjType === 'user' ? 'User' : 'Group'}</label>
|
||||
<select className="field-input" value={subjId} style={{ appearance: 'auto' }}
|
||||
onChange={e => setSubjId(e.target.value)}>
|
||||
<option value="">Pick a {subjType}…</option>
|
||||
{candidates.map(c => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{subjType === 'user' ? ('@' + c.username + (c.display_name ? ' · ' + c.display_name : '')) : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">Level</label>
|
||||
<select className="field-input" value={level} style={{ appearance: 'auto' }}
|
||||
onChange={e => setLevel(e.target.value)}>
|
||||
<option value="view">View</option>
|
||||
<option value="edit">Edit</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={addGrant} disabled={!subjId}>Add</button>
|
||||
</div>
|
||||
|
||||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
|
||||
|
||||
{/* Existing grants */}
|
||||
<div className="panel">
|
||||
{loading && <div style={{ padding: 16, color: 'var(--text-3)', fontSize: 12.5 }}>Loading…</div>}
|
||||
{!loading && grants.length === 0 && (
|
||||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||
No grants yet — only admins can see this project.
|
||||
</div>
|
||||
)}
|
||||
{!loading && grants.map(g => (
|
||||
<div key={g.subject_type + ':' + g.subject_id}
|
||||
style={{ display: 'grid', gridTemplateColumns: '20px 1fr 70px 80px', gap: 10, alignItems: 'center', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||
<Icon name={g.subject_type === 'group' ? 'users' : 'user'} size={13} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{g.subject_name || '(deleted)'}</div>
|
||||
{g.username && <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{g.username}</div>}
|
||||
</div>
|
||||
<span className={`badge ${g.level === 'edit' ? 'accent' : 'neutral'}`}>{g.level}</span>
|
||||
<button className="btn ghost sm danger" onClick={() => revoke(g)}>Revoke</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn primary sm" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -206,7 +346,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
||||
function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) {
|
||||
const ofProject = assets.filter(a => a.project_id === project.id);
|
||||
const thumbAssets = ofProject.slice(0, 4);
|
||||
|
||||
|
|
@ -275,6 +415,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
|||
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
|
||||
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename…</button>
|
||||
{canManageAccess && <button onClick={() => { setCtx(null); onManageAccess && onManageAccess(); }}><Icon name="users" size={11} />Manage access…</button>}
|
||||
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -284,3 +425,4 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
|
|||
|
||||
window.Projects = Projects;
|
||||
window.RenameProjectModal = RenameProjectModal;
|
||||
window.ProjectAccessModal = ProjectAccessModal;
|
||||
|
|
|
|||
|
|
@ -126,13 +126,18 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
// (Capture live-signal badge previously lived here; it now belongs in the
|
||||
// topbar status pip alongside the cluster pip. See issue #149.)
|
||||
|
||||
// Apply the live Jobs badge to the Operations section.
|
||||
// Apply the live Jobs badge to the Operations section, and gate the Admin
|
||||
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
|
||||
// This is UX only — the API enforces the same rules server-side.
|
||||
const isAdmin = me?.role === 'admin';
|
||||
const sections = React.useMemo(
|
||||
() => NAV_SECTIONS.map(sec => ({
|
||||
...sec,
|
||||
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
||||
})),
|
||||
[jobsBadge]
|
||||
() => NAV_SECTIONS
|
||||
.filter(sec => sec.label !== 'Admin' || isAdmin)
|
||||
.map(sec => ({
|
||||
...sec,
|
||||
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
||||
})),
|
||||
[jobsBadge, isAdmin]
|
||||
);
|
||||
const toggleGroup = (id) => {
|
||||
setOpenGroups(prev => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue