mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.
Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
63 lines
3.8 KiB
TypeScript
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");
|
|
}
|
|
}
|