Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
229 lines
8.5 KiB
JavaScript
229 lines
8.5 KiB
JavaScript
// ADR-0016 Phase 1+2: Authenticated file delivery + per-column visibility
|
|
//
|
|
// Replaces the open `app.use('/uploads', express.static(...))` and
|
|
// `app.use('/downloads', ...)` mounts that previously served any uploaded
|
|
// file to any caller (including unauthenticated). Look up the requested
|
|
// path against the `files` table, read the owning column's
|
|
// `column.config.visibility` ('public' | 'internal' | 'private') and gate
|
|
// access:
|
|
//
|
|
// - public -> serve unconditionally (no JWT required)
|
|
// - internal -> require any valid JWT
|
|
// - private -> require JWT AND space membership for files.space_id
|
|
//
|
|
// Files with no `column_id` (legacy / orphan / system uploads such as
|
|
// avatars) are forced to `private`. Files with no DB row at all return 404
|
|
// so unknown paths cannot leak directory listings or metadata.
|
|
//
|
|
// On `public` we skip JWT entirely; on the other two we delegate to the
|
|
// existing `authenticate` middleware (so the same JWT/cookie/API-key/query
|
|
// token sources work as for /api/v3/* routes).
|
|
//
|
|
// `mountPrefix` lets one factory power both `/uploads` and `/downloads` —
|
|
// the URL we look up in `files.url` is `<mountPrefix><req.path>` (e.g.
|
|
// `/uploads/spaces/12/foo.png`).
|
|
|
|
import { dbGet, safeJsonParse } from '../database/connection.js';
|
|
import { authenticate } from './auth.js';
|
|
import { checkUserSpaceAccess } from '../services/space/access.js';
|
|
import { apiLogger } from '../utils/logger.js';
|
|
|
|
const VALID_VISIBILITY = ['private', 'internal', 'public'];
|
|
|
|
// User avatars are uploaded straight to disk by /api/v3/auth/avatar and never
|
|
// registered in the `files` table (see backend/routes/v3/auth/profile.js).
|
|
// Without a special-case they would 404 through fileGuard. They're never
|
|
// secret (any logged-in user can see colleagues' avatars in chat lists), so
|
|
// treat the whole prefix as `internal` — JWT/cookie required, no DB row.
|
|
const PUBLIC_PREFIX_RULES = [
|
|
{ prefix: '/uploads/avatars/', visibility: 'internal' },
|
|
];
|
|
|
|
/**
|
|
* Look up the `files` row for a request path and figure out the effective
|
|
* visibility. Resolution order (ADR-0016 P5):
|
|
* 1. Static prefix rules (e.g. /uploads/avatars/ -> internal, no DB lookup)
|
|
* 2. Owning column's `config.visibility` if file.column_id is set
|
|
* 3. file.visibility column (P5 — covers chat attachments and agent uploads)
|
|
* 4. fallback `private`
|
|
*
|
|
* @param {string} fullUrl - full URL stored in files.url (e.g. "/uploads/foo.png")
|
|
* @returns {Promise<{file: object|null, visibility: string} | null>} null only
|
|
* when no DB row AND no matching prefix rule (still 404 for unknown paths).
|
|
*/
|
|
export async function lookupFileVisibility(fullUrl) {
|
|
// (1) Static prefix rules — bypass DB. fileGuard still runs auth, but we
|
|
// don't need a `files` row to gate a well-known asset family.
|
|
for (const rule of PUBLIC_PREFIX_RULES) {
|
|
if (fullUrl.startsWith(rule.prefix)) {
|
|
return { file: null, visibility: rule.visibility };
|
|
}
|
|
}
|
|
|
|
const file = await dbGet(
|
|
'SELECT id, column_id, space_id, url, visibility FROM files WHERE url = ?',
|
|
[fullUrl]
|
|
);
|
|
if (!file) return null;
|
|
|
|
// (2) Column-bound files — column.config.visibility wins, since a column
|
|
// may override per-row choices made at upload time.
|
|
if (file.column_id) {
|
|
const column = await dbGet(
|
|
'SELECT id, config FROM table_columns WHERE id = ?',
|
|
[file.column_id]
|
|
);
|
|
|
|
let visibility = 'private';
|
|
if (column && column.config) {
|
|
const cfg = safeJsonParse(column.config, {}) || {};
|
|
if (typeof cfg.visibility === 'string' && VALID_VISIBILITY.includes(cfg.visibility)) {
|
|
visibility = cfg.visibility;
|
|
}
|
|
}
|
|
return { file, visibility };
|
|
}
|
|
|
|
// (3) Orphan files — read files.visibility (P5). The CHECK constraint on
|
|
// the column guarantees the value is one of the valid ones, but we
|
|
// still validate defensively in case the DB was patched out-of-band.
|
|
let visibility = 'private';
|
|
if (typeof file.visibility === 'string' && VALID_VISIBILITY.includes(file.visibility)) {
|
|
visibility = file.visibility;
|
|
}
|
|
return { file, visibility };
|
|
}
|
|
|
|
/**
|
|
* Build the file-guard middleware for a given mount prefix.
|
|
*
|
|
* @param {string} mountPrefix - "/uploads" or "/downloads" — concatenated with
|
|
* `req.path` to reconstruct the URL that was stored in files.url
|
|
* on upload.
|
|
*/
|
|
export function createFileGuard(mountPrefix) {
|
|
if (typeof mountPrefix !== 'string' || !mountPrefix.startsWith('/')) {
|
|
throw new Error('createFileGuard: mountPrefix must start with /');
|
|
}
|
|
|
|
return async function fileGuard(req, res, next) {
|
|
try {
|
|
// req.path on a sub-mounted middleware is relative ("/foo/bar.png") —
|
|
// recombine with the prefix to hit the same value stored at upload time.
|
|
const fullUrl = `${mountPrefix}${req.path}`;
|
|
|
|
const lookup = await lookupFileVisibility(fullUrl);
|
|
if (!lookup) {
|
|
// Unknown path — no DB row. Closed by default, matches ADR §Phase 1.
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { code: 'FILE_NOT_FOUND', message: 'File not found' }
|
|
});
|
|
}
|
|
|
|
const { file, visibility } = lookup;
|
|
|
|
// PUBLIC — no auth, fall through to express.static.
|
|
if (visibility === 'public') {
|
|
return next();
|
|
}
|
|
|
|
// INTERNAL or PRIVATE — both require a valid JWT first.
|
|
// Reuse authenticate(): on success it calls next(); on failure it
|
|
// writes the 401 directly via `res.status().json()` and never calls
|
|
// next(). We resolve the promise on EITHER signal so this never
|
|
// hangs even if authenticate's contract changes.
|
|
await new Promise((resolve, reject) => {
|
|
// Wrap res.json so any direct write resolves the promise too.
|
|
const origJson = res.json.bind(res);
|
|
res.json = (...args) => {
|
|
const r = origJson(...args);
|
|
resolve();
|
|
return r;
|
|
};
|
|
try {
|
|
const ret = authenticate(req, res, (err) => (err ? reject(err) : resolve()));
|
|
if (ret && typeof ret.then === 'function') {
|
|
ret.then(() => resolve(), reject);
|
|
}
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
}).catch(() => {});
|
|
|
|
// If authenticate already responded (401), bail. headersSent is the
|
|
// standard way to detect that without coupling to the response shape.
|
|
if (res.headersSent) return;
|
|
|
|
if (!req.user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
|
});
|
|
}
|
|
|
|
if (visibility === 'internal') {
|
|
return next();
|
|
}
|
|
|
|
// PRIVATE — JWT + space membership.
|
|
// No DB row at all (matched via static prefix rule but flagged
|
|
// private) — refuse: prefix rules with private visibility have no
|
|
// space_id to gate on, so they can't satisfy the membership check.
|
|
if (!file) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { code: 'FORBIDDEN', message: 'No file metadata for this path' }
|
|
});
|
|
}
|
|
// No space_id on the file row (very old uploads) — refuse: closed by
|
|
// default, owners must re-upload under a column to share.
|
|
if (!file.space_id) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { code: 'FORBIDDEN', message: 'No space membership for this file' }
|
|
});
|
|
}
|
|
|
|
const space = await dbGet(
|
|
'SELECT id, type, owner_id, visibility, access_control FROM spaces WHERE id = ?',
|
|
[file.space_id]
|
|
);
|
|
if (!space) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: { code: 'SPACE_NOT_FOUND', message: 'Owning space no longer exists' }
|
|
});
|
|
}
|
|
|
|
const accessControl = safeJsonParse(space.access_control, null);
|
|
const hasAccess = await checkUserSpaceAccess(
|
|
req.user.id,
|
|
req.user.role,
|
|
space,
|
|
accessControl
|
|
);
|
|
|
|
if (!hasAccess) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
error: { code: 'FORBIDDEN', message: 'No access to this space' }
|
|
});
|
|
}
|
|
|
|
return next();
|
|
} catch (err) {
|
|
apiLogger.error({ err, path: req.path, mountPrefix }, '[fileGuard] error');
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: { code: 'FILE_GUARD_ERROR', message: 'File access check failed' }
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
// Pre-built guards for the two static mounts in server.js.
|
|
export const uploadsFileGuard = createFileGuard('/uploads');
|
|
export const downloadsFileGuard = createFileGuard('/downloads');
|
|
|
|
export { VALID_VISIBILITY };
|