fix(backend): harden runtime config and verification access
This commit is contained in:
@@ -5,10 +5,12 @@ import { requestContext } from './middleware/request-context.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { healthRouter } from './routes/health.js';
|
||||
import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js';
|
||||
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
|
||||
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
||||
|
||||
export function createApp() {
|
||||
assertSafeRuntimeConfig();
|
||||
const app = express();
|
||||
|
||||
app.use(requestContext);
|
||||
|
||||
45
backend/core-api/src/lib/runtime-safety.js
Normal file
45
backend/core-api/src/lib/runtime-safety.js
Normal file
@@ -0,0 +1,45 @@
|
||||
function runtimeEnvName() {
|
||||
return `${process.env.APP_ENV || process.env.NODE_ENV || ''}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isProtectedEnv() {
|
||||
return ['staging', 'prod', 'production'].includes(runtimeEnvName());
|
||||
}
|
||||
|
||||
export function assertSafeRuntimeConfig() {
|
||||
if (!isProtectedEnv()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (process.env.AUTH_BYPASS === 'true') {
|
||||
errors.push('AUTH_BYPASS must be disabled');
|
||||
}
|
||||
|
||||
if (process.env.UPLOAD_MOCK !== 'false') {
|
||||
errors.push('UPLOAD_MOCK must be false');
|
||||
}
|
||||
|
||||
if (process.env.SIGNED_URL_MOCK !== 'false') {
|
||||
errors.push('SIGNED_URL_MOCK must be false');
|
||||
}
|
||||
|
||||
if (process.env.LLM_MOCK !== 'false') {
|
||||
errors.push('LLM_MOCK must be false');
|
||||
}
|
||||
|
||||
const verificationStore = `${process.env.VERIFICATION_STORE || 'sql'}`.trim().toLowerCase();
|
||||
if (verificationStore !== 'sql') {
|
||||
errors.push('VERIFICATION_STORE must be sql');
|
||||
}
|
||||
|
||||
const verificationAccessMode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase();
|
||||
if (verificationAccessMode === 'authenticated') {
|
||||
errors.push('VERIFICATION_ACCESS_MODE must not be authenticated');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Unsafe core-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { isDatabaseConfigured, query, withTransaction } from './db.js';
|
||||
import { requireTenantContext } from './actor-context.js';
|
||||
import { loadActorContext, requireTenantContext } from './actor-context.js';
|
||||
import { invokeVertexMultimodalModel } from './llm.js';
|
||||
|
||||
export const VerificationStatus = Object.freeze({
|
||||
@@ -95,7 +95,11 @@ async function processVerificationJobInMemory(verificationId) {
|
||||
}
|
||||
|
||||
function accessMode() {
|
||||
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
|
||||
const mode = `${process.env.VERIFICATION_ACCESS_MODE || 'tenant'}`.trim().toLowerCase();
|
||||
if (mode === 'owner' || mode === 'tenant' || mode === 'authenticated') {
|
||||
return mode;
|
||||
}
|
||||
return 'tenant';
|
||||
}
|
||||
|
||||
function providerTimeoutMs() {
|
||||
@@ -156,12 +160,27 @@ function toPublicJob(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function assertAccess(row, actorUid) {
|
||||
if (accessMode() === 'authenticated') {
|
||||
async function assertAccess(row, actorUid) {
|
||||
if (row.owner_user_id === actorUid) {
|
||||
return;
|
||||
}
|
||||
if (row.owner_user_id !== actorUid) {
|
||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
|
||||
|
||||
const mode = accessMode();
|
||||
if (mode === 'authenticated') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'owner' || !row.tenant_id) {
|
||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, {
|
||||
verificationId: row.id,
|
||||
});
|
||||
}
|
||||
|
||||
const actorContext = await loadActorContext(actorUid);
|
||||
if (actorContext.tenant?.tenantId !== row.tenant_id) {
|
||||
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403, {
|
||||
verificationId: row.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,19 +633,19 @@ export async function createVerificationJob({ actorUid, payload }) {
|
||||
export async function getVerificationJob(verificationId, actorUid) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
const job = await loadJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
return toPublicJob(job);
|
||||
}
|
||||
|
||||
export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||
verificationId,
|
||||
@@ -668,7 +687,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||
}
|
||||
|
||||
const job = result.rows[0];
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
|
||||
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
|
||||
verificationId,
|
||||
@@ -735,7 +754,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
|
||||
export async function retryVerificationJob(verificationId, actorUid) {
|
||||
if (useMemoryStore()) {
|
||||
const job = loadMemoryJob(verificationId);
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
if (job.status === VerificationStatus.PROCESSING) {
|
||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||
verificationId,
|
||||
@@ -774,7 +793,7 @@ export async function retryVerificationJob(verificationId, actorUid) {
|
||||
}
|
||||
|
||||
const job = result.rows[0];
|
||||
assertAccess(job, actorUid);
|
||||
await assertAccess(job, actorUid);
|
||||
if (job.status === VerificationStatus.PROCESSING) {
|
||||
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
|
||||
verificationId,
|
||||
|
||||
@@ -3,7 +3,11 @@ import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
|
||||
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
|
||||
import {
|
||||
__resetVerificationJobsForTests,
|
||||
createVerificationJob,
|
||||
getVerificationJob,
|
||||
} from '../src/services/verification-jobs.js';
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
@@ -13,7 +17,7 @@ beforeEach(async () => {
|
||||
process.env.MAX_SIGNED_URL_SECONDS = '900';
|
||||
process.env.LLM_RATE_LIMIT_PER_MINUTE = '20';
|
||||
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
|
||||
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
|
||||
process.env.VERIFICATION_ACCESS_MODE = 'tenant';
|
||||
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
|
||||
process.env.VERIFICATION_STORE = 'memory';
|
||||
__resetLlmRateLimitForTests();
|
||||
@@ -66,6 +70,16 @@ test('GET /readyz reports database not configured when env is absent', async ()
|
||||
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
|
||||
});
|
||||
|
||||
test('createApp fails fast in protected env when unsafe core flags are enabled', async () => {
|
||||
process.env.APP_ENV = 'staging';
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
|
||||
|
||||
delete process.env.APP_ENV;
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
});
|
||||
|
||||
test('POST /core/create-signed-url requires auth', async () => {
|
||||
process.env.AUTH_BYPASS = 'false';
|
||||
const app = createApp();
|
||||
@@ -404,3 +418,24 @@ test('POST /core/verifications/:id/retry requeues verification', async () => {
|
||||
assert.equal(retried.status, 202);
|
||||
assert.equal(retried.body.status, 'PENDING');
|
||||
});
|
||||
|
||||
test('verification access is denied to a different actor by default', async () => {
|
||||
const created = await createVerificationJob({
|
||||
actorUid: 'owner-user',
|
||||
payload: {
|
||||
type: 'attire',
|
||||
subjectType: 'staff',
|
||||
subjectId: 'staff_1',
|
||||
fileUri: 'gs://krow-workforce-dev-private/uploads/owner-user/attire.jpg',
|
||||
rules: { attireType: 'shoes' },
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => getVerificationJob(created.verificationId, 'foreign-user'),
|
||||
(error) => {
|
||||
assert.equal(error.code, 'FORBIDDEN');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user