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>
137 lines
4.3 KiB
TypeScript
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,
|
|
});
|