feat(#273): Implement capability-based authorization for federation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

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:
2026-02-03 19:47:30 -06:00
parent dc1ed2a59e
commit 004f7828fb
7 changed files with 646 additions and 0 deletions

View File

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

View File

@@ -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
*/

View 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");
});
});
});

View 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,
});

View File

@@ -0,0 +1,5 @@
/**
* Federation Guards Exports
*/
export * from "./capability.guard";

View File

@@ -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";