fix(#195): Implement RLS context helpers consistently across all services
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Added workspace context management to PrismaService: - setWorkspaceContext(userId, workspaceId, client?) - Sets session variables - clearWorkspaceContext(client?) - Clears session variables - withWorkspaceContext(userId, workspaceId, fn) - Transaction wrapper Extended db-context.ts with workspace-scoped helpers: - setCurrentWorkspace(workspaceId, client) - setWorkspaceContext(userId, workspaceId, client) - clearWorkspaceContext(client) - withWorkspaceContext(userId, workspaceId, fn) All functions use SET LOCAL for transaction-scoped variables (connection pool safe). Added comprehensive tests (11 passing unit tests). Fixes #195 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -119,4 +119,86 @@ describe("PrismaService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaceContext", () => {
|
||||
it("should set workspace context variables in transaction", async () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
await service.$transaction(async (tx) => {
|
||||
await service.setWorkspaceContext(userId, workspaceId, tx);
|
||||
});
|
||||
|
||||
expect(executeRawSpy).toHaveBeenCalledTimes(2);
|
||||
// Check that both session variables were set
|
||||
expect(executeRawSpy).toHaveBeenNthCalledWith(1, expect.anything());
|
||||
expect(executeRawSpy).toHaveBeenNthCalledWith(2, expect.anything());
|
||||
});
|
||||
|
||||
it("should work when called outside transaction using default client", async () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
await service.setWorkspaceContext(userId, workspaceId);
|
||||
|
||||
expect(executeRawSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withWorkspaceContext", () => {
|
||||
it("should execute function with workspace context set", async () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
const result = await service.withWorkspaceContext(userId, workspaceId, async () => {
|
||||
return "test-result";
|
||||
});
|
||||
|
||||
expect(result).toBe("test-result");
|
||||
expect(executeRawSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should pass transaction client to callback", async () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
let receivedClient: unknown = null;
|
||||
await service.withWorkspaceContext(userId, workspaceId, async (tx) => {
|
||||
receivedClient = tx;
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(receivedClient).toBeDefined();
|
||||
expect(receivedClient).toHaveProperty("$executeRaw");
|
||||
});
|
||||
|
||||
it("should handle errors from callback", async () => {
|
||||
const userId = "user-123";
|
||||
const workspaceId = "workspace-456";
|
||||
vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
const error = new Error("Callback error");
|
||||
await expect(
|
||||
service.withWorkspaceContext(userId, workspaceId, async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearWorkspaceContext", () => {
|
||||
it("should clear workspace context variables", async () => {
|
||||
const executeRawSpy = vi.spyOn(service, "$executeRaw").mockResolvedValue(0);
|
||||
|
||||
await service.$transaction(async (tx) => {
|
||||
await service.clearWorkspaceContext(tx);
|
||||
});
|
||||
|
||||
expect(executeRawSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,4 +79,72 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
return { connected: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets workspace context for Row-Level Security (RLS)
|
||||
* Sets both user_id and workspace_id session variables for PostgreSQL RLS policies
|
||||
*
|
||||
* IMPORTANT: Must be called within a transaction or use the default client
|
||||
* Session variables are transaction-scoped (SET LOCAL) for connection pool safety
|
||||
*
|
||||
* @param userId - The ID of the authenticated user
|
||||
* @param workspaceId - The ID of the workspace context
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await prisma.$transaction(async (tx) => {
|
||||
* await prisma.setWorkspaceContext(userId, workspaceId, tx);
|
||||
* const tasks = await tx.task.findMany(); // Filtered by RLS
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async setWorkspaceContext(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
client: PrismaClient = this
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears workspace context session variables
|
||||
* Typically not needed as SET LOCAL is automatically cleared at transaction end
|
||||
*
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*/
|
||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with workspace context set within a transaction
|
||||
* Automatically sets the context and ensures proper scoping
|
||||
*
|
||||
* @param userId - The ID of the authenticated user
|
||||
* @param workspaceId - The ID of the workspace context
|
||||
* @param fn - Function to execute with context (receives transaction client)
|
||||
* @returns The result of the function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const tasks = await prisma.withWorkspaceContext(userId, workspaceId, async (tx) => {
|
||||
* return tx.task.findMany({
|
||||
* where: { status: 'IN_PROGRESS' }
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async withWorkspaceContext<T>(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
fn: (tx: PrismaClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
return this.$transaction(async (tx) => {
|
||||
await this.setWorkspaceContext(userId, workspaceId, tx as PrismaClient);
|
||||
return fn(tx as PrismaClient);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user