fix(authz): tighten policy scope enforcement

This commit is contained in:
zouantchaw
2026-03-19 16:48:43 +01:00
parent 2f25d10368
commit a4ac0b2a6b
14 changed files with 743 additions and 30 deletions

View File

@@ -9,6 +9,30 @@ function getBearerToken(header) {
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) {
try {
const token = getBearerToken(req.get('Authorization'));
@@ -17,7 +41,7 @@ export async function requireAuth(req, _res, next) {
}
if (process.env.AUTH_BYPASS === 'true') {
req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' };
req.actor = buildBypassActor();
return next();
}
@@ -36,10 +60,14 @@ export async function requireAuth(req, _res, next) {
}
export function requirePolicy(action, resource) {
return (req, _res, next) => {
if (!can(action, resource, req.actor)) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
return async (req, _res, next) => {
try {
if (!(await can(action, resource, req.actor, req))) {
return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403));
}
return next();
} catch (error) {
return next(error);
}
return next();
};
}

View File

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

View File

@@ -1,5 +1,118 @@
export function can(action, resource, actor) {
void action;
void resource;
return Boolean(actor?.uid);
import { loadActorContext } from './actor-context.js';
const TENANT_ADMIN_ROLES = new Set(['OWNER', 'ADMIN']);
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
a.id AS "assignmentId",
a.status,
a.business_id AS "businessId",
a.shift_id AS "shiftId",
a.staff_id AS "staffId",
s.title AS "shiftTitle",

View File

@@ -37,6 +37,10 @@ test('GET /readyz reports database not configured when no database env is presen
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';
@@ -134,3 +138,28 @@ test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validat
assert.equal(res.status, 200);
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);
});