Specul de arhitectura cere Platform Kernel INAINTEA modulelor de domeniu. Modulele A1/A2 au fost construite peste un kernel caruia ii lipseau exact piesele astea. Le adaug acum, aditiv, fara sa rup ce merge. - workspaces: tenant != workspace. Workspace-ul e contextul de lucru DIN tenant. Backfill: fiecare tenant existent primeste workspace implicit, altfel SessionGuard i-ar respinge toate requesturile. - memberships.workspace_id + valid_from/valid_until: rol per workspace si acces delegat cu expirare (contabil pana la o data). SessionGuard respinge membership expirat si membership legat de alt workspace. - ExecutionContext inlocuieste sesiunea subtire (userId+tenantId+role): requestId, correlationId, workspaceId, membershipId, roles, permissions, purpose, timezone, source. Tipul vechi ramane exportat sub acelasi nume, ca sa nu ating ~15 module de domeniu doar pentru o redenumire. - GET /v1/navigation: menu registry mutat in backend. Filtreaza pe rol, tip de workspace, permisiuni si feature flags; intoarce doar itemii autorizati. Ramane UX, nu securitate -- fiecare endpoint verifica din nou. - event envelope: workspace_id, occurred_at, actor_id, aggregate_type, causation_id, classification, provenance - audit envelope: workspace_id, actor_type, purpose, changed_fields, before/after hash, session_id
88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
import { ForbiddenException } from '@nestjs/common';
|
|
import type { ExecutionContext } from '@nestjs/common';
|
|
import type { Reflector } from '@nestjs/core';
|
|
import { TenantGuard, type AuthenticatedSession } from './tenant.guard';
|
|
|
|
interface FakeRequest {
|
|
session?: AuthenticatedSession;
|
|
body?: Record<string, unknown>;
|
|
query?: Record<string, unknown>;
|
|
}
|
|
|
|
function contextFor(request: FakeRequest, meta: { isPublic?: boolean; isAuthOnly?: boolean } = {}) {
|
|
const reflector = {
|
|
getAllAndOverride: jest.fn((key: string) => {
|
|
if (key === 'isPublic') {
|
|
return meta.isPublic ?? false;
|
|
}
|
|
if (key === 'isAuthOnly') {
|
|
return meta.isAuthOnly ?? false;
|
|
}
|
|
return false;
|
|
}),
|
|
} as unknown as Reflector;
|
|
|
|
const context = {
|
|
getHandler: () => ({}),
|
|
getClass: () => ({}),
|
|
switchToHttp: () => ({ getRequest: () => request }),
|
|
} as unknown as ExecutionContext;
|
|
|
|
return { guard: new TenantGuard(reflector), context };
|
|
}
|
|
|
|
const session: AuthenticatedSession = {
|
|
requestId: '00000000-0000-0000-0000-0000000000r1',
|
|
correlationId: '00000000-0000-0000-0000-0000000000c1',
|
|
sessionId: 'sess-1',
|
|
userId: '11111111-1111-1111-1111-111111111111',
|
|
tenantId: '22222222-2222-2222-2222-222222222222',
|
|
workspaceId: '44444444-4444-4444-4444-444444444444',
|
|
membershipId: '55555555-5555-5555-5555-555555555555',
|
|
role: 'member',
|
|
roles: ['member'],
|
|
permissions: ['task:read'],
|
|
purpose: 'user_request',
|
|
timezone: 'Europe/Bucharest',
|
|
source: 'web',
|
|
};
|
|
|
|
describe('TenantGuard', () => {
|
|
it('rejects requests without an active tenant session (deny-by-default)', () => {
|
|
const { guard, context } = contextFor({ body: {}, query: {} });
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('rejects client-supplied tenantId that differs from the session tenant', () => {
|
|
const { guard, context } = contextFor({
|
|
session,
|
|
body: { tenantId: '33333333-3333-3333-3333-333333333333' },
|
|
query: {},
|
|
});
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('rejects tenantId smuggled through the query string', () => {
|
|
const { guard, context } = contextFor({
|
|
session,
|
|
body: {},
|
|
query: { tenantId: '33333333-3333-3333-3333-333333333333' },
|
|
});
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it('allows a request whose session tenant matches', () => {
|
|
const { guard, context } = contextFor({ session, body: {}, query: {} });
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it('allows public routes without a session', () => {
|
|
const { guard, context } = contextFor({}, { isPublic: true });
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it('allows auth-only bootstrap routes without a tenant', () => {
|
|
const { guard, context } = contextFor({}, { isAuthOnly: true });
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
});
|