feat(#273): Implement capability-based authorization for federation
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>
This commit is contained in:
@@ -123,4 +123,23 @@ export class FederationAuditService {
|
||||
securityEvent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log capability denial (security event)
|
||||
* Logged when remote instance attempts operation without required capability
|
||||
*/
|
||||
logCapabilityDenied(
|
||||
remoteInstanceId: string,
|
||||
requiredCapability: string,
|
||||
requestedUrl: string
|
||||
): void {
|
||||
this.logger.warn({
|
||||
event: "FEDERATION_CAPABILITY_DENIED",
|
||||
remoteInstanceId,
|
||||
requiredCapability,
|
||||
requestedUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
securityEvent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,24 @@ export class ConnectionService {
|
||||
return this.mapToConnectionDetails(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection by ID (without workspace filter)
|
||||
* Used by CapabilityGuard for authorization checks
|
||||
*/
|
||||
async getConnectionById(connectionId: string): Promise<ConnectionDetails | null> {
|
||||
const connection = await this.prisma.federationConnection.findUnique({
|
||||
where: {
|
||||
id: connectionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.mapToConnectionDetails(connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming connection request from remote instance
|
||||
*/
|
||||
|
||||
265
apps/api/src/federation/guards/capability.guard.spec.ts
Normal file
265
apps/api/src/federation/guards/capability.guard.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
136
apps/api/src/federation/guards/capability.guard.ts
Normal file
136
apps/api/src/federation/guards/capability.guard.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Capability Guard
|
||||
*
|
||||
* Enforces capability-based authorization for federation operations.
|
||||
* Fail-closed security model: denies access unless capability is explicitly granted.
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
import type { Request } from "express";
|
||||
import type { FederationCapabilities } from "../types/instance.types";
|
||||
import { ConnectionService } from "../connection.service";
|
||||
import { FederationAuditService } from "../audit.service";
|
||||
|
||||
/**
|
||||
* Metadata key for required capability
|
||||
*/
|
||||
const REQUIRED_CAPABILITY_KEY = "federation:requiredCapability";
|
||||
|
||||
/**
|
||||
* Guard that enforces capability-based authorization
|
||||
*
|
||||
* Security properties:
|
||||
* - Fail-closed: Denies access if capability is undefined or not explicitly true
|
||||
* - Connection validation: Verifies connection exists and is CONNECTED
|
||||
* - Audit logging: Logs all capability denials for security monitoring
|
||||
*/
|
||||
@Injectable()
|
||||
export class CapabilityGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly auditService: FederationAuditService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Check if capability requirement is set on endpoint
|
||||
const requiredCapability = this.reflector.get<keyof FederationCapabilities>(
|
||||
REQUIRED_CAPABILITY_KEY,
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
// If no capability required, allow access
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!requiredCapability) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
// Extract connection ID from request
|
||||
const connectionId = this.extractConnectionId(request);
|
||||
if (!connectionId) {
|
||||
throw new UnauthorizedException("Federation connection not identified");
|
||||
}
|
||||
|
||||
// Load connection
|
||||
const connection = await this.connectionService.getConnectionById(connectionId);
|
||||
if (!connection) {
|
||||
throw new UnauthorizedException("Invalid federation connection");
|
||||
}
|
||||
|
||||
// Verify connection is active
|
||||
if (connection.status !== FederationConnectionStatus.ACTIVE) {
|
||||
throw new ForbiddenException("Connection is not active");
|
||||
}
|
||||
|
||||
// Check if capability is granted (fail-closed: must be explicitly true)
|
||||
const capabilities = connection.remoteCapabilities as unknown as FederationCapabilities;
|
||||
const hasCapability = capabilities[requiredCapability] === true;
|
||||
|
||||
if (!hasCapability) {
|
||||
// Log capability denial for security monitoring
|
||||
this.auditService.logCapabilityDenied(
|
||||
connection.remoteInstanceId,
|
||||
requiredCapability,
|
||||
request.url
|
||||
);
|
||||
|
||||
throw new ForbiddenException(`Operation requires capability: ${requiredCapability}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract connection ID from request
|
||||
* Checks body, headers, and params in order
|
||||
*/
|
||||
private extractConnectionId(request: Request): string | undefined {
|
||||
// Check body first (most common for POST/PATCH)
|
||||
const body = request.body as { connectionId?: string } | undefined;
|
||||
if (body?.connectionId) {
|
||||
return body.connectionId;
|
||||
}
|
||||
|
||||
// Check headers (for federated requests)
|
||||
const headerConnectionId = request.headers["x-federation-connection-id"];
|
||||
if (headerConnectionId) {
|
||||
return Array.isArray(headerConnectionId) ? headerConnectionId[0] : headerConnectionId;
|
||||
}
|
||||
|
||||
// Check route params (for GET/DELETE operations)
|
||||
const params = request.params as { connectionId?: string } | undefined;
|
||||
if (params?.connectionId) {
|
||||
return params.connectionId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator to mark endpoints that require specific federation capabilities
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Post("execute-command")
|
||||
* @RequireCapability("supportsCommand")
|
||||
* async executeCommand(@Body() dto: ExecuteCommandDto) {
|
||||
* // Only reachable if remote instance has supportsCommand = true
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const RequireCapability = (capability: keyof FederationCapabilities) =>
|
||||
Reflector.createDecorator<keyof FederationCapabilities>({
|
||||
key: REQUIRED_CAPABILITY_KEY,
|
||||
transform: () => capability,
|
||||
});
|
||||
5
apps/api/src/federation/guards/index.ts
Normal file
5
apps/api/src/federation/guards/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Federation Guards Exports
|
||||
*/
|
||||
|
||||
export * from "./capability.guard";
|
||||
@@ -14,6 +14,7 @@ export * from "./query.service";
|
||||
export * from "./query.controller";
|
||||
export * from "./command.service";
|
||||
export * from "./command.controller";
|
||||
export * from "./guards";
|
||||
export * from "./types/instance.types";
|
||||
export * from "./types/identity-linking.types";
|
||||
export * from "./types/message.types";
|
||||
|
||||
Reference in New Issue
Block a user