mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
fix: retain task context in shared source authorization
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
1b41e74faa
commit
ab45588d13
6 files changed
+30
-18
No files matched your search
@@ -301,9 +301,16 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await expect(tasks.create(f.companyId, { title: "Denied legacy binding", createdByAgentId: agent.id,
|
||||
executionWorkspaceId: shared.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
|
||||
|
||||
const [organization] = await db.insert(projects).values({ companyId: f.companyId, name: "Task organization" }).returning();
|
||||
await db.update(issues).set({ projectId: organization.id }).where(eq(issues.id, f.issueId));
|
||||
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
|
||||
principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: agent.id } }).returning();
|
||||
principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: organization.id, assigneeAgentId: agent.id } }).returning();
|
||||
const selectionRequest = { ...f, actor, selection: sharedSelection, expectedBindingRevision: 0, requestKey: "authorized-shared" };
|
||||
// Task lineage and organizational-project authority cannot grant writes
|
||||
// to an independently protected source project's folder.
|
||||
await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/);
|
||||
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: agent.id } })
|
||||
.where(eq(principalPermissionGrants.id, grant.id));
|
||||
await expect(svc.selectTaskWorkspace(selectionRequest)).resolves.toMatchObject({ kind: "scheduled" });
|
||||
expect(await tasks.create(f.companyId, { title: "Authorized shared source", createdByAgentId: agent.id,
|
||||
workspaceSelection: sharedSelection, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
|
||||
|
||||
@@ -76,7 +76,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
// Serialize receipts across tasks without blocking unchanged-ID foreign-key reads.
|
||||
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))).for("no key update");
|
||||
if (!workspace || workspace.status !== "active") throw conflict("Execution workspace is unavailable");
|
||||
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true });
|
||||
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId });
|
||||
const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`)));
|
||||
for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef);
|
||||
if (existing[0]) {
|
||||
@@ -99,7 +99,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
/** Logical rows may deliberately name one physical folder. Reuse bytes only
|
||||
* after validating every published owner's retained privacy and source intent. */
|
||||
async function sharedPublicationPin(input: { row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) {
|
||||
async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) {
|
||||
const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces })
|
||||
.from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
|
||||
.where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId),
|
||||
@@ -113,7 +113,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue;
|
||||
assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef);
|
||||
if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access");
|
||||
await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true });
|
||||
await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true, issueId: input.issueId });
|
||||
if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
|
||||
pin = candidatePin;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
// local-board is a server identity, not a membership record. Preserve the
|
||||
// agent's own authority rather than promoting it to a board actor.
|
||||
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId };
|
||||
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true });
|
||||
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId });
|
||||
const root = await fs.realpath(input.cwd);
|
||||
const repositoryRoot = path.join(root, ".paperclip-repositories");
|
||||
await fs.mkdir(repositoryRoot, { recursive: true });
|
||||
@@ -194,7 +194,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
} catch { return null; } finally { await handle.close(); }
|
||||
};
|
||||
const initialOwnership = await readOwnership();
|
||||
const authorizedPin = await sharedPublicationPin({ row, root, actor, ownership: initialOwnership });
|
||||
const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, ownership: initialOwnership });
|
||||
if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) {
|
||||
throw conflict("Repository directory has no matching preparation receipt; repair it before retrying");
|
||||
}
|
||||
|
||||
@@ -1288,7 +1288,7 @@ const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseRe
|
||||
|
||||
export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) {
|
||||
/** Validate intent without allocating files or changing a task binding. */
|
||||
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection }, reader: Db | DbTransaction = db) {
|
||||
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null }, reader: Db | DbTransaction = db) {
|
||||
const selection = taskWorkspaceSelectionSchema.parse(input.selection);
|
||||
if (selection.kind === "existing") {
|
||||
const [workspace] = await reader.select().from(executionWorkspaces).where(and(
|
||||
@@ -1297,7 +1297,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
await executionWorkspaceReadSqlCondition(reader, input.actor),
|
||||
));
|
||||
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true });
|
||||
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId });
|
||||
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
|
||||
}
|
||||
@@ -1308,7 +1308,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
await projectReadSqlCondition(reader, input.actor),
|
||||
));
|
||||
if (!source) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared" });
|
||||
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared", issueId: input.issueId, parentIssueId: input.parentIssueId });
|
||||
return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: { mode: selection.mode === "shared" ? "shared_workspace" : "isolated_workspace" } };
|
||||
}
|
||||
|
||||
@@ -3205,10 +3205,10 @@ export function heartbeatService(
|
||||
if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
|
||||
if (workspaceRequiringAccess) {
|
||||
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true });
|
||||
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId });
|
||||
} else if (!isDotRun && executionProjectId && (selectedWorkspaceSource || projectContext?.hasWorkspace)) {
|
||||
// Check source authority before resolution can clone or expose files.
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace" });
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace", issueId });
|
||||
}
|
||||
}
|
||||
const requestedReusableExecutionWorkspaceConfig =
|
||||
@@ -3483,7 +3483,7 @@ export function heartbeatService(
|
||||
if (taskWorkspaceIntentRow?.intent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
|
||||
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
|
||||
selection: taskWorkspaceIntentRow.intent.selection });
|
||||
selection: taskWorkspaceIntentRow.intent.selection, issueId });
|
||||
}
|
||||
const explicitTaskDirectory = taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory";
|
||||
const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({
|
||||
|
||||
@@ -10166,7 +10166,7 @@ export function issueService(db: Db) {
|
||||
if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) {
|
||||
const projection = await executionWorkspaceService(db).validateSelection({ companyId,
|
||||
actor: workspaceActor,
|
||||
selection: workspaceSelection }, tx);
|
||||
selection: workspaceSelection, parentIssueId: issueData.parentId }, tx);
|
||||
projectWorkspaceId = projection.projectWorkspaceId;
|
||||
executionWorkspaceId = projection.executionWorkspaceId;
|
||||
executionWorkspacePreference = projection.executionWorkspacePreference;
|
||||
@@ -10266,6 +10266,7 @@ export function issueService(db: Db) {
|
||||
.where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor)));
|
||||
if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, {
|
||||
parentIssueId: issueData.parentId,
|
||||
write: !executionWorkspaceId && resolveExecutionWorkspaceMode({
|
||||
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled),
|
||||
issueSettings: parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings),
|
||||
@@ -10277,7 +10278,7 @@ export function issueService(db: Db) {
|
||||
const [readableWorkspace] = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId }).from(executionWorkspaces)
|
||||
.where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(tx, workspaceActor)));
|
||||
if (!readableWorkspace) throw notFound("Workspace is unavailable or inaccessible");
|
||||
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true });
|
||||
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId });
|
||||
}
|
||||
if (projectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(
|
||||
|
||||
@@ -5,10 +5,11 @@ 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 };
|
||||
|
||||
/** Filesystem source scope is independent of a task's organizational project. */
|
||||
export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: { write?: boolean } = {},
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {},
|
||||
) {
|
||||
if (!projectId) return;
|
||||
const decision = await accessService(db as Db).decide({
|
||||
@@ -21,7 +22,10 @@ export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : null;
|
||||
const assignment = await accessService(db as Db).decide({
|
||||
actor, action: "tasks:assign",
|
||||
resource: { type: "issue", companyId, projectId, assigneeAgentId },
|
||||
resource: { type: "issue", companyId, projectId, assigneeAgentId,
|
||||
issueId: options.issueId, parentIssueId: options.parentIssueId },
|
||||
// Lineage proves low-trust containment; grants must still cover the source
|
||||
// project independently of the task's organizational project.
|
||||
scope: { projectId, assigneeAgentId },
|
||||
});
|
||||
if (!assignment.allowed) throw forbidden(assignment.explanation);
|
||||
@@ -30,14 +34,14 @@ export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
|
||||
/** Retained files keep every task's authorization boundary after rebinding. */
|
||||
export async function assertTaskWorkspaceAccess(
|
||||
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: { write?: boolean } = {},
|
||||
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, { write: options.write && workspace.mode === "shared_workspace" });
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.mode === "shared_workspace" });
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user