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

@@ -40,6 +40,7 @@ function validOrderCreatePayload() {
beforeEach(() => {
process.env.IDEMPOTENCY_STORE = 'memory';
delete process.env.AUTH_BYPASS_CONTEXT;
delete process.env.IDEMPOTENCY_DATABASE_URL;
delete process.env.DATABASE_URL;
__resetIdempotencyStoreForTests();
@@ -126,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(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

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