Compare commits
6 commits
9d6bbf8112
...
72fc608d8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72fc608d8a | ||
|
|
3fe7d6bba2 | ||
|
|
2615143c6d | ||
|
|
0c3a4b625f | ||
|
|
fff0828d79 | ||
|
|
ec026195eb |
39 changed files with 2667 additions and 121 deletions
27
.env.example
27
.env.example
|
|
@ -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.
|
||||
|
|
@ -36,3 +43,23 @@ ALLOWED_ORIGINS=
|
|||
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
||||
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
|
||||
# disable; the "Sign in with Google" button and the /auth/google routes only
|
||||
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
|
||||
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
|
||||
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Must exactly match a redirect URI on the OAuth client, e.g.
|
||||
# https://dragonflight.live/api/v1/auth/google/callback
|
||||
OAUTH_REDIRECT_URL=
|
||||
# Restrict sign-in to one Google Workspace domain (recommended). First login from
|
||||
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
|
||||
# by Google's stable subject id, never by email — so a Google login can never
|
||||
# seize a pre-existing local account). An admin then grants project access.
|
||||
# Leave blank to allow any verified Google account to self-provision (NOT advised).
|
||||
GOOGLE_ALLOWED_DOMAIN=
|
||||
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
|
||||
# the authenticator code (Google is treated as the first factor). Accounts without
|
||||
# TOTP complete sign-in in one Google step.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@
|
|||
"bullmq": "^5.5.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^9.0.1",
|
||||
"dotenv": "^16.4.5"
|
||||
"dotenv": "^16.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"google-auth-library": "^9.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
90
services/mam-api/src/auth/google-oauth.js
Normal file
90
services/mam-api/src/auth/google-oauth.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Google OAuth (OIDC) sign-in helpers.
|
||||
//
|
||||
// Entirely config-gated: if GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET /
|
||||
// OAUTH_REDIRECT_URL aren't set, isConfigured() is false and the routes 404, so
|
||||
// a deployment without Google SSO behaves exactly as before. google-auth-library
|
||||
// is imported lazily so the dependency is only required when the feature is on.
|
||||
//
|
||||
// Flow: /auth/google redirects to Google's consent screen with a signed `state`;
|
||||
// /auth/google/callback exchanges the code, verifies the ID token, enforces the
|
||||
// allowed Workspace domain, and auto-provisions a viewer account on first login.
|
||||
|
||||
const SCOPES = ['openid', 'email', 'profile'];
|
||||
|
||||
export function isConfigured() {
|
||||
return !!(process.env.GOOGLE_CLIENT_ID
|
||||
&& process.env.GOOGLE_CLIENT_SECRET
|
||||
&& process.env.OAUTH_REDIRECT_URL);
|
||||
}
|
||||
|
||||
export function allowedDomain() {
|
||||
return (process.env.GOOGLE_ALLOWED_DOMAIN || '').trim().toLowerCase() || null;
|
||||
}
|
||||
|
||||
// Lazily build an OAuth2 client (throws a clear error if the dep is missing).
|
||||
async function makeClient() {
|
||||
let OAuth2Client;
|
||||
try {
|
||||
({ OAuth2Client } = await import('google-auth-library'));
|
||||
} catch {
|
||||
const err = new Error('google-auth-library is not installed');
|
||||
err.status = 500;
|
||||
throw err;
|
||||
}
|
||||
return new OAuth2Client({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
redirectUri: process.env.OAUTH_REDIRECT_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// URL of Google's consent screen. `state` is an opaque anti-CSRF token we also
|
||||
// stash in the session and re-check on callback.
|
||||
export async function buildAuthUrl(state) {
|
||||
const client = await makeClient();
|
||||
return client.generateAuthUrl({
|
||||
access_type: 'online',
|
||||
scope: SCOPES,
|
||||
state,
|
||||
prompt: 'select_account',
|
||||
// If a Workspace domain is configured, hint Google to scope the picker to it.
|
||||
...(allowedDomain() ? { hd: allowedDomain() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Exchange the authorization code and verify the returned ID token. Returns the
|
||||
// verified { sub, email, name, hd } payload. Throws { status } on any failure.
|
||||
export async function exchangeAndVerify(code) {
|
||||
const client = await makeClient();
|
||||
const { tokens } = await client.getToken(code);
|
||||
if (!tokens.id_token) {
|
||||
const err = new Error('no id_token from Google'); err.status = 401; throw err;
|
||||
}
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken: tokens.id_token,
|
||||
audience: process.env.GOOGLE_CLIENT_ID,
|
||||
});
|
||||
const p = ticket.getPayload();
|
||||
if (!p || !p.sub) {
|
||||
const err = new Error('invalid id_token'); err.status = 401; throw err;
|
||||
}
|
||||
// Require an explicitly verified email — a missing/undefined claim is NOT
|
||||
// treated as verified, since the email drives account linking/provisioning.
|
||||
if (!p.email || p.email_verified !== true) {
|
||||
const err = new Error('email not verified'); err.status = 403; throw err;
|
||||
}
|
||||
const domain = allowedDomain();
|
||||
if (domain) {
|
||||
// ONLY trust Google's `hd` (hosted-domain) claim — it's present iff the
|
||||
// account is a member of a Google Workspace domain that Google itself
|
||||
// has verified. The email-suffix fallback we used to allow let any
|
||||
// non-Workspace account with a spoof-friendly email through; if a
|
||||
// GOOGLE_ALLOWED_DOMAIN is set, the operator means "only this Workspace,"
|
||||
// and consumer accounts (no hd) must be rejected.
|
||||
const hd = (p.hd || '').toLowerCase();
|
||||
if (hd !== domain) {
|
||||
const err = new Error('domain not allowed'); err.status = 403; throw err;
|
||||
}
|
||||
}
|
||||
return { sub: p.sub, email: p.email, name: p.name || p.email, hd: p.hd || null };
|
||||
}
|
||||
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
58
services/mam-api/src/auth/mfa-tickets.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Short-lived MFA tickets bridging the two login steps.
|
||||
//
|
||||
// When a user with TOTP enabled passes password auth, we don't create a session
|
||||
// yet — we hand back an opaque ticket. The second request (code or recovery
|
||||
// code) redeems the ticket to finish login. Tickets are single-use and expire
|
||||
// fast so a stolen ticket is near-useless.
|
||||
//
|
||||
// Tickets are bound to the issuing request's IP and User-Agent (hashed). A
|
||||
// stolen ticket replayed from a different origin redeems to null. This is
|
||||
// defense in depth against ticket exfiltration via a logged proxy, browser
|
||||
// extension, or shoulder-surf; it does not stop an attacker who is on the same
|
||||
// IP and UA.
|
||||
//
|
||||
// In-memory + single-instance, matching the existing login rate-limiter
|
||||
// (auth/rate-limit.js). Documented limitation: in a multi-instance deployment
|
||||
// the second step must hit the same node. Acceptable for Dragonflight's
|
||||
// one-mam-api-per-node shape; revisit if that changes.
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
|
||||
const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code
|
||||
const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt }
|
||||
|
||||
function sweep() {
|
||||
const now = Date.now();
|
||||
for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id);
|
||||
}
|
||||
|
||||
function hashBinding(value) {
|
||||
return createHash('sha256').update(String(value || '')).digest('hex');
|
||||
}
|
||||
|
||||
export function issueTicket(userId, { ip, userAgent } = {}) {
|
||||
sweep();
|
||||
const id = randomBytes(32).toString('hex');
|
||||
tickets.set(id, {
|
||||
userId,
|
||||
ipHash: hashBinding(ip),
|
||||
uaHash: hashBinding(userAgent),
|
||||
expiresAt: Date.now() + TTL_MS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
// Redeem (and consume) a ticket. Returns the userId, or null if missing,
|
||||
// expired, or the binding doesn't match the redeeming request.
|
||||
export function redeemTicket(id, { ip, userAgent } = {}) {
|
||||
if (!id) return null;
|
||||
const t = tickets.get(id);
|
||||
if (!t) return null;
|
||||
tickets.delete(id); // single-use — burn even on binding mismatch so a
|
||||
// wrong-binding probe can't be retried.
|
||||
if (t.expiresAt <= Date.now()) return null;
|
||||
// If a caller doesn't supply bindings (e.g. tests), accept — the issue side
|
||||
// controls whether bindings get recorded.
|
||||
if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null;
|
||||
if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null;
|
||||
return t.userId;
|
||||
}
|
||||
118
services/mam-api/src/auth/totp.js
Normal file
118
services/mam-api/src/auth/totp.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
|
||||
//
|
||||
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
|
||||
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
|
||||
// in test/auth/totp.test.js.
|
||||
//
|
||||
// Defaults match every mainstream authenticator app (Google Authenticator,
|
||||
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
const DIGITS = 6;
|
||||
const STEP_SECONDS = 30;
|
||||
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
|
||||
export function base32Encode(buf) {
|
||||
let bits = 0, value = 0, out = '';
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(str) {
|
||||
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
|
||||
let bits = 0, value = 0;
|
||||
const out = [];
|
||||
for (const ch of clean) {
|
||||
const idx = RFC4648_B32.indexOf(ch);
|
||||
if (idx === -1) continue; // skip stray chars
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
|
||||
// SHA-1 key length).
|
||||
export function generateSecret() {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
// HOTP for a specific counter (RFC 4226).
|
||||
function hotp(secretBuf, counter) {
|
||||
const buf = Buffer.alloc(8);
|
||||
// 64-bit big-endian counter.
|
||||
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
||||
buf.writeUInt32BE(counter >>> 0, 4);
|
||||
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||
const code = ((hmac[offset] & 0x7f) << 24)
|
||||
| ((hmac[offset + 1] & 0xff) << 16)
|
||||
| ((hmac[offset + 2] & 0xff) << 8)
|
||||
| (hmac[offset + 3] & 0xff);
|
||||
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
|
||||
}
|
||||
|
||||
// The TOTP code for a given time (defaults to now).
|
||||
export function generateToken(base32Secret, atMs = Date.now()) {
|
||||
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||
return hotp(base32Decode(base32Secret), counter);
|
||||
}
|
||||
|
||||
// Verify a user-supplied code, allowing ±`window` steps of clock drift
|
||||
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
|
||||
//
|
||||
// Returns the matched counter on success (so callers can persist it for
|
||||
// replay protection — RFC 6238 §5.2), or null on failure. Boolean truthiness
|
||||
// still works for the common case (`if (verifyToken(...))`).
|
||||
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
|
||||
if (!base32Secret || !token) return null;
|
||||
const cleaned = String(token).replace(/\s+/g, '');
|
||||
if (!/^\d{6}$/.test(cleaned)) return null;
|
||||
const secretBuf = base32Decode(base32Secret);
|
||||
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||||
const want = Buffer.from(cleaned);
|
||||
for (let w = -window; w <= window; w++) {
|
||||
const candidate = Buffer.from(hotp(secretBuf, counter + w));
|
||||
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return counter + w;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
|
||||
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
|
||||
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
||||
const params = new URLSearchParams({
|
||||
secret: base32Secret,
|
||||
issuer,
|
||||
algorithm: 'SHA1',
|
||||
digits: String(DIGITS),
|
||||
period: String(STEP_SECONDS),
|
||||
});
|
||||
return `otpauth://totp/${label}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
|
||||
// them before storage and shows the raw set to the user exactly once.
|
||||
export function generateRecoveryCodes(n = 10) {
|
||||
const codes = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
|
||||
const hex = randomBytes(5).toString('hex');
|
||||
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
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);
|
||||
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
20
services/mam-api/src/db/migrations/027-totp-2fa.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- Migration 027 — TOTP two-factor auth.
|
||||
--
|
||||
-- totp_secret holds the base32 shared secret once enrollment is confirmed
|
||||
-- (NULL while disabled or mid-enrollment). totp_enabled flips true only after
|
||||
-- the user verifies their first code, so a half-finished enrollment never locks
|
||||
-- anyone out. Recovery codes are one-time bcrypt-hashed fallbacks; used_at marks
|
||||
-- a code as spent.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_recovery_codes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recovery_codes_user ON user_recovery_codes(user_id);
|
||||
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
13
services/mam-api/src/db/migrations/028-google-oauth.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- Migration 028 — Google OAuth (OIDC) sign-in.
|
||||
--
|
||||
-- google_sub is Google's stable subject identifier — the join key for a linked
|
||||
-- or auto-provisioned account (unique, but NULL for password-only users).
|
||||
-- email is captured for display + domain checks. password_hash becomes nullable
|
||||
-- so an OAuth-only account can exist without a local password; such an account
|
||||
-- simply can't use the password login path until an admin sets one.
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_sub TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL;
|
||||
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
9
services/mam-api/src/db/migrations/030-totp-replay.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Migration 030 — TOTP replay protection.
|
||||
--
|
||||
-- RFC 6238 §5.2 hardening: track the last counter value we accepted for each
|
||||
-- user and reject codes at counters ≤ the last one. Without this, the same
|
||||
-- 6-digit code can be submitted N times within its 30s step. Low impact in
|
||||
-- practice (the code is only valid for ~90s with ±1 drift) but standard.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS totp_last_counter BIGINT NOT NULL DEFAULT 0;
|
||||
|
|
@ -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';
|
||||
|
|
@ -104,7 +104,10 @@ app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|||
|
||||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||||
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
|
||||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||||
const UNAUTH_PATHS = new Set([
|
||||
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
|
||||
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
|
||||
]);
|
||||
// node-agent now authenticates /cluster/heartbeat with a bound api_token
|
||||
// (migration 019 + bound_hostname on the token). requireAuth handles the
|
||||
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
|
||||
|
|
@ -117,8 +120,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 +134,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;
|
||||
|
|
@ -18,7 +20,7 @@ async function destroyAnd401(req, res) {
|
|||
|
||||
async function loadUser(id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, role FROM users WHERE id = $1`, [id]);
|
||||
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
|
@ -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,10 +316,22 @@ 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;
|
||||
|
||||
// bin_id must reference a bin in the asset's OWN project — otherwise an
|
||||
// editor in project A could stuff their asset into project B's bin tree.
|
||||
// Null/empty clears the bin, which is always allowed.
|
||||
if (bin_id) {
|
||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [bin_id]);
|
||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'bin_id not found' });
|
||||
if (bin.rows[0].project_id !== req.assetProjectId) {
|
||||
return res.status(400).json({ error: 'bin_id belongs to a different project' });
|
||||
}
|
||||
}
|
||||
|
||||
const updates = [], params = [];
|
||||
let paramCount = 1;
|
||||
if (display_name !== undefined) { updates.push(`display_name = $${paramCount++}`); params.push(display_name); }
|
||||
|
|
@ -299,13 +350,32 @@ 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;
|
||||
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
const src = r.rows[0];
|
||||
|
||||
// Destination project defaults to source's. If the caller overrides it,
|
||||
// assert edit on the target — without this, an editor in project A could
|
||||
// clone any asset they can see into project B with no grant on B.
|
||||
const destProjectId = projectId || src.project_id;
|
||||
if (projectId && projectId !== src.project_id) {
|
||||
await assertProjectAccess(req.user, destProjectId, 'edit');
|
||||
}
|
||||
// Destination bin (if any) must belong to the destination project — same
|
||||
// class of bug as the PATCH bin_id hole.
|
||||
const destBinId = binId === undefined ? src.bin_id : (binId || null);
|
||||
if (destBinId) {
|
||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [destBinId]);
|
||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
|
||||
if (bin.rows[0].project_id !== destProjectId) {
|
||||
return res.status(400).json({ error: 'binId belongs to a different project than the destination' });
|
||||
}
|
||||
}
|
||||
|
||||
const newId = uuidv4();
|
||||
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
|
||||
// sharing S3 objects with the source. Set status to 'processing' so the copy
|
||||
|
|
@ -320,8 +390,8 @@ router.post('/:id/copy', async (req, res, next) => {
|
|||
$1,$2,$3,$4,$5,$6,$7,$8,NULL,NULL,$9,$10,$11,$12,$13,$14,$15,$16,NOW(),NOW()
|
||||
) RETURNING *`,
|
||||
[
|
||||
newId, projectId || src.project_id,
|
||||
binId === undefined ? src.bin_id : (binId || null),
|
||||
newId, destProjectId,
|
||||
destBinId,
|
||||
src.filename, src.display_name, 'processing', src.media_type,
|
||||
src.original_s3_key,
|
||||
src.codec, src.resolution, src.fps, src.duration_ms, src.start_tc,
|
||||
|
|
@ -346,7 +416,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 +454,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 +506,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 +522,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 +547,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 +598,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 +617,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 +978,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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@ import pool from '../db/pool.js';
|
|||
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
|
||||
import { hashPassword, comparePassword } from '../auth/passwords.js';
|
||||
import { ipBackoff } from '../auth/rate-limit.js';
|
||||
import {
|
||||
generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
|
||||
} from '../auth/totp.js';
|
||||
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
|
||||
import {
|
||||
isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
|
||||
} from '../auth/google-oauth.js';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
|
||||
|
||||
|
|
@ -76,7 +84,7 @@ router.post('/login', async (req, res, next) => {
|
|||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
|
||||
`SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
|
||||
[username.trim(), DEV_USER_ID]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
|
|
@ -93,21 +101,123 @@ router.post('/login', async (req, res, next) => {
|
|||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
// The critical line — wait for the row to land in `sessions` before responding.
|
||||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
// Second factor: if TOTP is enabled, don't create a session yet. Hand back
|
||||
// a short-lived ticket the client redeems via /login/totp with a code.
|
||||
// Crucially: do NOT clear the per-IP failure counter here. If we did, each
|
||||
// /login retry would reset the backoff and let an attacker brute the 6-digit
|
||||
// TOTP space (10^6) with no per-attempt delay. The counter is cleared
|
||||
// inside establishSession() once MFA has actually passed.
|
||||
if (user.totp_enabled) {
|
||||
return res.json({
|
||||
mfa_required: true,
|
||||
ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
|
||||
});
|
||||
}
|
||||
|
||||
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||
|
||||
ipBackoff.recordSuccess(ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
|
||||
await establishSession(req, user, ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Write the session and wait for it to persist before responding. Extracted so
|
||||
// both the password-only and the MFA-completion paths share one implementation.
|
||||
// Clears the per-IP failure counter only here — after every required factor has
|
||||
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
|
||||
async function establishSession(req, user, ip) {
|
||||
req.session.user_id = user.id;
|
||||
req.session.first_seen_at = Date.now();
|
||||
req.session.last_seen_at = Date.now();
|
||||
// The critical line — wait for the row to land in `sessions` before responding.
|
||||
// Without this, the SPA's next request races the store write, hits 401, and
|
||||
// the prior bounce-to-login logic produced an infinite loop.
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
if (ip) ipBackoff.recordSuccess(ip);
|
||||
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
|
||||
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
|
||||
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
|
||||
// request body (password-login path) or req.session.mfa_ticket (Google path).
|
||||
router.post('/login/totp', async (req, res, next) => {
|
||||
try {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
// Rate-limit the second factor with the same per-IP backoff as /login so
|
||||
// the 6-digit code space can't be hammered.
|
||||
const delay = ipBackoff.delayMs(ip);
|
||||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||||
|
||||
const { ticket: bodyTicket, code } = req.body || {};
|
||||
const ticket = bodyTicket || req.session?.mfa_ticket;
|
||||
if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
|
||||
// Bound to the issuing request's IP + UA — replays from a different origin
|
||||
// redeem to null. See mfa-tickets.js for the binding model.
|
||||
const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
|
||||
if (!userId) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
return res.status(401).json({ error: 'invalid or expired ticket' });
|
||||
}
|
||||
if (!code) return res.status(400).json({ error: 'code required' });
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
|
||||
FROM users WHERE id = $1`, [userId]);
|
||||
const user = rows[0];
|
||||
if (!user || !user.totp_enabled || !user.totp_secret) {
|
||||
return res.status(401).json({ error: 'invalid credentials' });
|
||||
}
|
||||
|
||||
// verifyToken returns the matched counter on success. Reject codes at
|
||||
// counters ≤ totp_last_counter to prevent replay within the same step.
|
||||
// The CAS-style UPDATE makes this race-free under concurrent submissions.
|
||||
const matchedCounter = verifyToken(user.totp_secret, code);
|
||||
let ok = false;
|
||||
if (matchedCounter !== null) {
|
||||
const lastCounter = BigInt(user.totp_last_counter || 0);
|
||||
if (BigInt(matchedCounter) > lastCounter) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE users SET totp_last_counter = $1
|
||||
WHERE id = $2 AND totp_last_counter < $1`,
|
||||
[String(matchedCounter), user.id]
|
||||
);
|
||||
ok = upd.rowCount === 1;
|
||||
}
|
||||
// matchedCounter ≤ last → silent replay; falls through to recovery-code
|
||||
// path which also fails → 401. Same UX as a wrong code, no info leak.
|
||||
}
|
||||
if (!ok) ok = await consumeRecoveryCode(user.id, code);
|
||||
if (!ok) {
|
||||
ipBackoff.recordFailure(ip);
|
||||
// The ticket was single-use; the client must restart from /login.
|
||||
return res.status(401).json({ error: 'invalid code' });
|
||||
}
|
||||
|
||||
// recordSuccess is called by establishSession once the session lands —
|
||||
// that's the first moment we know every required factor has passed.
|
||||
await establishSession(req, user, ip);
|
||||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Check a recovery code against the user's unused codes; mark it spent on match.
|
||||
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
|
||||
// so two concurrent redemptions of the same code can't both succeed.
|
||||
async function consumeRecoveryCode(userId, code) {
|
||||
const cleaned = String(code).trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
|
||||
for (const row of rows) {
|
||||
if (await comparePassword(cleaned, row.code_hash)) {
|
||||
const upd = await pool.query(
|
||||
`UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
|
||||
// Lost the race if another request already consumed it.
|
||||
return upd.rowCount === 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||||
router.post('/logout', (req, res) => {
|
||||
if (!req.session) return res.status(204).end();
|
||||
|
|
@ -125,6 +235,7 @@ router.get('/me', requireAuth, (req, res) => {
|
|||
username: req.user.username,
|
||||
display_name: req.user.display_name,
|
||||
role: req.user.role,
|
||||
totp_enabled: !!req.user.totp_enabled,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -149,5 +260,202 @@ router.post('/password', requireAuth, async (req, res, next) => {
|
|||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── TOTP enrollment (all require an active session) ─────────────────────────
|
||||
|
||||
// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
|
||||
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
|
||||
// base32 secret for manual entry. Enrollment isn't active until /enable
|
||||
// confirms a code, so a started-but-abandoned setup never locks the user out.
|
||||
router.post('/totp/setup', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||
if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||
|
||||
const secret = generateSecret();
|
||||
await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
|
||||
const uri = otpauthURI(secret, req.user.username || 'user');
|
||||
|
||||
// QR rendering is optional — the otpauth URI + manual secret are sufficient
|
||||
// to enroll. Render a data-URL QR only if the optional `qrcode` dep is
|
||||
// present, so a missing dependency degrades instead of 500-ing.
|
||||
let qr = null;
|
||||
try {
|
||||
const QRCode = (await import('qrcode')).default;
|
||||
qr = await QRCode.toDataURL(uri);
|
||||
} catch { /* qrcode not installed — client falls back to manual entry */ }
|
||||
|
||||
res.json({ secret, otpauth_uri: uri, qr });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
|
||||
// the authenticator. On success, flips totp_enabled and returns one-time
|
||||
// recovery codes (shown exactly once).
|
||||
router.post('/totp/enable', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { code } = req.body || {};
|
||||
if (!code) return badRequest(res, 'code required');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
|
||||
const row = rows[0];
|
||||
if (!row?.totp_secret) return badRequest(res, 'start setup first');
|
||||
if (row.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });
|
||||
const enrollCounter = verifyToken(row.totp_secret, code);
|
||||
if (enrollCounter === null) return badRequest(res, 'incorrect code');
|
||||
|
||||
const recovery = generateRecoveryCodes(10);
|
||||
const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
|
||||
// Enable + seed totp_last_counter to the enrollment code's counter so the
|
||||
// same code can't be reused on first login. Replace any stale recovery
|
||||
// codes atomically.
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
|
||||
[req.user.id, String(enrollCounter)]
|
||||
);
|
||||
await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||
for (const h of hashes) {
|
||||
await client.query(
|
||||
`INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw e;
|
||||
} finally { client.release(); }
|
||||
|
||||
res.json({ enabled: true, recovery_codes: recovery });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
|
||||
// account password as a confirmation so a hijacked live session can't silently
|
||||
// strip the second factor.
|
||||
router.post('/totp/disable', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
if (!password) return badRequest(res, 'password required');
|
||||
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
||||
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
||||
if (!(await comparePassword(password, rows[0].password_hash))) {
|
||||
return badRequest(res, 'incorrect password');
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
|
||||
res.status(204).end();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
|
||||
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
|
||||
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.
|
||||
|
||||
// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
|
||||
// whether to render the "Sign in with Google" button.
|
||||
router.get('/google/enabled', (_req, res) => {
|
||||
res.json({ enabled: googleConfigured() });
|
||||
});
|
||||
|
||||
// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
|
||||
// in the session and redirects to Google's consent screen.
|
||||
router.get('/google', async (req, res, next) => {
|
||||
try {
|
||||
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||
const state = randomBytes(16).toString('hex');
|
||||
req.session.oauth_state = state;
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
res.redirect(await buildAuthUrl(state));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
|
||||
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
|
||||
// on first login, establishes the session, then redirects to the SPA.
|
||||
router.get('/google/callback', async (req, res, next) => {
|
||||
try {
|
||||
if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
|
||||
const { code, state } = req.query;
|
||||
const expected = req.session.oauth_state;
|
||||
delete req.session.oauth_state;
|
||||
if (!code || !state || !expected || state !== expected) {
|
||||
return res.status(400).json({ error: 'invalid oauth state' });
|
||||
}
|
||||
|
||||
const profile = await exchangeAndVerify(code);
|
||||
const user = await resolveGoogleUser(profile);
|
||||
|
||||
// If this account has TOTP enabled, Google is only the FIRST factor — route
|
||||
// through the same second-factor step as password login. The ticket lives in
|
||||
// the session (not the URL) and the SPA prompts for the code.
|
||||
if (user.totp_enabled) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
req.session.mfa_ticket = issueTicket(user.id, {
|
||||
ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||||
return res.redirect('/?mfa=1');
|
||||
}
|
||||
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
await establishSession(req, user, ip);
|
||||
|
||||
// Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
|
||||
res.redirect('/');
|
||||
} catch (err) {
|
||||
// Surface a friendly message on the login screen rather than a raw 500.
|
||||
if (err.status === 403) return res.redirect('/?auth_error=domain');
|
||||
if (err.status === 401) return res.redirect('/?auth_error=google');
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Map a verified Google profile to a Dragonflight user row.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Existing link by google_sub → that user.
|
||||
// 2. Otherwise auto-provision a fresh 'viewer'.
|
||||
//
|
||||
// We deliberately do NOT auto-link to an existing account by matching email:
|
||||
// that would let anyone who controls a Google address with the same email sign
|
||||
// in as a pre-existing local (possibly admin) account, bypassing its password
|
||||
// and TOTP. Linking an existing account to Google is an explicit, authenticated
|
||||
// action (a future "connect Google" under Settings), not something a login does.
|
||||
async function resolveGoogleUser(profile) {
|
||||
const found = await pool.query(
|
||||
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||
if (found.rows.length) return found.rows[0];
|
||||
|
||||
const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
|
||||
let username = base, n = 1;
|
||||
while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
|
||||
username = base + (++n);
|
||||
}
|
||||
|
||||
try {
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
|
||||
VALUES ($1, NULL, $2, 'viewer', $3, $4)
|
||||
RETURNING id, username, display_name, totp_enabled`,
|
||||
[username, profile.name, profile.email, profile.sub]);
|
||||
return ins.rows[0];
|
||||
} catch (err) {
|
||||
// Concurrent first-login race: the unique google_sub index rejected our
|
||||
// INSERT because a sibling request just created the row. Re-resolve.
|
||||
if (err.code === '23505') {
|
||||
const retry = await pool.query(
|
||||
`SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
|
||||
if (retry.rows.length) return retry.rows[0];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
export { realUserCount };
|
||||
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -136,10 +171,13 @@ router.post('/:id/assets', async (req, res, next) => {
|
|||
return res.status(400).json({ error: 'asset_id is required' });
|
||||
}
|
||||
|
||||
// Verify bin exists
|
||||
const binCheck = await pool.query('SELECT id FROM bins WHERE id = $1', [id]);
|
||||
if (binCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Bin not found' });
|
||||
// Asset must live in the bin's own project. Without this, an editor in
|
||||
// project A (where the bin lives) could pull an asset from project B (no
|
||||
// grant) into A's bin tree, exposing it in A's views.
|
||||
const a = await pool.query('SELECT project_id FROM assets WHERE id = $1', [asset_id]);
|
||||
if (a.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
if (a.rows[0].project_id !== req.binProjectId) {
|
||||
return res.status(400).json({ error: 'asset belongs to a different project than the bin' });
|
||||
}
|
||||
|
||||
// Update asset's bin_id
|
||||
|
|
@ -158,8 +196,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,7 @@
|
|||
// authz: intentionally any-logged-in (no per-project scoping). This is a thin
|
||||
// proxy to shared capture hardware with no project_id of its own; the resulting
|
||||
// asset is scoped when it's registered via the /assets route. Gated by the
|
||||
// global requireAuth in index.js, like the rest of /api/v1.
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
|
|
|||
|
|
@ -5,9 +5,23 @@
|
|||
|
||||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
// Scope every comment route to the parent asset's project: resolve project_id
|
||||
// via the asset, then require 'view' to read and 'edit' to write. A non-UUID or
|
||||
// unknown asset is a clean 404 before any access decision leaks its existence.
|
||||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT project_id FROM assets WHERE id = $1', [req.params.assetId]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
await assertProjectAccess(req.user, rows[0].project_id, MUTATING.has(req.method) ? 'edit' : 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
function rowToJson(r) {
|
||||
return {
|
||||
id: r.id,
|
||||
|
|
@ -49,8 +63,9 @@ router.post('/', async (req, res, next) => {
|
|||
if (!body || !String(body).trim()) {
|
||||
return res.status(400).json({ error: 'body is required' });
|
||||
}
|
||||
// Best-effort author lookup — pull from the session if AUTH_ENABLED is on.
|
||||
const userId = req.session?.userId || null;
|
||||
// Author is the authenticated user (requireAuth sets req.user for both
|
||||
// session and bearer auth, and the dev user when AUTH_ENABLED=false).
|
||||
const userId = req.user?.id || null;
|
||||
|
||||
const ins = await pool.query(
|
||||
`INSERT INTO asset_comments (asset_id, user_id, body, frame_ms)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import express from 'express';
|
|||
import { Queue } from 'bullmq';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import pool from '../db/pool.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -60,6 +61,8 @@ router.post('/youtube', async (req, res, next) => {
|
|||
if (projCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
// Importing writes an asset into the project — require edit access.
|
||||
await assertProjectAccess(req.user, projectId, 'edit');
|
||||
|
||||
const assetId = uuidv4();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import express from 'express';
|
||||
import pool from '../db/pool.js';
|
||||
import { Queue } from 'bullmq';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
// Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
|
||||
|
|
@ -324,6 +325,10 @@ router.post('/conform', async (req, res, next) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Conform writes back into a project — require edit on that project. Without
|
||||
// this, any logged-in user could enqueue conform jobs targeting any project.
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
|
||||
const bullJob = await conformQueue.add('conform-task', {
|
||||
edl,
|
||||
projectId: project_id,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,34 @@ import dgram from 'dgram';
|
|||
import pool from '../db/pool.js';
|
||||
import { getS3Bucket } from '../s3/client.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));
|
||||
|
||||
// Every /:id recorder route is scoped to the recorder's project. The param
|
||||
// handler validates the UUID, resolves the owning project_id, and asserts the
|
||||
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
|
||||
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
|
||||
// throws 403 for non-admins on a null project).
|
||||
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 recorders WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Recorder not found' });
|
||||
req.recorderProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.recorderProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
async function requireRecorderEdit(req, res, next) {
|
||||
try {
|
||||
await assertProjectAccess(req.user, req.recorderProjectId, 'edit');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// Base port for on-demand SDI sidecar containers on remote worker nodes.
|
||||
// Device index 0 → 7438, index 1 → 7439, etc.
|
||||
|
|
@ -149,6 +173,17 @@ function pickRecorderFields(body) {
|
|||
// parallel with a per-call timeout from `dockerApi`.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Scope to recorders in projects the caller can access (admins unfiltered).
|
||||
// Recorders with a NULL project are admin-only and never appear for scoped
|
||||
// users (accessibleProjectIds never yields a null id).
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
let scopeClause = '';
|
||||
const params = [];
|
||||
if (!access.all) {
|
||||
if (access.ids.size === 0) return res.json([]);
|
||||
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])';
|
||||
params.push([...access.ids]);
|
||||
}
|
||||
const result = await pool.query(`
|
||||
SELECT r.*, la.live_asset_id
|
||||
FROM recorders r
|
||||
|
|
@ -162,8 +197,9 @@ router.get('/', async (req, res, next) => {
|
|||
ORDER BY a.created_at DESC
|
||||
LIMIT 1
|
||||
) la ON TRUE
|
||||
${scopeClause}
|
||||
ORDER BY r.created_at DESC
|
||||
`);
|
||||
`, params);
|
||||
const rows = result.rows;
|
||||
|
||||
// Only inspect containers for recorders that actually claim to be recording.
|
||||
|
|
@ -194,6 +230,11 @@ router.post('/', async (req, res, next) => {
|
|||
.json({ error: 'Name and source_type are required' });
|
||||
}
|
||||
|
||||
// Creating a recorder writes into a project — require edit there. A recorder
|
||||
// with no project_id is admin-only (assertProjectAccess denies non-admins on
|
||||
// a null project).
|
||||
await assertProjectAccess(req.user, fields.project_id ?? null, 'edit');
|
||||
|
||||
// Defaults — written on insert so the DB row is always self-contained.
|
||||
const defaults = {
|
||||
source_config: {},
|
||||
|
|
@ -256,7 +297,7 @@ router.get('/:id', async (req, res, next) => {
|
|||
|
||||
// PATCH /:id - Edit recorder settings
|
||||
// Blocked while recorder is actively recording to prevent config drift.
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -295,7 +336,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/start - Start recording
|
||||
router.post('/:id/start', async (req, res, next) => {
|
||||
router.post('/:id/start', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -345,6 +386,14 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
? req.body.projectId
|
||||
: recorder.project_id;
|
||||
|
||||
// requireRecorderEdit only covered the recorder's own project. If this take
|
||||
// is being routed into a DIFFERENT project, the caller must have edit there
|
||||
// too — otherwise edit on recorder A's project would let them write live
|
||||
// assets into any project B.
|
||||
if (takeProjectId !== recorder.project_id) {
|
||||
await assertProjectAccess(req.user, takeProjectId, 'edit');
|
||||
}
|
||||
|
||||
// live-asset: create the asset row right now (status='live') so the
|
||||
// library shows the recording while it is happening.
|
||||
const assetIdLive = uuidv4();
|
||||
|
|
@ -551,7 +600,7 @@ router.post('/:id/start', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// POST /:id/stop - Stop recording
|
||||
router.post('/:id/stop', async (req, res, next) => {
|
||||
router.post('/:id/stop', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
@ -722,7 +771,7 @@ router.get('/:id/status', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// DELETE /:id - Delete recorder
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireRecorderEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import express from 'express';
|
|||
import pool from '../db/pool.js';
|
||||
import { getSignedUrlForObject } from '../s3/client.js';
|
||||
import { validateUuid } from '../middleware/errors.js';
|
||||
import { assertProjectAccess } from '../auth/authz.js';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
const parseRedisUrl = (url) => {
|
||||
|
|
@ -19,7 +20,27 @@ const conformQueue = new Queue('conform', {
|
|||
});
|
||||
|
||||
const router = express.Router();
|
||||
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
|
||||
|
||||
// Scope every /:id sequence route to its project: validate the UUID, resolve
|
||||
// project_id, assert the 'view' baseline; mutators escalate via requireSequenceEdit.
|
||||
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 sequences WHERE id = $1', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Sequence not found' });
|
||||
req.sequenceProjectId = rows[0].project_id;
|
||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'view');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
async function requireSequenceEdit(req, res, next) {
|
||||
try {
|
||||
await assertProjectAccess(req.user, req.sequenceProjectId, 'edit');
|
||||
next();
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
|
||||
// ── Row mapper ────────────────────────────────────────────────────────────────
|
||||
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a
|
||||
|
|
@ -124,6 +145,7 @@ router.get('/', async (req, res, next) => {
|
|||
try {
|
||||
const { project_id } = req.query;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
await assertProjectAccess(req.user, project_id, 'view');
|
||||
const r = await pool.query(
|
||||
`SELECT * FROM sequences WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||
[project_id]
|
||||
|
|
@ -143,6 +165,7 @@ router.post('/', async (req, res, next) => {
|
|||
height = 1080,
|
||||
} = req.body;
|
||||
if (!project_id) return res.status(400).json({ error: 'project_id is required' });
|
||||
await assertProjectAccess(req.user, project_id, 'edit');
|
||||
const r = await pool.query(
|
||||
`INSERT INTO sequences (project_id, name, frame_rate, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
|
|
@ -188,7 +211,7 @@ router.get('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── PUT /:id – update sequence metadata ──────────────────────────────────────
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
router.put('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const { name, frame_rate, width, height } = req.body;
|
||||
const updates = [];
|
||||
|
|
@ -211,7 +234,7 @@ router.put('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── DELETE /:id ───────────────────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
router.delete('/:id', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const r = await pool.query(`DELETE FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!r.rowCount) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
|
@ -220,25 +243,41 @@ router.delete('/:id', async (req, res, next) => {
|
|||
});
|
||||
|
||||
// ── PUT /:id/clips – full replace of clip array (single transaction) ──────────
|
||||
router.put('/:id/clips', async (req, res, next) => {
|
||||
// Verify sequence exists first (before acquiring transaction client)
|
||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
||||
router.put('/:id/clips', requireSequenceEdit, async (req, res, next) => {
|
||||
const clips = Array.isArray(req.body) ? req.body : [];
|
||||
for (const c of clips) {
|
||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||
}
|
||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||
}
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
let client;
|
||||
try {
|
||||
// Verify sequence exists first (before acquiring transaction client).
|
||||
const seqCheck = await pool.query(`SELECT id FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqCheck.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
||||
for (const c of clips) {
|
||||
if (!c.asset_id) return res.status(400).json({ error: 'Each clip must have asset_id' });
|
||||
if (!Number.isFinite(Number(c.timeline_in_frames)) || !Number.isFinite(Number(c.timeline_out_frames)) ||
|
||||
!Number.isFinite(Number(c.source_in_frames)) || !Number.isFinite(Number(c.source_out_frames))) {
|
||||
return res.status(400).json({ error: 'Clip frame fields must be finite numbers' });
|
||||
}
|
||||
if (!Number.isInteger(Number(c.track)) || Number(c.track) < 0) {
|
||||
return res.status(400).json({ error: 'Clip track must be a non-negative integer' });
|
||||
}
|
||||
}
|
||||
|
||||
// Every referenced asset must belong to THIS sequence's project. Without this,
|
||||
// a user with edit on the sequence could splice in assets from a project they
|
||||
// can't access — and GET /:id would then hand back those assets' names and
|
||||
// signed proxy URLs (cross-project leak).
|
||||
const assetIds = [...new Set(clips.map(c => c.asset_id))];
|
||||
if (assetIds.length) {
|
||||
const owning = await pool.query(
|
||||
`SELECT id FROM assets WHERE id = ANY($1::uuid[]) AND project_id = $2`,
|
||||
[assetIds, req.sequenceProjectId]
|
||||
);
|
||||
if (owning.rows.length !== assetIds.length) {
|
||||
return res.status(400).json({ error: 'All clip assets must belong to the sequence\'s project' });
|
||||
}
|
||||
}
|
||||
|
||||
client = await pool.connect();
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`DELETE FROM sequence_clips WHERE sequence_id = $1`,
|
||||
|
|
@ -265,10 +304,12 @@ router.put('/:id/clips', async (req, res, next) => {
|
|||
await client.query('COMMIT');
|
||||
res.json({ ok: true, count: clips.length });
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
// client is only set once we've connected; a failure in the pre-transaction
|
||||
// queries (existence/validation/ownership) has no transaction to roll back.
|
||||
if (client) await client.query('ROLLBACK').catch(() => {});
|
||||
next(e);
|
||||
} finally {
|
||||
client.release();
|
||||
if (client) client.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -300,7 +341,7 @@ router.post('/:id/export/edl', async (req, res, next) => {
|
|||
// ── POST /:id/conform – conform sequence via FCP XML ─────────────────────────
|
||||
// Accepts FCP XML content and encode settings from the Premiere plugin,
|
||||
// queues a conform job in BullMQ, and returns the job ID for polling.
|
||||
router.post('/:id/conform', async (req, res, next) => {
|
||||
router.post('/:id/conform', requireSequenceEdit, async (req, res, next) => {
|
||||
try {
|
||||
const seqR = await pool.query(`SELECT * FROM sequences WHERE id = $1`, [req.params.id]);
|
||||
if (!seqR.rows.length) return res.status(404).json({ error: 'Sequence not found' });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
AbortMultipartUploadCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
|
||||
import { assertProjectAccess, accessibleProjectIds } from '../auth/authz.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -138,16 +139,24 @@ function mediaTypeFromMime(mime = '') {
|
|||
return 'document';
|
||||
}
|
||||
|
||||
// GET /api/v1/upload - List in-progress uploads (#68)
|
||||
// GET /api/v1/upload - List in-progress uploads (#68). Scoped to projects the
|
||||
// caller can see — admins are unfiltered; a scoped viewer/editor only sees
|
||||
// uploads for projects they have access to (no enumeration of other projects'
|
||||
// in-flight filenames).
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE status = 'ingesting'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
const access = await accessibleProjectIds(req.user);
|
||||
let query = `SELECT id, filename, display_name, project_id, bin_id, status, created_at, updated_at
|
||||
FROM assets
|
||||
WHERE status = 'ingesting'`;
|
||||
const params = [];
|
||||
if (!access.all) {
|
||||
if (access.ids.size === 0) return res.json([]);
|
||||
query += ` AND project_id = ANY($1::uuid[])`;
|
||||
params.push([...access.ids]);
|
||||
}
|
||||
query += ` ORDER BY created_at DESC LIMIT 50`;
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
|
@ -163,6 +172,17 @@ router.post('/init', async (req, res, next) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Uploading creates an asset under a project — require edit on that project.
|
||||
// Without this, any logged-in user could write into any project.
|
||||
await assertProjectAccess(req.user, projectId, 'edit');
|
||||
if (binId) {
|
||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
|
||||
if (bin.rows.length === 0) return res.status(400).json({ error: 'binId not found' });
|
||||
if (bin.rows[0].project_id !== projectId) {
|
||||
return res.status(400).json({ error: 'binId belongs to a different project' });
|
||||
}
|
||||
}
|
||||
|
||||
const assetId = uuidv4();
|
||||
const s3Key = `originals/${assetId}/${filename}`;
|
||||
const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];
|
||||
|
|
@ -326,6 +346,20 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Same authz gate as /init.
|
||||
await assertProjectAccess(req.user, projectId, 'edit');
|
||||
if (binId) {
|
||||
const bin = await pool.query('SELECT project_id FROM bins WHERE id = $1', [binId]);
|
||||
if (bin.rows.length === 0) {
|
||||
unlinkPart(tmpPath);
|
||||
return res.status(400).json({ error: 'binId not found' });
|
||||
}
|
||||
if (bin.rows[0].project_id !== projectId) {
|
||||
unlinkPart(tmpPath);
|
||||
return res.status(400).json({ error: 'binId belongs to a different project' });
|
||||
}
|
||||
}
|
||||
|
||||
const assetId = uuidv4();
|
||||
const s3Key = `originals/${assetId}/${filename}`;
|
||||
const mimeType = contentType || req.file.mimetype;
|
||||
|
|
|
|||
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(); }
|
||||
});
|
||||
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
40
services/mam-api/test/auth/google-oauth.test.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Unit tests for the config-gating + domain helpers in google-oauth.js. The
|
||||
// token-exchange / ID-token-verify path requires Google's servers and is covered
|
||||
// by manual verification (see .env.example); here we lock down the pure logic
|
||||
// that decides whether the feature is on and which domain is allowed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isConfigured, allowedDomain } from '../../src/auth/google-oauth.js';
|
||||
|
||||
function withEnv(vars, fn) {
|
||||
const saved = {};
|
||||
for (const k of Object.keys(vars)) { saved[k] = process.env[k];
|
||||
if (vars[k] === undefined) delete process.env[k]; else process.env[k] = vars[k]; }
|
||||
try { return fn(); }
|
||||
finally {
|
||||
for (const k of Object.keys(vars)) {
|
||||
if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('isConfigured is false unless client id, secret, and redirect are all set', () => {
|
||||
withEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
||||
assert.equal(isConfigured(), false);
|
||||
});
|
||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: undefined, OAUTH_REDIRECT_URL: undefined }, () => {
|
||||
assert.equal(isConfigured(), false);
|
||||
});
|
||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: undefined }, () => {
|
||||
assert.equal(isConfigured(), false);
|
||||
});
|
||||
withEnv({ GOOGLE_CLIENT_ID: 'x', GOOGLE_CLIENT_SECRET: 'y', OAUTH_REDIRECT_URL: 'https://h/cb' }, () => {
|
||||
assert.equal(isConfigured(), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('allowedDomain normalizes and defaults to null', () => {
|
||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: undefined }, () => assert.equal(allowedDomain(), null));
|
||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: '' }, () => assert.equal(allowedDomain(), null));
|
||||
withEnv({ GOOGLE_ALLOWED_DOMAIN: ' WildDragon.NET ' }, () => assert.equal(allowedDomain(), 'wilddragon.net'));
|
||||
});
|
||||
49
services/mam-api/test/auth/mfa-tickets.test.js
Normal file
49
services/mam-api/test/auth/mfa-tickets.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// MFA ticket binding tests — the second login step's tickets are bound to the
|
||||
// issuing request's IP + User-Agent (hashed) so a stolen ticket replayed from
|
||||
// a different origin can't complete the second factor.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { issueTicket, redeemTicket } from '../../src/auth/mfa-tickets.js';
|
||||
|
||||
test('ticket round-trips when ip + userAgent match', () => {
|
||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
||||
});
|
||||
|
||||
test('ticket rejects redemption from a different IP', () => {
|
||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||||
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
||||
});
|
||||
|
||||
test('ticket rejects redemption with a different User-Agent', () => {
|
||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'Mozilla/5.0' }), null);
|
||||
});
|
||||
|
||||
test('ticket is single-use even on binding mismatch', () => {
|
||||
// A wrong-binding probe must still burn the ticket — otherwise an attacker
|
||||
// could try multiple IPs/UAs against the same ticket.
|
||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||||
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
||||
// Same ticket with correct bindings now also fails — it was consumed.
|
||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
||||
});
|
||||
|
||||
test('redeemTicket returns null for missing or unknown id', () => {
|
||||
assert.equal(redeemTicket(null), null);
|
||||
assert.equal(redeemTicket(undefined), null);
|
||||
assert.equal(redeemTicket(''), null);
|
||||
assert.equal(redeemTicket('not-a-real-id', { ip: 'x', userAgent: 'y' }), null);
|
||||
});
|
||||
|
||||
test('ticket is single-use on success', () => {
|
||||
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
||||
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
||||
});
|
||||
|
||||
test('issueTicket without bindings still works (back-compat / tests)', () => {
|
||||
const id = issueTicket('user-1');
|
||||
// No bindings on redeem either — both sides skip the check.
|
||||
assert.equal(redeemTicket(id), 'user-1');
|
||||
});
|
||||
96
services/mam-api/test/auth/totp.test.js
Normal file
96
services/mam-api/test/auth/totp.test.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
base32Encode, base32Decode, generateSecret, generateToken,
|
||||
verifyToken, otpauthURI, generateRecoveryCodes,
|
||||
} from '../../src/auth/totp.js';
|
||||
|
||||
// ── base32 round-trips ──────────────────────────────────────────────────────
|
||||
test('base32 encode/decode round-trips arbitrary bytes', () => {
|
||||
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
||||
const buf = Buffer.from(s);
|
||||
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
|
||||
}
|
||||
});
|
||||
|
||||
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
|
||||
// low 6 here, so compare the last 6 digits of each published value). ──────────
|
||||
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
|
||||
// check the 6-digit code at each published timestamp.
|
||||
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
|
||||
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
||||
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
|
||||
const vectors = [
|
||||
[59, '94287082'],
|
||||
[1111111109, '07081804'],
|
||||
[1111111111, '14050471'],
|
||||
[1234567890, '89005924'],
|
||||
[2000000000, '69279037'],
|
||||
[20000000000, '65353130'],
|
||||
];
|
||||
for (const [secs, full8] of vectors) {
|
||||
const got = generateToken(secret, secs * 1000);
|
||||
assert.equal(got, full8.slice(-6), `t=${secs}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── verify with drift window ────────────────────────────────────────────────
|
||||
// verifyToken returns the matched counter (truthy) or null (falsy).
|
||||
test('verifyToken accepts the current code and ±1 step of drift', () => {
|
||||
const secret = generateSecret();
|
||||
const now = 1_700_000_000_000;
|
||||
const code = generateToken(secret, now);
|
||||
const baseCounter = Math.floor(now / 1000 / 30);
|
||||
assert.equal(verifyToken(secret, code, now), baseCounter);
|
||||
// 30s earlier / later still inside ±1 window — the *issued* code matches the
|
||||
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
|
||||
// code matches as drift = -1 step → returns baseCounter.
|
||||
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
|
||||
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
|
||||
// 2 steps away → rejected.
|
||||
assert.equal(verifyToken(secret, code, now + 90_000), null);
|
||||
});
|
||||
|
||||
test('verifyToken rejects malformed / empty input without throwing', () => {
|
||||
const secret = generateSecret();
|
||||
assert.equal(verifyToken(secret, ''), null);
|
||||
assert.equal(verifyToken(secret, 'abcdef'), null);
|
||||
assert.equal(verifyToken(secret, '12345'), null); // too short
|
||||
assert.equal(verifyToken(secret, '1234567'), null); // too long
|
||||
assert.equal(verifyToken('', '123456'), null);
|
||||
});
|
||||
|
||||
test('verifyToken tolerates spaces in the user-entered code', () => {
|
||||
const secret = generateSecret();
|
||||
const now = 1_700_000_000_000;
|
||||
const code = generateToken(secret, now);
|
||||
const expected = Math.floor(now / 1000 / 30);
|
||||
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
|
||||
});
|
||||
|
||||
test('verifyToken returns the matched counter (for replay protection)', () => {
|
||||
const secret = generateSecret();
|
||||
const now = 1_700_000_000_000;
|
||||
const code = generateToken(secret, now);
|
||||
const counter = verifyToken(secret, code, now);
|
||||
assert.ok(typeof counter === 'number' && counter > 0);
|
||||
assert.equal(counter, Math.floor(now / 1000 / 30));
|
||||
});
|
||||
|
||||
// ── otpauth URI ─────────────────────────────────────────────────────────────
|
||||
test('otpauthURI embeds secret, issuer, and account', () => {
|
||||
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
|
||||
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
|
||||
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
|
||||
assert.match(uri, /issuer=Dragonflight/);
|
||||
assert.match(uri, /digits=6/);
|
||||
assert.match(uri, /period=30/);
|
||||
});
|
||||
|
||||
// ── recovery codes ──────────────────────────────────────────────────────────
|
||||
test('generateRecoveryCodes returns N distinct formatted codes', () => {
|
||||
const codes = generateRecoveryCodes(10);
|
||||
assert.equal(codes.length, 10);
|
||||
assert.equal(new Set(codes).size, 10);
|
||||
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
||||
});
|
||||
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(); }
|
||||
});
|
||||
76
services/mam-api/test/routes/comments-access.test.js
Normal file
76
services/mam-api/test/routes/comments-access.test.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// RBAC coverage for asset comments: the guard resolves the project via the
|
||||
// asset, requiring view to read and edit to write. Also verifies the author id
|
||||
// is recorded from req.user (regression for the old req.session.userId bug).
|
||||
// Skips without TEST_DATABASE_URL.
|
||||
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 appWithComments(user) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
const { default: commentsRouter } = await import('../../src/routes/comments.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.user = user; next(); });
|
||||
// Mirror index.js mount so :assetId flows through (mergeParams in the router).
|
||||
app.use('/api/v1/assets/:assetId/comments', commentsRouter);
|
||||
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 = async (pid, name) => (await pool.query(
|
||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`, [pid, name])).rows[0].id;
|
||||
const assetA = await asset(alpha, 'a1');
|
||||
const assetB = await asset(beta, 'b1');
|
||||
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
||||
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
|
||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
|
||||
return { assetA, assetB, viewer, editor };
|
||||
}
|
||||
|
||||
test('comments require view to read and block ungranted assets', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { assetA, assetB, viewer } = await seed(pool);
|
||||
const s = await appWithComments(viewer);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments')).status, 200);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/assets/' + assetB + '/comments')).status, 403);
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('posting a comment requires edit and records the author id from req.user', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { assetA, viewer, editor } = await seed(pool);
|
||||
|
||||
// viewer (view-only) cannot post.
|
||||
let s = await appWithComments(viewer);
|
||||
let r = await fetch(s.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'hi' }) });
|
||||
assert.equal(r.status, 403);
|
||||
await s.close();
|
||||
|
||||
// editor can post, and the author id is the editor (not null).
|
||||
let e = await appWithComments(editor);
|
||||
r = await fetch(e.baseUrl + '/api/v1/assets/' + assetA + '/comments', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: 'looks good' }) });
|
||||
assert.equal(r.status, 201);
|
||||
const created = await r.json();
|
||||
assert.equal(created.user_id, editor.id, 'comment author must be req.user.id');
|
||||
await e.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
63
services/mam-api/test/routes/google-link.test.js
Normal file
63
services/mam-api/test/routes/google-link.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Security regression test for resolveGoogleUser: a Google sign-in must NEVER
|
||||
// adopt a pre-existing local account by matching email (that would be account
|
||||
// takeover). It links only by google_sub, otherwise provisions a fresh viewer.
|
||||
// Skips without TEST_DATABASE_URL.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
import { hashPassword } from '../../src/auth/passwords.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
async function loadResolve() {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
return (await import('../../src/routes/auth.js?v=' + Date.now())).resolveGoogleUser;
|
||||
}
|
||||
|
||||
test('a Google login with an email matching a local admin does NOT take over that account', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
// Pre-existing local admin with a password and the same email the attacker controls.
|
||||
const adminId = (await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, email)
|
||||
VALUES ('boss', $1, 'admin', 'boss@wilddragon.net') RETURNING id`,
|
||||
[await hashPassword('a-real-password-12')])).rows[0].id;
|
||||
|
||||
const resolveGoogleUser = await loadResolve();
|
||||
const user = await resolveGoogleUser({ sub: 'google-attacker-sub', email: 'boss@wilddragon.net', name: 'Not The Boss' });
|
||||
|
||||
// Must be a brand-new account, NOT the admin.
|
||||
assert.notEqual(user.id, adminId, 'Google login must not resolve to the existing admin');
|
||||
const row = (await pool.query(`SELECT role, google_sub FROM users WHERE id = $1`, [user.id])).rows[0];
|
||||
assert.equal(row.role, 'viewer', 'auto-provisioned account must be a viewer');
|
||||
assert.equal(row.google_sub, 'google-attacker-sub');
|
||||
// The admin row is untouched (no google_sub linked onto it).
|
||||
const admin = (await pool.query(`SELECT google_sub FROM users WHERE id = $1`, [adminId])).rows[0];
|
||||
assert.equal(admin.google_sub, null, 'the existing admin must not have been linked');
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('a returning Google user resolves to the same account by google_sub', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const resolveGoogleUser = await loadResolve();
|
||||
const first = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
||||
const second = await resolveGoogleUser({ sub: 'sub-123', email: 'alice@wilddragon.net', name: 'Alice' });
|
||||
assert.equal(first.id, second.id, 'same google_sub must map to the same user');
|
||||
const count = (await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE google_sub = 'sub-123'`)).rows[0].n;
|
||||
assert.equal(count, 1, 'must not create a duplicate on second login');
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('username collisions get a numeric suffix', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('sam', 'x', 'viewer')`);
|
||||
const resolveGoogleUser = await loadResolve();
|
||||
const u = await resolveGoogleUser({ sub: 'sub-sam', email: 'sam@wilddragon.net', name: 'Sam' });
|
||||
assert.match(u.username, /^sam\d+$/, 'colliding username should get a numeric suffix');
|
||||
} 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(); }
|
||||
});
|
||||
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
107
services/mam-api/test/routes/recorders-access.test.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// RBAC coverage for recorders: list is scoped to accessible projects, /:id
|
||||
// asserts view, mutators assert edit, null-project recorders are admin-only.
|
||||
// Same harness as assets-access.test.js — singleton pool on TEST_DATABASE_URL,
|
||||
// req.user injected, router dynamic-imported. Skips without TEST_DATABASE_URL.
|
||||
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 appWithRecorders(user) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
const { default: recordersRouter } = await import('../../src/routes/recorders.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.user = user; next(); });
|
||||
app.use('/api/v1/recorders', recordersRouter);
|
||||
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 rec = async (pid, name) => (await pool.query(
|
||||
`INSERT INTO recorders (name, source_type, project_id) VALUES ($1, 'srt', $2) RETURNING id`, [name, pid])).rows[0].id;
|
||||
const recA = await rec(alpha, 'Cam A');
|
||||
const recB = await rec(beta, 'Cam B');
|
||||
const recNull = (await pool.query(
|
||||
`INSERT INTO recorders (name, source_type, project_id) VALUES ('Unassigned','srt',NULL) 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 ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
||||
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, recA, recB, recNull, admin, scoped };
|
||||
}
|
||||
|
||||
test('recorders list is scoped; admin sees all incl. null-project, scoped sees only granted', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { recA, admin, scoped } = await seed(pool);
|
||||
|
||||
let a = await appWithRecorders(admin);
|
||||
let list = await (await fetch(a.baseUrl + '/api/v1/recorders')).json();
|
||||
assert.equal(list.length, 3, 'admin sees all three recorders');
|
||||
await a.close();
|
||||
|
||||
let s = await appWithRecorders(scoped);
|
||||
list = await (await fetch(s.baseUrl + '/api/v1/recorders')).json();
|
||||
assert.deepEqual(list.map(r => r.id), [recA], 'scoped editor sees only the granted-project recorder');
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('recorder /:id enforces view; mutators enforce edit', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { recA, recB, recNull, scoped } = await seed(pool);
|
||||
const s = await appWithRecorders(scoped);
|
||||
|
||||
// view-granted on Alpha: can GET recA, cannot GET recB (other project) or recNull (admin-only).
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recA)).status, 200);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recB)).status, 403);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/recorders/' + recNull)).status, 403);
|
||||
|
||||
// view-only grant cannot PATCH (edit) or start.
|
||||
const patch = await fetch(s.baseUrl + '/api/v1/recorders/' + recA, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) });
|
||||
assert.equal(patch.status, 403, 'view-level grant must not allow edit');
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('creating a recorder requires edit; null-project create is admin-only', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, admin, scoped } = await seed(pool);
|
||||
|
||||
// scoped editor has only 'view' on Alpha → create denied.
|
||||
let s = await appWithRecorders(scoped);
|
||||
let r = await fetch(s.baseUrl + '/api/v1/recorders', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New', source_type: 'srt', project_id: alpha }) });
|
||||
assert.equal(r.status, 403);
|
||||
// null project → admin-only, still denied for the editor.
|
||||
r = await fetch(s.baseUrl + '/api/v1/recorders', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'New2', source_type: 'srt' }) });
|
||||
assert.equal(r.status, 403);
|
||||
await s.close();
|
||||
|
||||
// admin can create in any project and with no project.
|
||||
let a = await appWithRecorders(admin);
|
||||
r = await fetch(a.baseUrl + '/api/v1/recorders', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'AdminRec', source_type: 'srt' }) });
|
||||
assert.equal(r.status, 201);
|
||||
await a.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
103
services/mam-api/test/routes/sequences-access.test.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// RBAC coverage for sequences: list/create assert on the query/body project,
|
||||
// /:id asserts view, mutators assert edit. Skips without TEST_DATABASE_URL.
|
||||
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 appWithSequences(user) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
const { default: sequencesRouter } = await import('../../src/routes/sequences.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { req.user = user; next(); });
|
||||
app.use('/api/v1/sequences', sequencesRouter);
|
||||
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 seqB = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'B seq') RETURNING id`, [beta])).rows[0].id;
|
||||
const viewer = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('vu','x','viewer') RETURNING id`)).rows[0].id, role: 'viewer' };
|
||||
const editor = { id: (await pool.query(`INSERT INTO users (username, password_hash, role) VALUES ('ed','x','editor') RETURNING id`)).rows[0].id, role: 'editor' };
|
||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'view')`, [alpha, viewer.id]);
|
||||
await pool.query(`INSERT INTO project_access (project_id, subject_type, subject_id, level) VALUES ($1,'user',$2,'edit')`, [alpha, editor.id]);
|
||||
return { alpha, beta, seqB, viewer, editor };
|
||||
}
|
||||
|
||||
const asset = (pool, pid, name) => pool.query(
|
||||
`INSERT INTO assets (project_id, filename, display_name, media_type, status) VALUES ($1,$2,$2,'video','ready') RETURNING id`,
|
||||
[pid, name]).then(r => r.rows[0].id);
|
||||
|
||||
test('GET /sequences?project_id 403s on an ungranted project', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, beta, viewer } = await seed(pool);
|
||||
const s = await appWithSequences(viewer);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + alpha)).status, 200);
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences?project_id=' + beta)).status, 403);
|
||||
await s.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('POST /sequences requires edit; viewer denied, editor allowed; /:id view enforced', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, seqB, viewer, editor } = await seed(pool);
|
||||
|
||||
// viewer (view-only on Alpha) cannot create.
|
||||
let s = await appWithSequences(viewer);
|
||||
let r = await fetch(s.baseUrl + '/api/v1/sequences', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'X' }) });
|
||||
assert.equal(r.status, 403);
|
||||
// viewer cannot read a sequence in an ungranted project (Beta).
|
||||
assert.equal((await fetch(s.baseUrl + '/api/v1/sequences/' + seqB)).status, 403);
|
||||
await s.close();
|
||||
|
||||
// editor can create in Alpha and then PUT it.
|
||||
let e = await appWithSequences(editor);
|
||||
r = await fetch(e.baseUrl + '/api/v1/sequences', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: alpha, name: 'Mine' }) });
|
||||
assert.equal(r.status, 201);
|
||||
const seqId = (await r.json()).id;
|
||||
const put = await fetch(e.baseUrl + '/api/v1/sequences/' + seqId, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Renamed' }) });
|
||||
assert.equal(put.status, 200);
|
||||
await e.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('PUT /:id/clips rejects assets from outside the sequence project (cross-project leak guard)', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
const { alpha, beta, editor } = await seed(pool);
|
||||
const seqA = (await pool.query(`INSERT INTO sequences (project_id, name) VALUES ($1,'A seq') RETURNING id`, [alpha])).rows[0].id;
|
||||
const assetA = await asset(pool, alpha, 'a-clip');
|
||||
const assetB = await asset(pool, beta, 'b-clip'); // editor has NO access to project Beta
|
||||
|
||||
const e = await appWithSequences(editor);
|
||||
const clip = (aid) => ({ asset_id: aid, track: 0, timeline_in_frames: 0, timeline_out_frames: 10, source_in_frames: 0, source_out_frames: 10 });
|
||||
|
||||
// Same-project asset is accepted.
|
||||
let r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA)]) });
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// Splicing in a Beta asset must be rejected — it would leak B's media via GET /:id.
|
||||
r = await fetch(e.baseUrl + '/api/v1/sequences/' + seqA + '/clips', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([clip(assetA), clip(assetB)]) });
|
||||
assert.equal(r.status, 400, 'foreign-project asset must be rejected');
|
||||
await e.close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
143
services/mam-api/test/routes/totp.test.js
Normal file
143
services/mam-api/test/routes/totp.test.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Integration test for the TOTP two-step login + recovery codes.
|
||||
//
|
||||
// Mounts the real auth router with a session store on the throwaway test DB.
|
||||
// Drives: enroll (setup → enable) → logout → password login returns mfa_required
|
||||
// → complete with a generated code → and the recovery-code single-use path.
|
||||
// Skips when TEST_DATABASE_URL is unset.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||||
import { hashPassword } from '../../src/auth/passwords.js';
|
||||
import { generateToken } from '../../src/auth/totp.js';
|
||||
|
||||
const SKIP = !isTestDbConfigured() && 'TEST_DATABASE_URL not set';
|
||||
|
||||
async function appWithAuth(pool) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
process.env.AUTH_ENABLED = 'true';
|
||||
process.env.SESSION_SECRET = 'test';
|
||||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||||
const { default: authRouter } = await import('../../src/routes/auth.js?v=' + Date.now());
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({
|
||||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||||
secret: 'test', name: 'dragonflight.sid',
|
||||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||||
rolling: false, resave: false, saveUninitialized: false,
|
||||
}));
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
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)) }));
|
||||
});
|
||||
}
|
||||
|
||||
const J = (cookie, body) => ({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(cookie ? { cookie } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function loginPassword(baseUrl, username, password) {
|
||||
const r = await fetch(baseUrl + '/api/v1/auth/login', J(null, { username, password }));
|
||||
const cookie = (r.headers.get('set-cookie') || '').split(';')[0];
|
||||
return { r, body: await r.json().catch(() => ({})), cookie };
|
||||
}
|
||||
|
||||
test('enable TOTP, then password login requires a second factor', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
// 1. Password login (no TOTP yet) → 200 with a session cookie.
|
||||
const first = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
assert.equal(first.r.status, 200);
|
||||
assert.ok(!first.body.mfa_required);
|
||||
const cookie = first.cookie;
|
||||
|
||||
// 2. Enroll: setup returns a secret; enable confirms with a live code.
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(cookie, {}))).json();
|
||||
assert.match(setup.secret, /^[A-Z2-7]+$/);
|
||||
const enableRes = await fetch(baseUrl + '/api/v1/auth/totp/enable', J(cookie, { code: generateToken(setup.secret) }));
|
||||
assert.equal(enableRes.status, 200);
|
||||
const enableBody = await enableRes.json();
|
||||
assert.equal(enableBody.enabled, true);
|
||||
assert.equal(enableBody.recovery_codes.length, 10);
|
||||
|
||||
// 3. Fresh password login now returns mfa_required + a ticket, NO session cookie.
|
||||
const second = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
assert.equal(second.r.status, 200);
|
||||
assert.equal(second.body.mfa_required, true);
|
||||
assert.ok(second.body.ticket);
|
||||
assert.ok(!second.cookie, 'no session cookie should be set before the second factor');
|
||||
|
||||
// 4. Wrong code → 401; the ticket is now spent.
|
||||
const bad = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: second.body.ticket, code: '000000' }));
|
||||
assert.equal(bad.status, 401);
|
||||
|
||||
// 5. New login + correct code → 200 with a session cookie.
|
||||
const third = await loginPassword(baseUrl, 'alice', 'correct-horse-battery');
|
||||
const ok = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: third.body.ticket, code: generateToken(setup.secret) }));
|
||||
assert.equal(ok.status, 200);
|
||||
assert.match(ok.headers.get('set-cookie') || '', /dragonflight\.sid=/);
|
||||
|
||||
await close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('a recovery code logs in once and cannot be reused', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
const first = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
||||
const enableBody = await (await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }))).json();
|
||||
const recovery = enableBody.recovery_codes[0];
|
||||
|
||||
// Use a recovery code to complete a fresh login.
|
||||
const login1 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const use1 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login1.body.ticket, code: recovery }));
|
||||
assert.equal(use1.status, 200, 'recovery code should complete login once');
|
||||
|
||||
// The same recovery code must NOT work a second time.
|
||||
const login2 = await loginPassword(baseUrl, 'bob', 'correct-horse-battery');
|
||||
const use2 = await fetch(baseUrl + '/api/v1/auth/login/totp', J(null, { ticket: login2.body.ticket, code: recovery }));
|
||||
assert.equal(use2.status, 401, 'a spent recovery code must be rejected');
|
||||
|
||||
await close();
|
||||
} finally { await pool.end(); }
|
||||
});
|
||||
|
||||
test('disable TOTP returns login to single-factor', { skip: SKIP }, async () => {
|
||||
const pool = await setupTestDb();
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
try {
|
||||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('carol', $1)`, [await hashPassword('correct-horse-battery')]);
|
||||
const { baseUrl, close } = await appWithAuth(pool);
|
||||
|
||||
const first = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
||||
const setup = await (await fetch(baseUrl + '/api/v1/auth/totp/setup', J(first.cookie, {}))).json();
|
||||
await fetch(baseUrl + '/api/v1/auth/totp/enable', J(first.cookie, { code: generateToken(setup.secret) }));
|
||||
|
||||
// Disabling requires the password.
|
||||
const wrongPw = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'nope' }));
|
||||
assert.equal(wrongPw.status, 400);
|
||||
const disabled = await fetch(baseUrl + '/api/v1/auth/totp/disable', J(first.cookie, { password: 'correct-horse-battery' }));
|
||||
assert.equal(disabled.status, 204);
|
||||
|
||||
// Password login is single-factor again.
|
||||
const relog = await loginPassword(baseUrl, 'carol', 'correct-horse-battery');
|
||||
assert.equal(relog.r.status, 200);
|
||||
assert.ok(!relog.body.mfa_required);
|
||||
|
||||
await 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>
|
||||
)}
|
||||
|
|
@ -1474,6 +1488,135 @@ function AccountSection() {
|
|||
);
|
||||
}
|
||||
|
||||
// Two-factor (TOTP) enrollment + management. Reflects window.ZAMPP_DATA.ME.totp_enabled.
|
||||
function TotpSection() {
|
||||
const me = window.ZAMPP_DATA?.ME || {};
|
||||
const [enabled, setEnabled] = React.useState(!!me.totp_enabled);
|
||||
const [phase, setPhase] = React.useState('idle'); // idle | enrolling | recovery
|
||||
const [enroll, setEnroll] = React.useState(null); // { secret, otpauth_uri, qr }
|
||||
const [code, setCode] = React.useState('');
|
||||
const [recovery, setRecovery] = React.useState(null); // string[]
|
||||
const [disablePw, setDisablePw] = React.useState('');
|
||||
const [showDisable, setShowDisable] = React.useState(false);
|
||||
const [msg, setMsg] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const api = (path, body) => fetch('/api/v1/auth' + path, {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const startSetup = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/setup');
|
||||
if (r.status === 200) { setEnroll(await r.json()); setPhase('enrolling'); }
|
||||
else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Setup failed' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const confirmEnable = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/enable', { code: code.trim() });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (r.status === 200) {
|
||||
setRecovery(body.recovery_codes || []); setPhase('recovery');
|
||||
setEnabled(true); setCode('');
|
||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: true };
|
||||
} else setMsg({ kind: 'err', text: body.error || 'Could not enable' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setMsg(null); setBusy(true);
|
||||
try {
|
||||
const r = await api('/totp/disable', { password: disablePw });
|
||||
if (r.status === 204) {
|
||||
setEnabled(false); setShowDisable(false); setDisablePw(''); setPhase('idle');
|
||||
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), totp_enabled: false };
|
||||
setMsg({ kind: 'ok', text: 'Two-factor disabled' });
|
||||
} else setMsg({ kind: 'err', text: (await r.json().catch(() => ({}))).error || 'Could not disable' });
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Two-factor authentication</h3>
|
||||
|
||||
{/* Status line */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: phase === 'idle' ? 0 : 14 }}>
|
||||
<span className={`badge ${enabled ? 'success' : 'neutral'}`}>{enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{enabled ? 'An authenticator code is required at sign-in.' : 'Add a time-based code from an authenticator app.'}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{!enabled && phase === 'idle' && <button className="btn primary sm" disabled={busy} onClick={startSetup}>Set up</button>}
|
||||
{enabled && phase !== 'recovery' && !showDisable && <button className="btn ghost sm danger" onClick={() => setShowDisable(true)}>Disable</button>}
|
||||
</div>
|
||||
|
||||
{/* Enrolling: show QR / secret + code field */}
|
||||
{phase === 'enrolling' && enroll && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 16, alignItems: 'start' }}>
|
||||
<div>
|
||||
{enroll.qr
|
||||
? <img src={enroll.qr} alt="TOTP QR code" style={{ width: 160, height: 160, borderRadius: 6, background: '#fff', padding: 6 }} />
|
||||
: <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Scan the secret below in your app.</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
Scan the QR with Google Authenticator, Authy, or 1Password — or enter this secret manually:
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 12, background: 'var(--bg-2)', padding: '6px 10px', borderRadius: 5, wordBreak: 'break-all', marginBottom: 12 }}>{enroll.secret}</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Enter the 6-digit code to confirm</label>
|
||||
<input className="field-input mono" value={code} autoFocus
|
||||
onChange={e => setCode(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && code.trim()) confirmEnable(); }}
|
||||
placeholder="123456" style={{ width: 140 }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="btn primary sm" disabled={busy || !code.trim()} onClick={confirmEnable}>Enable</button>
|
||||
<button className="btn ghost sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setEnroll(null); setCode(''); }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recovery codes — shown exactly once */}
|
||||
{phase === 'recovery' && recovery && (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 8 }}>
|
||||
Save these recovery codes somewhere safe. Each works once if you lose your authenticator. They won't be shown again.
|
||||
</div>
|
||||
<div className="mono" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, background: 'var(--bg-2)', padding: 12, borderRadius: 6, fontSize: 13, marginBottom: 10 }}>
|
||||
{recovery.map(c => <div key={c}>{c}</div>)}
|
||||
</div>
|
||||
<button className="btn sm" onClick={() => navigator.clipboard && navigator.clipboard.writeText(recovery.join('\n'))}>Copy codes</button>
|
||||
<button className="btn primary sm" style={{ marginLeft: 8 }} onClick={() => { setPhase('idle'); setRecovery(null); }}>Done</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable confirmation */}
|
||||
{showDisable && (
|
||||
<div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: '160px 1fr auto auto', gap: 8, alignItems: 'end' }}>
|
||||
<div className="field" style={{ marginBottom: 0, gridColumn: '1 / 3' }}>
|
||||
<label className="field-label">Confirm your password to disable</label>
|
||||
<input className="field-input" type="password" value={disablePw} autoComplete="current-password"
|
||||
onChange={e => setDisablePw(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && disablePw) disable(); }} />
|
||||
</div>
|
||||
<button className="btn danger sm" disabled={busy || !disablePw} onClick={disable}>Disable 2FA</button>
|
||||
<button className="btn ghost sm" onClick={() => { setShowDisable(false); setDisablePw(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiTokensSection() {
|
||||
const [tokens, setTokens] = React.useState([]);
|
||||
const [name, setName] = React.useState('');
|
||||
|
|
@ -1583,6 +1726,7 @@ function Settings() {
|
|||
{section === 'account' && (
|
||||
<>
|
||||
<AccountSection />
|
||||
<TotpSection />
|
||||
<ApiTokensSection />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -116,27 +116,131 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Google sign-in availability + a friendly message for the callback's
|
||||
// ?auth_error redirect (domain-not-allowed / generic google failure).
|
||||
function useGoogleAndAuthError(setError) {
|
||||
const [googleEnabled, setGoogleEnabled] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
|
||||
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
|
||||
const params = new URLSearchParams(location.search);
|
||||
const e = params.get('auth_error');
|
||||
if (e === 'domain') setError('That Google account is not in an allowed domain.');
|
||||
else if (e === 'google') setError('Google sign-in failed. Please try again.');
|
||||
if (e) {
|
||||
// Clean the query string so a reload doesn't re-show the error.
|
||||
const url = location.pathname + location.hash;
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
}, [setError]);
|
||||
return googleEnabled;
|
||||
}
|
||||
|
||||
function GoogleButton() {
|
||||
return (
|
||||
<a href={API_BASE + '/auth/google'} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
|
||||
background: 'var(--bg-3)', color: 'var(--text-1)',
|
||||
border: '1px solid var(--border)', borderRadius: 4,
|
||||
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
|
||||
}}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
|
||||
Sign in with Google
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginScreen({ onDone }) {
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
// Second factor: when the server returns { mfa_required, ticket }, we switch
|
||||
// to the code step instead of completing login. `ticket` may be a real value
|
||||
// (password path) or the sentinel 'session' (Google path, where the ticket
|
||||
// lives in the session cookie and is not exposed to JS).
|
||||
const [ticket, setTicket] = React.useState(null);
|
||||
const [code, setCode] = React.useState('');
|
||||
const googleEnabled = useGoogleAndAuthError(setError);
|
||||
|
||||
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
|
||||
// session, so enter the code step without a body ticket.
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('mfa') === '1') {
|
||||
setTicket('session');
|
||||
history.replaceState(null, '', location.pathname + location.hash);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
const r = await postJson('/auth/login', { username, password });
|
||||
if (r.status === 200) { onDone(); return; }
|
||||
if (r.status === 200) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
|
||||
onDone(); return;
|
||||
}
|
||||
const body = await r.json().catch(() => ({}));
|
||||
setError(body.error || ('Login failed: ' + r.status));
|
||||
} catch (e) { setError(e.message || 'Login failed'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const submitCode = async () => {
|
||||
setError(''); setBusy(true);
|
||||
try {
|
||||
// For the Google path the ticket is the session sentinel — send code only.
|
||||
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
|
||||
const r = await postJson('/auth/login/totp', payload);
|
||||
if (r.status === 200) { onDone(); return; }
|
||||
const body = await r.json().catch(() => ({}));
|
||||
// An expired/used ticket means the user must start over.
|
||||
if (r.status === 401 && /ticket/.test(body.error || '')) {
|
||||
setTicket(null); setCode(''); setPassword('');
|
||||
setError('Session timed out — please sign in again.');
|
||||
} else {
|
||||
setError(body.error || ('Verification failed: ' + r.status));
|
||||
}
|
||||
} catch (e) { setError(e.message || 'Verification failed'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
if (ticket) {
|
||||
return (
|
||||
<Screen>
|
||||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
||||
Two-factor authentication
|
||||
</div>
|
||||
<ErrorRow text={error} />
|
||||
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
|
||||
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
|
||||
Enter the 6-digit code from your authenticator app, or a recovery code.
|
||||
</div>
|
||||
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<ErrorRow text={error} />
|
||||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
||||
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
||||
{googleEnabled && <><Divider /><GoogleButton /></>}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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