Merge pull request #667 from Oloodi/codex/feat-m5-backend-hardening-sweep

fix(backend): harden runtime config and verification access
This commit is contained in:
Wielfried Zouantcha
2026-03-19 12:23:08 -04:00
committed by GitHub
27 changed files with 1005 additions and 44 deletions

View File

@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { createCommandsRouter } from './routes/commands.js'; import { createCommandsRouter } from './routes/commands.js';
import { createMobileCommandsRouter } from './routes/mobile.js'; import { createMobileCommandsRouter } from './routes/mobile.js';
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) { export function createApp(options = {}) {
assertSafeRuntimeConfig();
const app = express(); const app = express();
app.use(requestContext); app.use(requestContext);

View File

@@ -0,0 +1,44 @@
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.IDEMPOTENCY_STORE || ''}`.trim().toLowerCase() === 'memory') {
errors.push('IDEMPOTENCY_STORE must not be memory');
}
if (errors.length > 0) {
throw new Error(`Unsafe command-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
}
}
export function assertSafeWorkerRuntimeConfig() {
if (!isProtectedEnv()) {
return;
}
const errors = [];
const deliveryMode = `${process.env.PUSH_DELIVERY_MODE || 'live'}`.trim().toLowerCase();
if (deliveryMode !== 'live') {
errors.push('PUSH_DELIVERY_MODE must be live');
}
if (errors.length > 0) {
throw new Error(`Unsafe notification-worker runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
}
}

View File

@@ -9,6 +9,30 @@ function getBearerToken(header) {
return token; return token;
} }
function buildBypassActor() {
let policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
business: { businessId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
if (process.env.AUTH_BYPASS_CONTEXT) {
try {
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
} catch (_error) {
policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
business: { businessId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
}
}
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
}
export async function requireAuth(req, _res, next) { export async function requireAuth(req, _res, next) {
try { try {
const token = getBearerToken(req.get('Authorization')); const token = getBearerToken(req.get('Authorization'));
@@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) {
} }
if (process.env.AUTH_BYPASS === 'true') { if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; req.actor = buildBypassActor();
return next(); return next();
} }
@@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) {
} }
export function requirePolicy(action, resource) { export function requirePolicy(action, resource) {
return (req, _res, next) => { return async (req, _res, next) => {
if (!can(action, resource, req.actor)) { try {
if (!(await can(action, resource, req.actor, req))) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
} }
return next(); return next();
} catch (error) {
return next(error);
}
}; };
} }

View File

@@ -4,6 +4,10 @@ import { recordGeofenceIncident } from './attendance-monitoring.js';
import { recordAttendanceSecurityProof } from './attendance-security.js'; import { recordAttendanceSecurityProof } from './attendance-security.js';
import { evaluateClockInAttempt } from './clock-in-policy.js'; import { evaluateClockInAttempt } from './clock-in-policy.js';
import { enqueueHubManagerAlert } from './notification-outbox.js'; import { enqueueHubManagerAlert } from './notification-outbox.js';
import {
requireClientContext as requireActorClientContext,
requireStaffContext as requireActorStaffContext,
} from './actor-context.js';
function toIsoOrNull(value) { function toIsoOrNull(value) {
return value ? new Date(value).toISOString() : null; return value ? new Date(value).toISOString() : null;
@@ -68,6 +72,33 @@ async function ensureStaffNotBlockedByBusiness(client, { tenantId, businessId, s
} }
} }
function assertTenantScope(context, tenantId) {
if (context.tenant.tenantId !== tenantId) {
throw new AppError('FORBIDDEN', 'Resource is outside actor tenant scope', 403, {
tenantId,
actorTenantId: context.tenant.tenantId,
});
}
}
function assertBusinessScope(context, businessId) {
if (context.business && context.business.businessId !== businessId) {
throw new AppError('FORBIDDEN', 'Resource is outside actor business scope', 403, {
businessId,
actorBusinessId: context.business.businessId,
});
}
}
function assertStaffScope(context, staffId) {
if (context.staff.staffId !== staffId) {
throw new AppError('FORBIDDEN', 'Resource is outside actor staff scope', 403, {
staffId,
actorStaffId: context.staff.staffId,
});
}
}
async function insertDomainEvent(client, { async function insertDomainEvent(client, {
tenantId, tenantId,
aggregateType, aggregateType,
@@ -451,6 +482,9 @@ function buildOrderUpdateStatement(payload) {
export async function createOrder(actor, payload) { export async function createOrder(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
assertBusinessScope(actorContext, payload.businessId);
await requireBusiness(client, payload.tenantId, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId);
if (payload.vendorId) { if (payload.vendorId) {
await requireVendor(client, payload.tenantId, payload.vendorId); await requireVendor(client, payload.tenantId, payload.vendorId);
@@ -620,8 +654,10 @@ export async function createOrder(actor, payload) {
export async function acceptShift(actor, payload) { export async function acceptShift(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorStaffContext(actor.uid);
const shiftRole = await requireShiftRole(client, payload.shiftRoleId); const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
assertTenantScope(actorContext, shiftRole.tenant_id);
if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) { if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) {
throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, { throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, {
shiftId: payload.shiftId, shiftId: payload.shiftId,
@@ -629,6 +665,13 @@ export async function acceptShift(actor, payload) {
}); });
} }
if (!actorContext.staff.workforceId || actorContext.staff.workforceId !== payload.workforceId) {
throw new AppError('FORBIDDEN', 'Staff can only accept shifts for their own workforce record', 403, {
workforceId: payload.workforceId,
actorWorkforceId: actorContext.staff.workforceId || null,
});
}
if (shiftRole.assigned_count >= shiftRole.workers_needed) { if (shiftRole.assigned_count >= shiftRole.workers_needed) {
const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce( const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce(
client, client,
@@ -736,7 +779,10 @@ export async function acceptShift(actor, payload) {
export async function updateOrder(actor, payload) { export async function updateOrder(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId); const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId);
assertBusinessScope(actorContext, existingOrder.business_id);
if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) { if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) {
await requireVendor(client, payload.tenantId, payload.vendorId); await requireVendor(client, payload.tenantId, payload.vendorId);
@@ -787,7 +833,10 @@ export async function updateOrder(actor, payload) {
export async function cancelOrder(actor, payload) { export async function cancelOrder(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
const order = await requireOrder(client, payload.tenantId, payload.orderId); const order = await requireOrder(client, payload.tenantId, payload.orderId);
assertBusinessScope(actorContext, order.business_id);
if (order.status === 'CANCELLED') { if (order.status === 'CANCELLED') {
return { return {
@@ -910,7 +959,10 @@ export async function cancelOrder(actor, payload) {
export async function changeShiftStatus(actor, payload) { export async function changeShiftStatus(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
const shift = await requireShift(client, payload.tenantId, payload.shiftId); const shift = await requireShift(client, payload.tenantId, payload.shiftId);
assertBusinessScope(actorContext, shift.business_id);
if (payload.status === 'COMPLETED') { if (payload.status === 'COMPLETED') {
const openSession = await client.query( const openSession = await client.query(
@@ -999,7 +1051,10 @@ export async function changeShiftStatus(actor, payload) {
export async function assignStaffToShift(actor, payload) { export async function assignStaffToShift(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
const shift = await requireShift(client, payload.tenantId, payload.shiftId); const shift = await requireShift(client, payload.tenantId, payload.shiftId);
assertBusinessScope(actorContext, shift.business_id);
const shiftRole = await requireShiftRole(client, payload.shiftRoleId); const shiftRole = await requireShiftRole(client, payload.shiftRoleId);
if (shiftRole.shift_id !== shift.id) { if (shiftRole.shift_id !== shift.id) {
@@ -1120,7 +1175,10 @@ export async function assignStaffToShift(actor, payload) {
async function createAttendanceEvent(actor, payload, eventType) { async function createAttendanceEvent(actor, payload, eventType) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorStaffContext(actor.uid);
const assignment = await requireAssignment(client, payload.assignmentId); const assignment = await requireAssignment(client, payload.assignmentId);
assertTenantScope(actorContext, assignment.tenant_id);
assertStaffScope(actorContext, assignment.staff_id);
const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString(); const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString();
let securityProof = null; let securityProof = null;
@@ -1553,6 +1611,9 @@ export async function clockOut(actor, payload) {
export async function addFavoriteStaff(actor, payload) { export async function addFavoriteStaff(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
assertBusinessScope(actorContext, payload.businessId);
await requireBusiness(client, payload.tenantId, payload.businessId); await requireBusiness(client, payload.tenantId, payload.businessId);
const staffResult = await client.query( const staffResult = await client.query(
@@ -1605,6 +1666,9 @@ export async function addFavoriteStaff(actor, payload) {
export async function removeFavoriteStaff(actor, payload) { export async function removeFavoriteStaff(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
assertBusinessScope(actorContext, payload.businessId);
const deleted = await client.query( const deleted = await client.query(
` `
DELETE FROM staff_favorites DELETE FROM staff_favorites
@@ -1640,7 +1704,11 @@ export async function removeFavoriteStaff(actor, payload) {
export async function createStaffReview(actor, payload) { export async function createStaffReview(actor, payload) {
return withTransaction(async (client) => { return withTransaction(async (client) => {
await ensureActorUser(client, actor); await ensureActorUser(client, actor);
const actorContext = await requireActorClientContext(actor.uid);
assertTenantScope(actorContext, payload.tenantId);
assertBusinessScope(actorContext, payload.businessId);
const assignment = await requireAssignment(client, payload.assignmentId); const assignment = await requireAssignment(client, payload.assignmentId);
assertBusinessScope(actorContext, assignment.business_id);
if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) { if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) {
throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, { throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, {
assignmentId: payload.assignmentId, assignmentId: payload.assignmentId,

View File

@@ -1,5 +1,125 @@
export function can(action, resource, actor) { import { loadActorContext } from './actor-context.js';
void action;
void resource; const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
return Boolean(actor?.uid);
function normalize(value) {
return `${value || ''}`.trim();
}
function requestField(req, field) {
return normalize(
req?.params?.[field]
?? req?.body?.[field]
?? req?.query?.[field]
);
}
function isTenantAdmin(context) {
return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase());
}
function hasTenantScope(context) {
return Boolean(context?.user && context?.tenant);
}
function hasClientScope(context) {
return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context));
}
function hasStaffScope(context) {
return hasTenantScope(context) && Boolean(context?.staff);
}
function requiredScopeFor(action) {
if (action === 'notifications.device.write') {
return 'tenant';
}
if (
action === 'orders.create'
|| action === 'orders.update'
|| action === 'orders.cancel'
|| action === 'shifts.change-status'
|| action === 'shifts.assign-staff'
|| action === 'business.favorite-staff'
|| action === 'business.unfavorite-staff'
|| action === 'assignments.review-staff'
|| action.startsWith('client.')
|| action.startsWith('billing.')
|| action.startsWith('coverage.')
|| action.startsWith('hubs.')
|| action.startsWith('vendors.')
|| action.startsWith('reports.')
) {
return 'client';
}
if (
action === 'shifts.accept'
|| action === 'attendance.clock-in'
|| action === 'attendance.clock-out'
|| action === 'attendance.location-stream.write'
|| action.startsWith('staff.')
|| action.startsWith('payments.')
) {
return 'staff';
}
return 'deny';
}
async function resolveActorContext(actor) {
if (!actor?.uid) {
return null;
}
if (actor.policyContext) {
return actor.policyContext;
}
const context = await loadActorContext(actor.uid);
actor.policyContext = context;
return context;
}
function requestScopeMatches(req, context, requiredScope) {
const tenantId = requestField(req, 'tenantId');
if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) {
return false;
}
const businessId = requestField(req, 'businessId');
if (
requiredScope === 'client'
&& businessId
&& context?.business?.businessId
&& context.business.businessId !== '*'
&& context.business.businessId !== businessId
) {
return false;
}
return true;
}
export async function can(action, resource, actor, req) {
void resource;
const context = await resolveActorContext(actor);
const requiredScope = requiredScopeFor(action);
if (requiredScope === 'deny' || !context?.user) {
return false;
}
if (requiredScope === 'tenant') {
return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope);
}
if (requiredScope === 'client') {
return hasClientScope(context) && requestScopeMatches(req, context, requiredScope);
}
if (requiredScope === 'staff') {
return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope);
}
return false;
} }

View File

@@ -1,10 +1,12 @@
import express from 'express'; import express from 'express';
import pino from 'pino'; import pino from 'pino';
import pinoHttp from 'pino-http'; import pinoHttp from 'pino-http';
import { assertSafeWorkerRuntimeConfig } from './lib/runtime-safety.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createWorkerApp({ dispatch = async () => ({}) } = {}) { export function createWorkerApp({ dispatch = async () => ({}) } = {}) {
assertSafeWorkerRuntimeConfig();
const app = express(); const app = express();
app.use( app.use(

View File

@@ -40,6 +40,7 @@ function validOrderCreatePayload() {
beforeEach(() => { beforeEach(() => {
process.env.IDEMPOTENCY_STORE = 'memory'; process.env.IDEMPOTENCY_STORE = 'memory';
delete process.env.AUTH_BYPASS_CONTEXT;
delete process.env.IDEMPOTENCY_DATABASE_URL; delete process.env.IDEMPOTENCY_DATABASE_URL;
delete process.env.DATABASE_URL; delete process.env.DATABASE_URL;
__resetIdempotencyStoreForTests(); __resetIdempotencyStoreForTests();
@@ -63,6 +64,16 @@ test('GET /readyz reports database not configured when no database env is presen
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
}); });
test('createApp fails fast in protected env when auth bypass is 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('command route requires idempotency key', async () => { test('command route requires idempotency key', async () => {
const app = createApp(); const app = createApp();
const res = await request(app) const res = await request(app)
@@ -116,3 +127,36 @@ test('command route is idempotent by key and only executes handler once', async
assert.equal(first.body.idempotencyKey, 'abc-123'); assert.equal(first.body.idempotencyKey, 'abc-123');
assert.equal(second.body.idempotencyKey, 'abc-123'); assert.equal(second.body.idempotencyKey, 'abc-123');
}); });
test('client command routes deny mismatched business scope before handler execution', async () => {
process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({
user: { userId: 'test-user' },
tenant: { tenantId, role: 'MANAGER' },
business: { businessId: '99999999-9999-4999-8999-999999999999' },
});
const app = createApp({
commandHandlers: {
createOrder: async () => assert.fail('createOrder should not be called'),
acceptShift: async () => assert.fail('acceptShift should not be called'),
clockIn: async () => assert.fail('clockIn should not be called'),
clockOut: async () => assert.fail('clockOut should not be called'),
addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'),
removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'),
createStaffReview: async () => assert.fail('createStaffReview should not be called'),
updateOrder: async () => assert.fail('updateOrder should not be called'),
cancelOrder: async () => assert.fail('cancelOrder should not be called'),
changeShiftStatus: async () => assert.fail('changeShiftStatus should not be called'),
assignStaffToShift: async () => assert.fail('assignStaffToShift should not be called'),
},
});
const res = await request(app)
.post('/commands/orders/create')
.set('Authorization', 'Bearer test-token')
.set('Idempotency-Key', 'scope-mismatch')
.send(validOrderCreatePayload());
assert.equal(res.status, 403);
assert.equal(res.body.code, 'FORBIDDEN');
});

View File

@@ -12,6 +12,16 @@ test('GET /readyz returns healthy response', async () => {
assert.equal(res.body.service, 'notification-worker-v2'); assert.equal(res.body.service, 'notification-worker-v2');
}); });
test('createWorkerApp fails fast in protected env when push delivery is not live', async () => {
process.env.APP_ENV = 'staging';
process.env.PUSH_DELIVERY_MODE = 'log-only';
assert.throws(() => createWorkerApp(), /PUSH_DELIVERY_MODE must be live/);
delete process.env.APP_ENV;
delete process.env.PUSH_DELIVERY_MODE;
});
test('POST /tasks/dispatch-notifications returns dispatch summary', async () => { test('POST /tasks/dispatch-notifications returns dispatch summary', async () => {
const app = createWorkerApp({ const app = createWorkerApp({
dispatch: async () => ({ dispatch: async () => ({

View File

@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { can } from '../src/services/policy.js';
test('client actions require business scope and matching business id', async () => {
const allowed = await can(
'orders.create',
'order',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
business: { businessId: 'business-1' },
},
},
{ body: { tenantId: 'tenant-1', businessId: 'business-1' } }
);
const denied = await can(
'orders.create',
'order',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
business: { businessId: 'business-1' },
},
},
{ body: { tenantId: 'tenant-1', businessId: 'business-2' } }
);
assert.equal(allowed, true);
assert.equal(denied, false);
});
test('staff actions require staff scope', async () => {
const allowed = await can(
'shifts.accept',
'shift',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
staff: { staffId: 'staff-1', workforceId: 'workforce-1' },
},
},
{ body: { tenantId: 'tenant-1' } }
);
const denied = await can(
'shifts.accept',
'shift',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
business: { businessId: 'business-1' },
},
},
{ body: { tenantId: 'tenant-1' } }
);
assert.equal(allowed, true);
assert.equal(denied, false);
});
test('notifications.device.write allows tenant-scoped actor', async () => {
const allowed = await can(
'notifications.device.write',
'device',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
},
},
{ body: { tenantId: 'tenant-1' } }
);
assert.equal(allowed, true);
});

View File

@@ -5,10 +5,12 @@ import { requestContext } from './middleware/request-context.js';
import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js'; import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js';
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp() { export function createApp() {
assertSafeRuntimeConfig();
const app = express(); const app = express();
app.use(requestContext); app.use(requestContext);

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

View File

@@ -9,6 +9,28 @@ function getBearerToken(header) {
return token; return token;
} }
function buildBypassActor() {
let policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
if (process.env.AUTH_BYPASS_CONTEXT) {
try {
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
} catch (_error) {
policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
}
}
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
}
export async function requireAuth(req, _res, next) { export async function requireAuth(req, _res, next) {
try { try {
const token = getBearerToken(req.get('Authorization')); const token = getBearerToken(req.get('Authorization'));
@@ -17,7 +39,7 @@ export async function requireAuth(req, _res, next) {
} }
if (process.env.AUTH_BYPASS === 'true') { if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; req.actor = buildBypassActor();
return next(); return next();
} }
@@ -36,10 +58,14 @@ export async function requireAuth(req, _res, next) {
} }
export function requirePolicy(action, resource) { export function requirePolicy(action, resource) {
return (req, _res, next) => { return async (req, _res, next) => {
if (!can(action, resource, req.actor)) { try {
if (!(await can(action, resource, req.actor, req))) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
} }
return next(); return next();
} catch (error) {
return next(error);
}
}; };
} }

View File

@@ -1,5 +1,46 @@
export function can(action, resource, actor) { import { loadActorContext } from './actor-context.js';
void action;
void resource; function normalize(value) {
return Boolean(actor?.uid); return `${value || ''}`.trim();
}
function requestField(req, field) {
return normalize(
req?.params?.[field]
?? req?.body?.[field]
?? req?.query?.[field]
);
}
async function resolveActorContext(actor) {
if (!actor?.uid) {
return null;
}
if (actor.policyContext) {
return actor.policyContext;
}
const context = await loadActorContext(actor.uid);
actor.policyContext = context;
return context;
}
export async function can(action, resource, actor, req) {
void resource;
if (!action.startsWith('core.')) {
return false;
}
const context = await resolveActorContext(actor);
if (!context?.user || !context?.tenant) {
return false;
}
const tenantId = requestField(req, 'tenantId');
if (!tenantId) {
return true;
}
if (context.tenant.tenantId === '*') {
return true;
}
return context.tenant.tenantId === tenantId;
} }

View File

@@ -1,6 +1,6 @@
import { AppError } from '../lib/errors.js'; import { AppError } from '../lib/errors.js';
import { isDatabaseConfigured, query, withTransaction } from './db.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'; import { invokeVertexMultimodalModel } from './llm.js';
export const VerificationStatus = Object.freeze({ export const VerificationStatus = Object.freeze({
@@ -95,7 +95,11 @@ async function processVerificationJobInMemory(verificationId) {
} }
function accessMode() { 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() { function providerTimeoutMs() {
@@ -156,12 +160,27 @@ function toPublicJob(row) {
}; };
} }
function assertAccess(row, actorUid) { async function assertAccess(row, actorUid) {
if (accessMode() === 'authenticated') { if (row.owner_user_id === actorUid) {
return; 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) { export async function getVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) { if (useMemoryStore()) {
const job = loadMemoryJob(verificationId); const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid); await assertAccess(job, actorUid);
return toPublicJob(job); return toPublicJob(job);
} }
const job = await loadJob(verificationId); const job = await loadJob(verificationId);
assertAccess(job, actorUid); await assertAccess(job, actorUid);
return toPublicJob(job); return toPublicJob(job);
} }
export async function reviewVerificationJob(verificationId, actorUid, review) { export async function reviewVerificationJob(verificationId, actorUid, review) {
if (useMemoryStore()) { if (useMemoryStore()) {
const job = loadMemoryJob(verificationId); const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid); await assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) { if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, { throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId, verificationId,
@@ -668,7 +687,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
} }
const job = result.rows[0]; const job = result.rows[0];
assertAccess(job, actorUid); await assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) { if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, { throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId, verificationId,
@@ -735,7 +754,7 @@ export async function reviewVerificationJob(verificationId, actorUid, review) {
export async function retryVerificationJob(verificationId, actorUid) { export async function retryVerificationJob(verificationId, actorUid) {
if (useMemoryStore()) { if (useMemoryStore()) {
const job = loadMemoryJob(verificationId); const job = loadMemoryJob(verificationId);
assertAccess(job, actorUid); await assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) { if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId, verificationId,
@@ -774,7 +793,7 @@ export async function retryVerificationJob(verificationId, actorUid) {
} }
const job = result.rows[0]; const job = result.rows[0];
assertAccess(job, actorUid); await assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) { if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId, verificationId,

View File

@@ -3,7 +3,11 @@ import assert from 'node:assert/strict';
import request from 'supertest'; import request from 'supertest';
import { createApp } from '../src/app.js'; import { createApp } from '../src/app.js';
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.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 () => { beforeEach(async () => {
process.env.AUTH_BYPASS = 'true'; process.env.AUTH_BYPASS = 'true';
@@ -13,7 +17,7 @@ beforeEach(async () => {
process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.MAX_SIGNED_URL_SECONDS = '900';
process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20';
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; 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_ATTIRE_PROVIDER = 'mock';
process.env.VERIFICATION_STORE = 'memory'; process.env.VERIFICATION_STORE = 'memory';
__resetLlmRateLimitForTests(); __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'); 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 () => { test('POST /core/create-signed-url requires auth', async () => {
process.env.AUTH_BYPASS = 'false'; process.env.AUTH_BYPASS = 'false';
const app = createApp(); const app = createApp();
@@ -404,3 +418,24 @@ test('POST /core/verifications/:id/retry requeues verification', async () => {
assert.equal(retried.status, 202); assert.equal(retried.status, 202);
assert.equal(retried.body.status, 'PENDING'); 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;
}
);
});

View File

@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { can } from '../src/services/policy.js';
test('core actions require tenant scope', async () => {
const allowed = await can(
'core.verification.read',
'verification',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
},
},
{}
);
const denied = await can(
'core.verification.read',
'verification',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
},
},
{}
);
assert.equal(allowed, true);
assert.equal(denied, false);
});

View File

@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { createQueryRouter } from './routes/query.js'; import { createQueryRouter } from './routes/query.js';
import { createMobileQueryRouter } from './routes/mobile.js'; import { createMobileQueryRouter } from './routes/mobile.js';
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) { export function createApp(options = {}) {
assertSafeRuntimeConfig();
const app = express(); const app = express();
app.use(requestContext); app.use(requestContext);

View File

@@ -0,0 +1,17 @@
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;
}
if (process.env.AUTH_BYPASS === 'true') {
throw new Error(`Unsafe query-api runtime config for ${runtimeEnvName()}: AUTH_BYPASS must be disabled`);
}
}

View File

@@ -9,6 +9,30 @@ function getBearerToken(header) {
return token; return token;
} }
function buildBypassActor() {
let policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
business: { businessId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
if (process.env.AUTH_BYPASS_CONTEXT) {
try {
policyContext = JSON.parse(process.env.AUTH_BYPASS_CONTEXT);
} catch (_error) {
policyContext = {
user: { userId: 'test-user' },
tenant: { tenantId: '*' },
business: { businessId: '*' },
staff: { staffId: '*', workforceId: '*' },
};
}
}
return { uid: 'test-user', email: 'test@krow.local', role: 'TEST', policyContext };
}
export async function requireAuth(req, _res, next) { export async function requireAuth(req, _res, next) {
try { try {
const token = getBearerToken(req.get('Authorization')); const token = getBearerToken(req.get('Authorization'));
@@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) {
} }
if (process.env.AUTH_BYPASS === 'true') { if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; req.actor = buildBypassActor();
return next(); return next();
} }
@@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) {
} }
export function requirePolicy(action, resource) { export function requirePolicy(action, resource) {
return (req, _res, next) => { return async (req, _res, next) => {
if (!can(action, resource, req.actor)) { try {
if (!(await can(action, resource, req.actor, req))) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
} }
return next(); return next();
} catch (error) {
return next(error);
}
}; };
} }

View File

@@ -27,6 +27,11 @@ function requireUuid(value, field) {
export function createQueryRouter(queryService = defaultQueryService) { export function createQueryRouter(queryService = defaultQueryService) {
const router = Router(); const router = Router();
function actorBusinessId(actor) {
const businessId = actor?.policyContext?.business?.businessId;
return businessId && businessId !== '*' ? businessId : null;
}
router.get( router.get(
'/tenants/:tenantId/orders', '/tenants/:tenantId/orders',
requireAuth, requireAuth,
@@ -34,9 +39,10 @@ export function createQueryRouter(queryService = defaultQueryService) {
async (req, res, next) => { async (req, res, next) => {
try { try {
const tenantId = requireUuid(req.params.tenantId, 'tenantId'); const tenantId = requireUuid(req.params.tenantId, 'tenantId');
const scopedBusinessId = actorBusinessId(req.actor);
const orders = await queryService.listOrders({ const orders = await queryService.listOrders({
tenantId, tenantId,
businessId: req.query.businessId, businessId: scopedBusinessId || req.query.businessId,
status: req.query.status, status: req.query.status,
limit: req.query.limit, limit: req.query.limit,
offset: req.query.offset, offset: req.query.offset,
@@ -57,10 +63,16 @@ export function createQueryRouter(queryService = defaultQueryService) {
requirePolicy('orders.read', 'order'), requirePolicy('orders.read', 'order'),
async (req, res, next) => { async (req, res, next) => {
try { try {
const scopedBusinessId = actorBusinessId(req.actor);
const order = await queryService.getOrderDetail({ const order = await queryService.getOrderDetail({
tenantId: requireUuid(req.params.tenantId, 'tenantId'), tenantId: requireUuid(req.params.tenantId, 'tenantId'),
orderId: requireUuid(req.params.orderId, 'orderId'), orderId: requireUuid(req.params.orderId, 'orderId'),
}); });
if (scopedBusinessId && order.businessId !== scopedBusinessId) {
throw new AppError('FORBIDDEN', 'Order is outside actor business scope', 403, {
orderId: req.params.orderId,
});
}
return res.status(200).json({ return res.status(200).json({
...order, ...order,
requestId: req.requestId, requestId: req.requestId,
@@ -77,9 +89,10 @@ export function createQueryRouter(queryService = defaultQueryService) {
requirePolicy('business.favorite-staff.read', 'staff'), requirePolicy('business.favorite-staff.read', 'staff'),
async (req, res, next) => { async (req, res, next) => {
try { try {
const scopedBusinessId = actorBusinessId(req.actor);
const items = await queryService.listFavoriteStaff({ const items = await queryService.listFavoriteStaff({
tenantId: requireUuid(req.params.tenantId, 'tenantId'), tenantId: requireUuid(req.params.tenantId, 'tenantId'),
businessId: requireUuid(req.params.businessId, 'businessId'), businessId: requireUuid(scopedBusinessId || req.params.businessId, 'businessId'),
limit: req.query.limit, limit: req.query.limit,
offset: req.query.offset, offset: req.query.offset,
}); });
@@ -120,12 +133,19 @@ export function createQueryRouter(queryService = defaultQueryService) {
requirePolicy('attendance.read', 'attendance'), requirePolicy('attendance.read', 'attendance'),
async (req, res, next) => { async (req, res, next) => {
try { try {
const scopedBusinessId = actorBusinessId(req.actor);
const attendance = await queryService.getAssignmentAttendance({ const attendance = await queryService.getAssignmentAttendance({
tenantId: requireUuid(req.params.tenantId, 'tenantId'), tenantId: requireUuid(req.params.tenantId, 'tenantId'),
assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'), assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'),
}); });
if (scopedBusinessId && attendance.businessId !== scopedBusinessId) {
throw new AppError('FORBIDDEN', 'Assignment attendance is outside actor business scope', 403, {
assignmentId: req.params.assignmentId,
});
}
const { businessId: _businessId, ...publicAttendance } = attendance;
return res.status(200).json({ return res.status(200).json({
...attendance, ...publicAttendance,
requestId: req.requestId, requestId: req.requestId,
}); });
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,118 @@
export function can(action, resource, actor) { import { loadActorContext } from './actor-context.js';
void action;
void resource; const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
return Boolean(actor?.uid);
function normalize(value) {
return `${value || ''}`.trim();
}
function requestField(req, field) {
return normalize(
req?.params?.[field]
?? req?.body?.[field]
?? req?.query?.[field]
);
}
function isTenantAdmin(context) {
return TENANT_ADMIN_ROLES.has(normalize(context?.tenant?.role).toUpperCase());
}
function hasTenantScope(context) {
return Boolean(context?.user && context?.tenant);
}
function hasClientScope(context) {
return hasTenantScope(context) && Boolean(context?.business || isTenantAdmin(context));
}
function hasStaffScope(context) {
return hasTenantScope(context) && Boolean(context?.staff);
}
function requiredScopeFor(action) {
if (action === 'attendance.read') {
return 'tenant';
}
if (
action === 'orders.read'
|| action === 'orders.reorder.read'
|| action === 'business.favorite-staff.read'
|| action === 'staff.reviews.read'
|| action.startsWith('client.')
|| action.startsWith('billing.')
|| action.startsWith('coverage.')
|| action.startsWith('hubs.')
|| action.startsWith('vendors.')
|| action.startsWith('reports.')
) {
return 'client';
}
if (
action === 'shifts.read'
|| action.startsWith('staff.')
|| action.startsWith('payments.')
) {
return 'staff';
}
return 'deny';
}
async function resolveActorContext(actor) {
if (!actor?.uid) {
return null;
}
if (actor.policyContext) {
return actor.policyContext;
}
const context = await loadActorContext(actor.uid);
actor.policyContext = context;
return context;
}
function requestScopeMatches(req, context, requiredScope) {
const tenantId = requestField(req, 'tenantId');
if (tenantId && context?.tenant?.tenantId !== '*' && context?.tenant?.tenantId !== tenantId) {
return false;
}
const businessId = requestField(req, 'businessId');
if (
requiredScope === 'client'
&& businessId
&& context?.business?.businessId
&& context.business.businessId !== '*'
&& context.business.businessId !== businessId
) {
return false;
}
return true;
}
export async function can(action, resource, actor, req) {
void resource;
const context = await resolveActorContext(actor);
const requiredScope = requiredScopeFor(action);
if (requiredScope === 'deny' || !context?.user) {
return false;
}
if (requiredScope === 'tenant') {
return hasTenantScope(context) && requestScopeMatches(req, context, requiredScope);
}
if (requiredScope === 'client') {
return hasClientScope(context) && requestScopeMatches(req, context, requiredScope);
}
if (requiredScope === 'staff') {
return hasStaffScope(context) && requestScopeMatches(req, context, requiredScope);
}
return false;
} }

View File

@@ -233,6 +233,7 @@ export async function getAssignmentAttendance({ tenantId, assignmentId }) {
SELECT SELECT
a.id AS "assignmentId", a.id AS "assignmentId",
a.status, a.status,
a.business_id AS "businessId",
a.shift_id AS "shiftId", a.shift_id AS "shiftId",
a.staff_id AS "staffId", a.staff_id AS "staffId",
s.title AS "shiftTitle", s.title AS "shiftTitle",

View File

@@ -37,6 +37,20 @@ test('GET /readyz reports database not configured when no database env is presen
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
}); });
test.afterEach(() => {
delete process.env.AUTH_BYPASS_CONTEXT;
});
test('createApp fails fast in protected env when auth bypass is 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('GET unknown route returns not found envelope', async () => { test('GET unknown route returns not found envelope', async () => {
const app = createApp(); const app = createApp();
const res = await request(app).get('/query/unknown'); const res = await request(app).get('/query/unknown');
@@ -124,3 +138,28 @@ test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validat
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(res.body.items[0].staffId, staffId); assert.equal(res.body.items[0].staffId, staffId);
}); });
test('GET /query/tenants/:tenantId/orders denies mismatched tenant scope before handler execution', async () => {
process.env.AUTH_BYPASS_CONTEXT = JSON.stringify({
user: { userId: 'test-user' },
tenant: { tenantId: '99999999-9999-4999-8999-999999999999', role: 'MANAGER' },
business: { businessId },
});
const app = createApp({
queryService: {
listOrders: async () => assert.fail('listOrders should not be called'),
getOrderDetail: async () => assert.fail('getOrderDetail should not be called'),
listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'),
getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'),
getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'),
},
});
const res = await request(app)
.get(`/query/tenants/${tenantId}/orders`)
.set('Authorization', 'Bearer test-token');
assert.equal(res.status, 403);
assert.equal(res.body.code, 'FORBIDDEN');
});

View File

@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { can } from '../src/services/policy.js';
test('orders.read requires client scope and matching tenant/business scope', async () => {
const allowed = await can(
'orders.read',
'order',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
business: { businessId: 'business-1' },
},
},
{ params: { tenantId: 'tenant-1' }, query: { businessId: 'business-1' } }
);
const denied = await can(
'orders.read',
'order',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1', role: 'MANAGER' },
business: { businessId: 'business-1' },
},
},
{ params: { tenantId: 'tenant-2' }, query: { businessId: 'business-1' } }
);
assert.equal(allowed, true);
assert.equal(denied, false);
});
test('shifts.read requires staff scope', async () => {
const allowed = await can(
'shifts.read',
'shift',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
staff: { staffId: 'staff-1' },
},
},
{ params: {} }
);
const denied = await can(
'shifts.read',
'shift',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
business: { businessId: 'business-1' },
},
},
{ params: {} }
);
assert.equal(allowed, true);
assert.equal(denied, false);
});
test('attendance.read allows tenant-scoped actor', async () => {
const allowed = await can(
'attendance.read',
'attendance',
{
uid: 'user-1',
policyContext: {
user: { userId: 'user-1' },
tenant: { tenantId: 'tenant-1' },
},
},
{ params: { tenantId: 'tenant-1' } }
);
assert.equal(allowed, true);
});

View File

@@ -6,10 +6,12 @@ import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
import { healthRouter } from './routes/health.js'; import { healthRouter } from './routes/health.js';
import { createAuthRouter } from './routes/auth.js'; import { createAuthRouter } from './routes/auth.js';
import { createProxyRouter } from './routes/proxy.js'; import { createProxyRouter } from './routes/proxy.js';
import { assertSafeRuntimeConfig } from './lib/runtime-safety.js';
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
export function createApp(options = {}) { export function createApp(options = {}) {
assertSafeRuntimeConfig();
const app = express(); const app = express();
app.use(requestContext); app.use(requestContext);

View File

@@ -0,0 +1,35 @@
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.CORE_API_BASE_URL) {
errors.push('CORE_API_BASE_URL is required');
}
if (!process.env.COMMAND_API_BASE_URL) {
errors.push('COMMAND_API_BASE_URL is required');
}
if (!process.env.QUERY_API_BASE_URL) {
errors.push('QUERY_API_BASE_URL is required');
}
if (errors.length > 0) {
throw new Error(`Unsafe unified-api runtime config for ${runtimeEnvName()}: ${errors.join('; ')}`);
}
}

View File

@@ -29,6 +29,19 @@ test('GET /readyz reports database not configured when env is absent', async ()
assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED');
}); });
test('createApp fails fast in protected env when upstream config is unsafe', async () => {
process.env.APP_ENV = 'staging';
process.env.AUTH_BYPASS = 'true';
delete process.env.CORE_API_BASE_URL;
delete process.env.COMMAND_API_BASE_URL;
delete process.env.QUERY_API_BASE_URL;
assert.throws(() => createApp(), /AUTH_BYPASS must be disabled/);
delete process.env.APP_ENV;
process.env.AUTH_BYPASS = 'true';
});
test('POST /auth/client/sign-in validates payload', async () => { test('POST /auth/client/sign-in validates payload', async () => {
const app = createApp(); const app = createApp();
const res = await request(app).post('/auth/client/sign-in').send({ const res = await request(app).post('/auth/client/sign-in').send({