Files
PaperClipAI/server/src/services/task-workspace-source-access.ts
T
DottaandPaperclip 3452c67d86 refactor(workspaces): isolate source identity and align review fixtures
Extract source-root identity helpers and split policy regression suites.
Preserve behavior while applying current contributor source rules and
reconcile Storybook execution-default fixtures and catalog dependencies.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-12 17:53:55 -05:00

63 lines
3.8 KiB
TypeScript

import { executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { forbidden } from "../errors.js";
import { accessService } from "./access.js";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null };
export async function assertTaskWorkspaceSourceProjectAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {},
) {
if (!projectId) return;
const decision = await accessService(db as Db).decide({
actor, action: "project:read", resource: { type: "project", companyId, projectId },
});
if (!decision.allowed) throw forbidden(decision.explanation);
if (options.write) {
const [task] = actor.type === "board" && options.issueId
? await db.select({ assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
.where(and(eq(issues.id, options.issueId), eq(issues.companyId, companyId)))
: [];
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : task?.assigneeAgentId ?? options.assigneeAgentId ?? null;
const assigneeUserId = actor.type === "agent" ? null : task?.assigneeUserId ?? options.assigneeUserId ?? null;
const assignment = await accessService(db as Db).decide({
actor, action: "project:write_workspace",
resource: { type: "issue", companyId, projectId, assigneeAgentId, assigneeUserId,
issueId: options.issueId, parentIssueId: options.parentIssueId },
scope: { projectId, assigneeAgentId, assigneeUserId },
});
if (!assignment.allowed) throw forbidden(assignment.explanation);
}
}
export async function assertTaskWorkspaceAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: WorkspaceAccessContext = {},
) {
const [workspace] = await db.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
await executionWorkspaceReadSqlCondition(db, actor),
));
if (!workspace) throw forbidden("Task workspace access is no longer available");
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.strategyType !== "git_worktree" });
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
const sources = await db.select({ id: issues.id }).from(issues).where(and(
eq(issues.companyId, companyId),
or(eq(issues.executionWorkspaceId, workspaceId), retainedIssueIds.length > 0
? inArray(sql<string>`${issues.id}::text`, retainedIssueIds) : undefined),
));
const sourceIds = new Set(sources.map(source => source.id));
if (retainedIssueIds.some(id => !sourceIds.has(id))) throw forbidden("Task workspace source is no longer accessible");
const access = accessService(db as Db);
if (sources.length === 0 && !workspace.projectId) {
const decision = await access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } });
if (!decision.allowed) throw forbidden("An unscoped workspace is outside this actor's authorization boundary");
}
for (const source of sources) {
const decision = await access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: source.id } });
if (!decision.allowed) throw forbidden("Task workspace retains files outside this actor's authorization boundary");
}
}