Add CapabilityGuard infrastructure to enforce capability-based authorization on federation endpoints. Implements fail-closed security model. Security properties: - Deny by default (no capability = deny) - Only explicit true values grant access - Connection must exist and be ACTIVE - All denials logged for audit trail Implementation: - Created CapabilityGuard with fail-closed authorization logic - Added @RequireCapability decorator for marking endpoints - Added getConnectionById() to ConnectionService - Added logCapabilityDenied() to AuditService - 12 comprehensive tests covering all security scenarios Quality gates: - ✅ Tests: 12/12 passing - ✅ Lint: 0 new errors (33 pre-existing) - ✅ TypeScript: 0 new errors (8 pre-existing) Refs #273 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
/**
|
|
* Capability Guard Tests
|
|
*
|
|
* Verifies capability-based authorization enforcement.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { ExecutionContext, ForbiddenException, UnauthorizedException } from "@nestjs/common";
|
|
import { Reflector } from "@nestjs/core";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { FederationConnectionStatus } from "@prisma/client";
|
|
import { CapabilityGuard, RequireCapability } from "./capability.guard";
|
|
import { ConnectionService } from "../connection.service";
|
|
import { FederationAuditService } from "../audit.service";
|
|
import type { ConnectionDetails } from "../types/connection.types";
|
|
|
|
describe("CapabilityGuard", () => {
|
|
let guard: CapabilityGuard;
|
|
let connectionService: vi.Mocked<ConnectionService>;
|
|
let auditService: vi.Mocked<FederationAuditService>;
|
|
let reflector: Reflector;
|
|
|
|
const mockConnection: ConnectionDetails = {
|
|
id: "conn-123",
|
|
workspaceId: "ws-456",
|
|
remoteInstanceId: "remote-instance",
|
|
remoteUrl: "https://remote.example.com",
|
|
remotePublicKey: "public-key",
|
|
remoteCapabilities: {
|
|
supportsQuery: true,
|
|
supportsCommand: false,
|
|
supportsEvent: true,
|
|
supportsAgentSpawn: undefined, // Explicitly test undefined
|
|
},
|
|
status: FederationConnectionStatus.ACTIVE,
|
|
metadata: {},
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
connectedAt: new Date(),
|
|
disconnectedAt: null,
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
CapabilityGuard,
|
|
{
|
|
provide: ConnectionService,
|
|
useValue: {
|
|
getConnectionById: vi.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: FederationAuditService,
|
|
useValue: {
|
|
logCapabilityDenied: vi.fn(),
|
|
},
|
|
},
|
|
Reflector,
|
|
],
|
|
}).compile();
|
|
|
|
guard = module.get<CapabilityGuard>(CapabilityGuard);
|
|
connectionService = module.get(ConnectionService) as vi.Mocked<ConnectionService>;
|
|
auditService = module.get(FederationAuditService) as vi.Mocked<FederationAuditService>;
|
|
reflector = module.get<Reflector>(Reflector);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
/**
|
|
* Helper to create mock execution context
|
|
*/
|
|
function createMockContext(
|
|
requiredCapability: string | undefined,
|
|
requestData: {
|
|
body?: Record<string, unknown>;
|
|
headers?: Record<string, string>;
|
|
params?: Record<string, string>;
|
|
url?: string;
|
|
}
|
|
): ExecutionContext {
|
|
const mockHandler = vi.fn();
|
|
|
|
// Mock reflector to return required capability
|
|
vi.spyOn(reflector, "get").mockReturnValue(requiredCapability);
|
|
|
|
return {
|
|
getHandler: () => mockHandler,
|
|
switchToHttp: () => ({
|
|
getRequest: () => ({
|
|
body: requestData.body || {},
|
|
headers: requestData.headers || {},
|
|
params: requestData.params || {},
|
|
url: requestData.url || "/api/test",
|
|
}),
|
|
}),
|
|
} as unknown as ExecutionContext;
|
|
}
|
|
|
|
describe("Capability Enforcement", () => {
|
|
it("should allow access when no capability required", async () => {
|
|
const context = createMockContext(undefined, {});
|
|
|
|
const result = await guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
expect(connectionService.getConnectionById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should deny access when connection ID is missing", async () => {
|
|
const context = createMockContext("supportsQuery", {});
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
|
"Federation connection not identified"
|
|
);
|
|
});
|
|
|
|
it("should deny access when connection does not exist", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
body: { connectionId: "nonexistent" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(null);
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
|
|
await expect(guard.canActivate(context)).rejects.toThrow("Invalid federation connection");
|
|
});
|
|
|
|
it("should deny access when connection is not CONNECTED", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
body: { connectionId: "conn-123" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue({
|
|
...mockConnection,
|
|
status: FederationConnectionStatus.PENDING,
|
|
});
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
|
await expect(guard.canActivate(context)).rejects.toThrow("Connection is not active");
|
|
});
|
|
|
|
it("should deny access when capability is not granted", async () => {
|
|
const context = createMockContext("supportsCommand", {
|
|
body: { connectionId: "conn-123" },
|
|
url: "/api/execute-command",
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
|
"Operation requires capability: supportsCommand"
|
|
);
|
|
|
|
expect(auditService.logCapabilityDenied).toHaveBeenCalledWith(
|
|
"remote-instance",
|
|
"supportsCommand",
|
|
"/api/execute-command"
|
|
);
|
|
});
|
|
|
|
it("should allow access when capability is granted", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
body: { connectionId: "conn-123" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
const result = await guard.canActivate(context);
|
|
|
|
expect(result).toBe(true);
|
|
expect(auditService.logCapabilityDenied).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Connection ID Extraction", () => {
|
|
it("should extract connection ID from request body", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
body: { connectionId: "conn-123" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
await guard.canActivate(context);
|
|
|
|
expect(connectionService.getConnectionById).toHaveBeenCalledWith("conn-123");
|
|
});
|
|
|
|
it("should extract connection ID from headers", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
headers: { "x-federation-connection-id": "conn-456" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
await guard.canActivate(context);
|
|
|
|
expect(connectionService.getConnectionById).toHaveBeenCalledWith("conn-456");
|
|
});
|
|
|
|
it("should extract connection ID from route params", async () => {
|
|
const context = createMockContext("supportsQuery", {
|
|
params: { connectionId: "conn-789" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
await guard.canActivate(context);
|
|
|
|
expect(connectionService.getConnectionById).toHaveBeenCalledWith("conn-789");
|
|
});
|
|
});
|
|
|
|
describe("Fail-Closed Security", () => {
|
|
it("should deny access when capability is undefined (fail-closed)", async () => {
|
|
const context = createMockContext("supportsAgentSpawn", {
|
|
body: { connectionId: "conn-123" },
|
|
});
|
|
|
|
connectionService.getConnectionById.mockResolvedValue(mockConnection);
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
|
await expect(guard.canActivate(context)).rejects.toThrow(
|
|
"Operation requires capability: supportsAgentSpawn"
|
|
);
|
|
});
|
|
|
|
it("should only allow explicitly true values (not truthy)", async () => {
|
|
const context = createMockContext("supportsEvent", {
|
|
body: { connectionId: "conn-123" },
|
|
});
|
|
|
|
// Test with explicitly true (should pass)
|
|
connectionService.getConnectionById.mockResolvedValue({
|
|
...mockConnection,
|
|
remoteCapabilities: { supportsEvent: true },
|
|
});
|
|
|
|
const resultTrue = await guard.canActivate(context);
|
|
expect(resultTrue).toBe(true);
|
|
|
|
// Test with truthy but not true (should fail)
|
|
connectionService.getConnectionById.mockResolvedValue({
|
|
...mockConnection,
|
|
remoteCapabilities: { supportsEvent: 1 as unknown as boolean },
|
|
});
|
|
|
|
await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException);
|
|
});
|
|
});
|
|
|
|
describe("RequireCapability Decorator", () => {
|
|
it("should create decorator with correct metadata", () => {
|
|
const decorator = RequireCapability("supportsQuery");
|
|
|
|
expect(decorator).toBeDefined();
|
|
expect(typeof decorator).toBe("function");
|
|
});
|
|
});
|
|
});
|