fix(#183): remove hardcoded workspace ID from Discord service

Remove critical security vulnerability where Discord service used hardcoded
"default-workspace" ID, bypassing Row-Level Security policies and creating
potential for cross-tenant data leakage.

Changes:
- Add DISCORD_WORKSPACE_ID environment variable requirement
- Add validation in connect() to require workspace configuration
- Replace hardcoded workspace ID with configured value
- Add 3 new tests for workspace configuration
- Update .env.example with security documentation

Security Impact:
- Multi-tenant isolation now properly enforced
- Each Discord bot instance must be configured for specific workspace
- Service fails fast if workspace ID not configured

Breaking Change:
- Existing deployments must set DISCORD_WORKSPACE_ID environment variable

Tests: All 21 Discord service tests passing (100%)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 11:41:38 -06:00
parent f6d4e07d31
commit cc6a5edfdf
4 changed files with 263 additions and 6 deletions

View File

@@ -71,6 +71,7 @@ describe("DiscordService", () => {
process.env.DISCORD_BOT_TOKEN = "test-token";
process.env.DISCORD_GUILD_ID = "test-guild-id";
process.env.DISCORD_CONTROL_CHANNEL_ID = "test-channel-id";
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
// Clear ready callbacks
mockReadyCallbacks.length = 0;
@@ -389,7 +390,7 @@ describe("DiscordService", () => {
});
expect(stitcherService.dispatchJob).toHaveBeenCalledWith({
workspaceId: "default-workspace",
workspaceId: "test-workspace-id",
type: "code-task",
priority: 10,
metadata: {
@@ -452,10 +453,84 @@ describe("DiscordService", () => {
process.env.DISCORD_BOT_TOKEN = "test-token";
});
it("should use default workspace if not configured", async () => {
// This is tested through the handleCommand test above
// which verifies workspaceId: 'default-workspace'
expect(true).toBe(true);
it("should throw error if DISCORD_WORKSPACE_ID is not set", async () => {
delete process.env.DISCORD_WORKSPACE_ID;
const module: TestingModule = await Test.createTestingModule({
providers: [
DiscordService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
],
}).compile();
const newService = module.get<DiscordService>(DiscordService);
await expect(newService.connect()).rejects.toThrow("DISCORD_WORKSPACE_ID is required");
// Restore for other tests
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
});
it("should use configured workspace ID from environment", async () => {
const testWorkspaceId = "configured-workspace-123";
process.env.DISCORD_WORKSPACE_ID = testWorkspaceId;
const module: TestingModule = await Test.createTestingModule({
providers: [
DiscordService,
{
provide: StitcherService,
useValue: mockStitcherService,
},
],
}).compile();
const newService = module.get<DiscordService>(DiscordService);
const message: ChatMessage = {
id: "msg-1",
channelId: "test-channel-id",
authorId: "user-1",
authorName: "TestUser",
content: "@mosaic fix 42",
timestamp: new Date(),
};
const mockThread = {
id: "thread-123",
send: vi.fn(),
isThread: () => true,
};
const mockChannel = {
isTextBased: () => true,
threads: {
create: vi.fn().mockResolvedValue(mockThread),
},
};
(mockClient.channels.fetch as any)
.mockResolvedValueOnce(mockChannel)
.mockResolvedValueOnce(mockThread);
await newService.connect();
await newService.handleCommand({
command: "fix",
args: ["42"],
message,
});
expect(mockStitcherService.dispatchJob).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: testWorkspaceId,
})
);
// Restore for other tests
process.env.DISCORD_WORKSPACE_ID = "test-workspace-id";
});
});
});

View File

@@ -26,10 +26,12 @@ export class DiscordService implements IChatProvider {
private connected = false;
private readonly botToken: string;
private readonly controlChannelId: string;
private readonly workspaceId: string;
constructor(private readonly stitcherService: StitcherService) {
this.botToken = process.env.DISCORD_BOT_TOKEN ?? "";
this.controlChannelId = process.env.DISCORD_CONTROL_CHANNEL_ID ?? "";
this.workspaceId = process.env.DISCORD_WORKSPACE_ID ?? "";
// Initialize Discord client with required intents
this.client = new Client({
@@ -91,6 +93,10 @@ export class DiscordService implements IChatProvider {
throw new Error("DISCORD_BOT_TOKEN is required");
}
if (!this.workspaceId) {
throw new Error("DISCORD_WORKSPACE_ID is required");
}
this.logger.log("Connecting to Discord...");
await this.client.login(this.botToken);
}
@@ -280,7 +286,7 @@ export class DiscordService implements IChatProvider {
// Dispatch job to stitcher
const result = await this.stitcherService.dispatchJob({
workspaceId: "default-workspace", // TODO: Get from configuration
workspaceId: this.workspaceId,
type: "code-task",
priority: 10,
metadata: {