Files
stack/apps/api/src/federation/query.service.spec.ts
Jason Woltje 73074932f6 feat(#360): Add federation credential isolation
Implement explicit deny-lists in QueryService and CommandService to prevent
user credentials from leaking across federation boundaries.

## Changes

### Core Implementation
- QueryService: Block all credential-related queries with keyword detection
- CommandService: Block all credential operations (create/update/delete/read)
- Case-insensitive keyword matching for both queries and commands

### Security Features
- Deny-list includes: credential, api_key, secret, token, password, oauth
- Errors returned for blocked operations
- No impact on existing allowed operations (tasks, events, projects, agent commands)

### Testing
- Added 2 unit tests to query.service.spec.ts
- Added 3 unit tests to command.service.spec.ts
- Added 8 integration tests in credential-isolation.integration.spec.ts
- All 377 federation tests passing

### Documentation
- Created comprehensive security doc at docs/security/federation-credential-isolation.md
- Documents 4 security guarantees (G1-G4)
- Includes testing strategy and incident response procedures

## Security Guarantees

1. G1: Credential Confidentiality - Credentials never leave instance in plaintext
2. G2: Cross-Instance Isolation - Compromised key on one instance doesn't affect others
3. G3: Query/Command Isolation - Federated instances cannot query/modify credentials
4. G4: Accidental Exposure Prevention - Credentials cannot leak via messages

## Defense-in-Depth

This implementation adds application-layer protection on top of existing:
- Transit key separation (mosaic-credentials vs mosaic-federation)
- Per-instance OpenBao servers
- Workspace-scoped credential access

Fixes #360

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:55:49 -06:00

785 lines
26 KiB
TypeScript

/**
* Query Service Tests
*
* Tests for federated query message handling.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import {
FederationConnectionStatus,
FederationMessageType,
FederationMessageStatus,
} from "@prisma/client";
import { QueryService } from "./query.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { TasksService } from "../tasks/tasks.service";
import { EventsService } from "../events/events.service";
import { ProjectsService } from "../projects/projects.service";
import { HttpService } from "@nestjs/axios";
import { of, throwError } from "rxjs";
import type { AxiosResponse } from "axios";
describe("QueryService", () => {
let service: QueryService;
let prisma: PrismaService;
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
const mockPrisma = {
federationConnection: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
federationMessage: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
};
const mockFederationService = {
getInstanceIdentity: vi.fn(),
getPublicIdentity: vi.fn(),
};
const mockSignatureService = {
signMessage: vi.fn(),
verifyMessage: vi.fn(),
validateTimestamp: vi.fn(),
};
const mockHttpService = {
post: vi.fn(),
};
const mockConfig = {
get: vi.fn(),
};
const mockTasksService = {
findAll: vi.fn(),
};
const mockEventsService = {
findAll: vi.fn(),
};
const mockProjectsService = {
findAll: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
QueryService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: FederationService, useValue: mockFederationService },
{ provide: SignatureService, useValue: mockSignatureService },
{ provide: HttpService, useValue: mockHttpService },
{ provide: ConfigService, useValue: mockConfig },
{ provide: TasksService, useValue: mockTasksService },
{ provide: EventsService, useValue: mockEventsService },
{ provide: ProjectsService, useValue: mockProjectsService },
],
}).compile();
service = module.get<QueryService>(QueryService);
prisma = module.get<PrismaService>(PrismaService);
federationService = module.get<FederationService>(FederationService);
signatureService = module.get<SignatureService>(SignatureService);
httpService = module.get<HttpService>(HttpService);
// Setup default mock return values for service queries
mockTasksService.findAll.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
});
mockEventsService.findAll.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
});
mockProjectsService.findAll.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
});
vi.clearAllMocks();
});
describe("sendQuery", () => {
it("should send query to remote instance with signed message", async () => {
const workspaceId = "workspace-1";
const connectionId = "connection-1";
const query = "SELECT * FROM tasks";
const context = { userId: "user-1" };
const mockConnection = {
id: connectionId,
workspaceId,
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
remotePublicKey: "mock-public-key",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
id: "identity-1",
instanceId: "local-instance-1",
name: "Local Instance",
url: "https://local.example.com",
publicKey: "local-public-key",
privateKey: "local-private-key",
};
const mockMessage = {
id: "message-1",
workspaceId,
connectionId,
messageType: FederationMessageType.QUERY,
messageId: expect.any(String),
correlationId: null,
query,
response: null,
status: FederationMessageStatus.PENDING,
error: null,
signature: "mock-signature",
createdAt: new Date(),
updatedAt: new Date(),
deliveredAt: null,
};
const mockResponse: AxiosResponse = {
data: { success: true },
status: 200,
statusText: "OK",
headers: {},
config: { headers: {} as never },
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue(mockMessage);
mockHttpService.post.mockReturnValue(of(mockResponse));
const result = await service.sendQuery(workspaceId, connectionId, query, context);
expect(result).toBeDefined();
expect(result.messageType).toBe(FederationMessageType.QUERY);
expect(result.query).toBe(query);
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: {
id: connectionId,
workspaceId,
status: FederationConnectionStatus.ACTIVE,
},
});
expect(mockPrisma.federationMessage.create).toHaveBeenCalled();
expect(mockHttpService.post).toHaveBeenCalledWith(
`${mockConnection.remoteUrl}/api/v1/federation/incoming/query`,
expect.objectContaining({
messageId: expect.any(String),
instanceId: mockIdentity.instanceId,
query,
context,
timestamp: expect.any(Number),
signature: "mock-signature",
})
);
});
it("should throw error if connection not found", async () => {
mockPrisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection not found");
});
it("should throw error if connection not active", async () => {
// Connection should not be found by query because it's not ACTIVE
mockPrisma.federationConnection.findUnique.mockResolvedValue(null);
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Connection not found");
// Verify that findUnique was called with status: ACTIVE in the query
expect(mockPrisma.federationConnection.findUnique).toHaveBeenCalledWith({
where: {
id: "connection-1",
workspaceId: "workspace-1",
status: FederationConnectionStatus.ACTIVE,
},
});
});
it("should handle network errors gracefully", async () => {
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
remoteUrl: "https://remote.example.com",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findUnique.mockResolvedValue(mockConnection);
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("mock-signature");
mockPrisma.federationMessage.create.mockResolvedValue({
id: "message-1",
messageId: "msg-1",
});
mockHttpService.post.mockReturnValue(throwError(() => new Error("Network error")));
await expect(
service.sendQuery("workspace-1", "connection-1", "SELECT * FROM tasks")
).rejects.toThrow("Failed to send query");
});
});
describe("handleIncomingQuery", () => {
it("should process valid incoming query", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
context: {},
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.messageId).toBeDefined();
expect(result.correlationId).toBe(queryMessage.messageId);
expect(result.instanceId).toBe(mockIdentity.instanceId);
expect(result.signature).toBe("response-signature");
});
it("should reject query with invalid signature", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow("Invalid signature");
});
it("should reject query with expired timestamp", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now() - 10 * 60 * 1000, // 10 minutes ago
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(false);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"Query timestamp is outside acceptable range"
);
});
it("should reject query from inactive connection", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
// Connection should not be found because status filter in query excludes non-ACTIVE
mockPrisma.federationConnection.findFirst.mockResolvedValue(null);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"No connection found for remote instance"
);
// Verify the findFirst was called with status: ACTIVE filter
expect(mockPrisma.federationConnection.findFirst).toHaveBeenCalledWith({
where: {
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
},
});
});
it("should reject query from unknown instance", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "unknown-instance",
query: "SELECT * FROM tasks",
timestamp: Date.now(),
signature: "valid-signature",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(null);
mockSignatureService.validateTimestamp.mockReturnValue(true);
await expect(service.handleIncomingQuery(queryMessage)).rejects.toThrow(
"No connection found for remote instance"
);
});
});
describe("getQueryMessages", () => {
it("should return query messages for workspace", async () => {
const workspaceId = "workspace-1";
const mockMessages = [
{
id: "msg-1",
workspaceId,
connectionId: "connection-1",
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
},
];
mockPrisma.federationMessage.findMany.mockResolvedValue(mockMessages);
const result = await service.getQueryMessages(workspaceId);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg-1");
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
},
orderBy: { createdAt: "desc" },
});
});
it("should filter by status when provided", async () => {
const workspaceId = "workspace-1";
const status = FederationMessageStatus.PENDING;
mockPrisma.federationMessage.findMany.mockResolvedValue([]);
await service.getQueryMessages(workspaceId, status);
expect(mockPrisma.federationMessage.findMany).toHaveBeenCalledWith({
where: {
workspaceId,
messageType: FederationMessageType.QUERY,
status,
},
orderBy: { createdAt: "desc" },
});
});
});
describe("getQueryMessage", () => {
it("should return query message by ID", async () => {
const workspaceId = "workspace-1";
const messageId = "msg-1";
const mockMessage = {
id: "msg-1",
workspaceId,
messageType: FederationMessageType.QUERY,
messageId: "unique-msg-1",
query: "SELECT * FROM tasks",
status: FederationMessageStatus.DELIVERED,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrisma.federationMessage.findUnique.mockResolvedValue(mockMessage);
const result = await service.getQueryMessage(workspaceId, messageId);
expect(result).toBeDefined();
expect(result.id).toBe(messageId);
expect(mockPrisma.federationMessage.findUnique).toHaveBeenCalledWith({
where: { id: messageId, workspaceId },
});
});
it("should throw error if message not found", async () => {
mockPrisma.federationMessage.findUnique.mockResolvedValue(null);
await expect(service.getQueryMessage("workspace-1", "msg-1")).rejects.toThrow(
"Query message not found"
);
});
});
describe("processQueryResponse", () => {
it("should update message with response", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
data: { tasks: [] },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
status: FederationMessageStatus.PENDING,
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockPrisma.federationMessage.update.mockResolvedValue({
...mockMessage,
status: FederationMessageStatus.DELIVERED,
response: response.data,
});
await service.processQueryResponse(response);
expect(mockPrisma.federationMessage.update).toHaveBeenCalledWith({
where: { id: mockMessage.id },
data: {
status: FederationMessageStatus.DELIVERED,
response: response.data,
deliveredAt: expect.any(Date),
},
});
});
it("should reject response with invalid signature", async () => {
const response = {
messageId: "response-1",
correlationId: "original-msg-1",
instanceId: "remote-instance-1",
success: true,
timestamp: Date.now(),
signature: "invalid-signature",
};
const mockMessage = {
id: "msg-1",
messageId: "original-msg-1",
workspaceId: "workspace-1",
};
mockPrisma.federationMessage.findFirst.mockResolvedValue(mockMessage);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({
valid: false,
error: "Invalid signature",
});
await expect(service.processQueryResponse(response)).rejects.toThrow("Invalid signature");
});
});
describe("query processing with actual data", () => {
it("should process 'get tasks' query and return tasks data", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "get tasks",
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.correlationId).toBe(queryMessage.messageId);
});
it("should process 'get events' query and return events data", async () => {
const queryMessage = {
messageId: "msg-2",
instanceId: "remote-instance-1",
query: "get events",
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
});
it("should process 'get projects' query and return projects data", async () => {
const queryMessage = {
messageId: "msg-3",
instanceId: "remote-instance-1",
query: "get projects",
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
});
it("should handle unknown query type gracefully", async () => {
const queryMessage = {
messageId: "msg-4",
instanceId: "remote-instance-1",
query: "unknown command",
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(false);
expect(result.error).toContain("Unknown query type");
});
it("should enforce workspace context in queries", async () => {
const queryMessage = {
messageId: "msg-5",
instanceId: "remote-instance-1",
query: "get tasks",
context: {}, // Missing workspaceId
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(false);
expect(result.error).toContain("workspaceId");
});
it("should reject queries for UserCredential entity type", async () => {
const queryMessage = {
messageId: "msg-1",
instanceId: "remote-instance-1",
query: "credentials",
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
const result = await service.handleIncomingQuery(queryMessage);
expect(result).toBeDefined();
expect(result.success).toBe(false);
expect(result.error).toContain("Credential queries are not allowed");
});
it("should reject queries containing credential-related keywords", async () => {
const credentialQueries = [
"SELECT * FROM user_credentials",
"get all credentials",
"show my api keys",
"list oauth tokens",
];
const mockConnection = {
id: "connection-1",
workspaceId: "workspace-1",
remoteInstanceId: "remote-instance-1",
status: FederationConnectionStatus.ACTIVE,
};
const mockIdentity = {
instanceId: "local-instance-1",
};
mockPrisma.federationConnection.findFirst.mockResolvedValue(mockConnection);
mockSignatureService.validateTimestamp.mockReturnValue(true);
mockSignatureService.verifyMessage.mockResolvedValue({ valid: true });
mockFederationService.getInstanceIdentity.mockResolvedValue(mockIdentity);
mockSignatureService.signMessage.mockResolvedValue("response-signature");
for (const query of credentialQueries) {
const queryMessage = {
messageId: `msg-${Math.random()}`,
instanceId: "remote-instance-1",
query,
context: { workspaceId: "workspace-1" },
timestamp: Date.now(),
signature: "valid-signature",
};
const result = await service.handleIncomingQuery(queryMessage);
expect(result.success).toBe(false);
expect(result.error).toContain("Credential queries are not allowed");
}
});
});
});