Files
stack/apps/api/src/federation/federation.controller.ts
Jason Woltje e23490a5f7
All checks were successful
ci/woodpecker/push/api Pipeline was successful
fix(api): remove redundant CsrfGuard from FederationController
CsrfGuard is already applied globally via APP_GUARD in AppModule.
The explicit @UseGuards(CsrfGuard) on FederationController caused a
DI error because CsrfService is not provided in FederationModule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:14:03 -06:00

241 lines
8.3 KiB
TypeScript

/**
* Federation Controller
*
* API endpoints for instance identity and federation management.
* Issue #272: Rate limiting applied to prevent DoS attacks
*/
import { Controller, Get, Post, UseGuards, Logger, Req, Body, Param, Query } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import { FederationService } from "./federation.service";
import { FederationAuditService } from "./audit.service";
import { ConnectionService } from "./connection.service";
import { getDefaultWorkspaceId } from "./federation.config";
import { AuthGuard } from "../auth/guards/auth.guard";
import { AdminGuard } from "../auth/guards/admin.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import { SkipCsrf } from "../common/decorators/skip-csrf.decorator";
import type { PublicInstanceIdentity } from "./types/instance.types";
import type { ConnectionDetails } from "./types/connection.types";
import type { AuthenticatedRequest } from "../common/types/user.types";
import {
InitiateConnectionDto,
AcceptConnectionDto,
RejectConnectionDto,
DisconnectConnectionDto,
IncomingConnectionRequestDto,
} from "./dto/connection.dto";
import { FederationConnectionStatus } from "@prisma/client";
@Controller("api/v1/federation")
export class FederationController {
private readonly logger = new Logger(FederationController.name);
constructor(
private readonly federationService: FederationService,
private readonly auditService: FederationAuditService,
private readonly connectionService: ConnectionService
) {}
/**
* Get this instance's public identity
* No authentication required - this is public information for federation
* Rate limit: "long" tier (200 req/hour) - public endpoint
* CSRF exempt: GET method (safe)
*/
@Get("instance")
@Throttle({ long: { limit: 200, ttl: 3600000 } })
async getInstance(): Promise<PublicInstanceIdentity> {
this.logger.debug("GET /api/v1/federation/instance");
return this.federationService.getPublicIdentity();
}
/**
* Regenerate instance keypair
* Requires system administrator privileges
* Returns public identity only (private key never exposed in API)
* Rate limit: "medium" tier (20 req/min) - sensitive admin operation
*/
@Post("instance/regenerate-keys")
@UseGuards(AuthGuard, AdminGuard)
@Throttle({ medium: { limit: 20, ttl: 60000 } })
async regenerateKeys(@Req() req: AuthenticatedRequest): Promise<PublicInstanceIdentity> {
if (!req.user) {
throw new Error("User not authenticated");
}
this.logger.warn(`Admin user ${req.user.id} regenerating instance keypair`);
const result = await this.federationService.regenerateKeypair();
// Audit log for security compliance
this.auditService.logKeypairRegeneration(req.user.id, result.instanceId);
return result;
}
/**
* Initiate a connection to a remote instance
* Requires authentication and workspace access
* Rate limit: "medium" tier (20 req/min) - authenticated endpoint
*/
@Post("connections/initiate")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ medium: { limit: 20, ttl: 60000 } })
async initiateConnection(
@Req() req: AuthenticatedRequest,
@Body() dto: InitiateConnectionDto
): Promise<ConnectionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} initiating connection to ${dto.remoteUrl} for workspace ${req.user.workspaceId}`
);
return this.connectionService.initiateConnection(req.user.workspaceId, dto.remoteUrl);
}
/**
* Accept a pending connection
* Requires authentication and workspace access
* Rate limit: "medium" tier (20 req/min) - authenticated endpoint
*/
@Post("connections/:id/accept")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ medium: { limit: 20, ttl: 60000 } })
async acceptConnection(
@Req() req: AuthenticatedRequest,
@Param("id") connectionId: string,
@Body() dto: AcceptConnectionDto
): Promise<ConnectionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(
`User ${req.user.id} accepting connection ${connectionId} for workspace ${req.user.workspaceId}`
);
return this.connectionService.acceptConnection(
req.user.workspaceId,
connectionId,
dto.metadata
);
}
/**
* Reject a pending connection
* Requires authentication and workspace access
* Rate limit: "medium" tier (20 req/min) - authenticated endpoint
*/
@Post("connections/:id/reject")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ medium: { limit: 20, ttl: 60000 } })
async rejectConnection(
@Req() req: AuthenticatedRequest,
@Param("id") connectionId: string,
@Body() dto: RejectConnectionDto
): Promise<ConnectionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(`User ${req.user.id} rejecting connection ${connectionId}: ${dto.reason}`);
return this.connectionService.rejectConnection(req.user.workspaceId, connectionId, dto.reason);
}
/**
* Disconnect an active connection
* Requires authentication and workspace access
* Rate limit: "medium" tier (20 req/min) - authenticated endpoint
*/
@Post("connections/:id/disconnect")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ medium: { limit: 20, ttl: 60000 } })
async disconnectConnection(
@Req() req: AuthenticatedRequest,
@Param("id") connectionId: string,
@Body() dto: DisconnectConnectionDto
): Promise<ConnectionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
this.logger.log(`User ${req.user.id} disconnecting connection ${connectionId}`);
return this.connectionService.disconnect(req.user.workspaceId, connectionId, dto.reason);
}
/**
* Get all connections for the workspace
* Requires authentication and workspace access
* Rate limit: "long" tier (200 req/hour) - read-only endpoint
*/
@Get("connections")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ long: { limit: 200, ttl: 3600000 } })
async getConnections(
@Req() req: AuthenticatedRequest,
@Query("status") status?: FederationConnectionStatus
): Promise<ConnectionDetails[]> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.connectionService.getConnections(req.user.workspaceId, status);
}
/**
* Get a single connection
* Requires authentication and workspace access
* Rate limit: "long" tier (200 req/hour) - read-only endpoint
*/
@Get("connections/:id")
@UseGuards(AuthGuard, WorkspaceGuard)
@Throttle({ long: { limit: 200, ttl: 3600000 } })
async getConnection(
@Req() req: AuthenticatedRequest,
@Param("id") connectionId: string
): Promise<ConnectionDetails> {
if (!req.user?.workspaceId) {
throw new Error("Workspace ID not found in request");
}
return this.connectionService.getConnection(req.user.workspaceId, connectionId);
}
/**
* Handle incoming connection request from remote instance
* Public endpoint - no authentication required (signature-based verification)
* Rate limit: "short" tier (3 req/sec) - CRITICAL DoS protection (Issue #272)
* CSRF exempt: Uses signature-based authentication instead
*/
@Post("incoming/connect")
@SkipCsrf()
@Throttle({ short: { limit: 3, ttl: 1000 } })
async handleIncomingConnection(
@Body() dto: IncomingConnectionRequestDto
): Promise<{ status: string; connectionId?: string }> {
this.logger.log(`Received connection request from ${dto.instanceId}`);
// LIMITATION: Incoming connections are created in a default workspace
// TODO: Future enhancement - Allow configuration of which workspace handles incoming connections
// This could be based on routing rules, instance configuration, or a dedicated federation workspace
// Issue #338: Validate DEFAULT_WORKSPACE_ID is a valid UUID (throws if invalid/missing)
const workspaceId = getDefaultWorkspaceId();
const connection = await this.connectionService.handleIncomingConnectionRequest(
workspaceId,
dto
);
return {
status: "pending",
connectionId: connection.id,
};
}
}