Files
stack/apps/api/src/federation/guards/capability.guard.ts
Jason Woltje 004f7828fb
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
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>
2026-02-03 19:53:09 -06:00

137 lines
4.3 KiB
TypeScript

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