mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
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>
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");
|
|
}
|
|
}
|