Compare commits

...

6 commits

Author SHA1 Message Date
Zac
72fc608d8a fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.

1. TOTP brute-force gate (the big one). /login was calling
   ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
   *before* the second factor was proven. That cleared the per-IP failure
   counter, so each /login retry let an attacker with a known password
   hammer the 6-digit /login/totp space (10^6) at full speed.
   Now recordSuccess fires only inside establishSession() — i.e. after
   every required factor has actually passed (password [+TOTP] or
   OAuth [+TOTP]).

2. MFA ticket binding. Tickets issued by /login (and the Google callback)
   were unbound — a stolen ticket replayed from a different origin still
   worked. Tickets now carry SHA-256 hashes of the issuing request's IP
   and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
   even on mismatch so a wrong-binding probe can't be retried.

3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
   accepted the same code as many times as you submitted it. Now
   verifyToken returns the matched counter, and /login/totp does a CAS
   UPDATE on users.totp_last_counter — codes at counters <= the last
   accepted value are rejected. New migration 030 adds totp_last_counter,
   seeded on /totp/enable so the enrollment code itself can't be reused
   at first login, and zeroed on /totp/disable.

4. Google OAuth domain check no longer falls back to the email suffix
   when the hd (hosted-domain) claim is missing. Email-suffix matching
   let consumer (non-Workspace) Google accounts whose email happens to
   end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
   the operator means "only this Workspace", so accounts without a
   verified hd must be rejected.

Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:53 +00:00
Zac
3fe7d6bba2 fix(mam-api): close cross-project authz gaps in assets/bins/jobs/upload
Review of the v2 auth landing found four places where the per-project RBAC
helpers weren't applied to destination/source projects, letting a scoped
editor write into projects they don't have access to:

- assets PATCH /🆔 bin_id moved with no check, so an editor in project A
  could stuff their asset into a bin in project B. Now validates the bin's
  project_id matches the asset's own project (assets don't change project).
- assets POST /:id/copy: body's projectId/binId never checked, so any
  reachable asset could be cloned into an arbitrary project. Now asserts
  edit on the destination project and validates binId belongs there.
- bins POST /:id/assets: requireBinEdit checks edit on the bin's project but
  not on the source asset's project, so an asset from project B could be
  pulled into A's bin tree (and surfaced in A's views). Now the asset must
  belong to the bin's own project.
- jobs POST /conform: project_id from body never gated, so any logged-in
  user could enqueue conform jobs against any project. Now asserts edit.
- upload POST /init, POST /simple: projectId/binId from body never gated,
  same class of bug. Now asserts edit on projectId and validates binId.
- upload GET /: returned every in-progress upload globally, leaking
  filenames across projects. Now scoped via accessibleProjectIds.

These are the same pattern as the holes 2615143 closed on recorders/
sequences/imports/comments — these routes existed before the RBAC commit
landed and were never marked TODO(authz), so the broad sweep missed them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:52:29 +00:00
Zac
2615143c6d feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.

- recorders: param('id') resolves project + view baseline, requireRecorderEdit
  on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
  asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
  and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
  to write); also fixes the author bug (req.session.userId -> req.user.id, which
  was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
  registration) — TODO replaced with a rationale note

Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
  asset into a project the caller lacks edit on — now asserts edit on the
  override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
  could pull in (and via GET /:id leak signed proxy URLs for) assets from a
  project they can't access — now every clip asset must belong to the
  sequence's project; pre-transaction queries moved inside try/catch so a DB
  error returns 500 instead of hanging the request

- tests: recorders-access, sequences-access (incl. cross-project clip guard),
  comments-access (incl. author-id regression)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 03:48:02 +00:00
Zac
0c3a4b625f feat(mam-api,web-ui): Google OAuth (OIDC) sign-in
Optional "Sign in with Google" with auto-provisioning, fully config-gated:
without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the
button is hidden, so deployments without SSO are unaffected.

- migration 028: users.google_sub (unique) + email; password_hash nullable
  for OAuth-only accounts
- src/auth/google-oauth.js: lazy google-auth-library, ID-token verify,
  GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true
- auth routes: /auth/google (state-CSRF redirect), /auth/google/callback,
  /auth/google/enabled; reuses establishSession
- web-ui: "Sign in with Google" on the login screen (shown only when enabled),
  friendly callback error handling
- .env.example documents all new vars

Security hardening (from review of this + the TOTP work):
- resolveGoogleUser links ONLY by google_sub, never by email — a Google login
  can never seize a pre-existing local account (account-takeover fix)
- a Google-linked account with TOTP still requires the second factor (ticket
  in session, /?mfa=1 step) instead of bypassing it
- /login/totp now applies the per-IP login backoff
- recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount)
- concurrent first-login race on google_sub is caught and re-resolved
- tests: google-oauth config helpers + google-link takeover/dedup regression

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:51:59 +00:00
Zac
fff0828d79 feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.

- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
  recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
  login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
  /totp/disable (password-confirmed); login returns {mfa_required, ticket}
  when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
  manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:42:57 +00:00
Zac
ec026195eb feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.

- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
  assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
  view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
  "Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
  service-token-needs-admin/grants requirement

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 02:37:36 +00:00
39 changed files with 2667 additions and 121 deletions

View file

@ -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.

View file

@ -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"

View 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;
}
}

View 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 };
}

View 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;
}

View 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;
}

View 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);

View 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);

View 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;

View 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;

View file

@ -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);

View file

@ -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.

View file

@ -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(

View file

@ -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 };

View file

@ -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;

View file

@ -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();

View file

@ -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)

View file

@ -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();

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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' });

View file

@ -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;

View 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(); }
});

View 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'));
});

View 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');
});

View 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}$/);
});

View 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(); }
});

View 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(); }
});

View 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(); }
});

View 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(); }
});

View 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(); }
});

View 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(); }
});

View 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(); }
});

View file

@ -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;

View file

@ -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 />
</>
)}

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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 => {