Files
stack/apps/gateway/src/federation/server/__tests__/scope.service.spec.ts
jason.woltje c739256a2c
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
feat(#462): add federation scope enforcement service (#672)
2026-06-24 23:22:46 +00:00

325 lines
8.9 KiB
TypeScript

/**
* Unit tests for FederationScopeService (FED-M3-04).
*
* Coverage:
* - resource allowlist deny
* - excluded resource deny
* - invalid scope deny
* - invalid requested limit deny
* - native RBAC deny as subjectUserId
* - scope/native filter intersection for personal and team rows
* - native RBAC personal deny wins over scope include_personal allow/default
* - max_rows_per_query cap
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
import type { FederationContext } from '../federation-context.js';
const GRANT_ID = 'grant-1';
const PEER_ID = 'peer-1';
const SUBJECT_USER_ID = 'user-1';
function makeContext(scope: Record<string, unknown>): FederationContext {
return {
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
scope,
};
}
function makeNativeRbac(
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
): FederationNativeRbacEvaluator {
return {
evaluateReadAccess: vi.fn().mockResolvedValue(result),
};
}
describe('FederationScopeService', () => {
let service: FederationScopeService;
beforeEach(() => {
service = new FederationScopeService();
});
it('allows a granted resource and returns a capped query filter', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
max_rows_per_query: 50,
}),
resource: 'tasks',
requestedLimit: 500,
nativeRbac,
});
expect(result).toEqual({
allowed: true,
filter: {
resource: 'tasks',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: ['team-1'],
limit: 50,
maxRowsPerQuery: 50,
},
});
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
});
});
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: true,
teamIds: ['team-1', 'team-2'],
limit: 100,
},
});
});
it('honors include_personal false even when native RBAC allows personal rows', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['memory'],
filters: { memory: { include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'memory',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_personal: true } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: false, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: ['team-1'],
},
});
});
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('denies invalid grant scope before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: [], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies unsupported resource names before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'unknown_resource',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_resource',
stage: 'resource_allowlist',
statusCode: 403,
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
excluded_resources: ['credentials'],
max_rows_per_query: 100,
}),
resource: 'credentials',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
resource: 'credentials',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies supported resources that are not granted by scope', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_not_granted',
stage: 'resource_allowlist',
statusCode: 403,
resource: 'notes',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies invalid requested row limits before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
requestedLimit: 0,
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
details: { requestedLimit: 0 },
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: false,
reason: 'read:tasks denied',
details: { permission: 'tasks:read' },
}),
});
expect(result).toEqual({
allowed: false,
deny: {
code: 'native_rbac_denied',
stage: 'native_rbac',
statusCode: 403,
message: 'read:tasks denied',
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
details: { permission: 'tasks:read' },
},
});
});
});