Update private task enforcement and storage dependency

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 09:34:28 -05:00
commit 2758159166
23 files changed
+302 -63

No files matched your search

+1 -1
View File
@@ -1383,7 +1383,7 @@ describe("worktree helpers", () => {
.select()
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId));
expect(executionWorkspace?.metadata).toEqual({
expect(executionWorkspace?.metadata).toMatchObject({
keep: "execution-metadata",
config: {
environmentId: "environment-1",
+2
View File
@@ -474,3 +474,5 @@ reservation cannot silently disappear. Failed cleanup or an ambiguous storage
write requires operator reconciliation before an unattached reservation is
removed. The table stores no response bodies. See `doc/runner-api-tools.md` for
limits and the operator override.
Project `privacy_owner_user_id` records who may manage its audience independently of project read membership. Creation assigns the authenticated user or run responsible user; migration recovers legacy ownership from creation audit evidence. Missing evidence leaves management with administrators.
+11
View File
@@ -56,6 +56,11 @@ Company run lists retain only timing/status/token/cost metadata for
non-members so budget oversight continues without exposing task identity or run
content.
Workspace operation records retain their original task and run sources even if
those entities are deleted or the operation is relinked. Deletion preserves the
audit record; unresolved sources make its details and log inaccessible. A
company-scoped run does not override a private task linked to the operation.
> **Residual risk — trusted agents (`trust-agent`).** Paperclip enforces privacy
> on reads from the control plane, but an agent that legitimately processes a
> private task may write learned content into shared persistent memory, its home
@@ -164,3 +169,9 @@ it protects:
Adding a new task-derived read surface requires a non-member fixture in this
gate before the surface can ship.
Project privacy management belongs to its recorded creator (the responsible user
for agent-created projects), the personal-project owner, and administrators.
Project read membership alone never allows publishing the project or changing
its audience. Legacy projects recover ownership from their creation audit event;
when that evidence is missing, an administrator manages their privacy.
@@ -64,7 +64,19 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => {
(${maintenanceRunId}, ${companyId}, ${agentId}, 'succeeded', NULL, ${sql.json({ wakeReason: "heartbeat_timer" })})
`;
const legacyProjectId = randomUUID();
await sql`INSERT INTO projects (id, company_id, name, visibility) VALUES (${legacyProjectId}, ${companyId}, 'Legacy private project', 'private')`;
await sql`INSERT INTO activity_log (company_id, actor_type, actor_id, action, entity_type, entity_id)
VALUES (${companyId}, 'user', 'original-owner', 'project.created', 'project', ${legacyProjectId})`;
await sql`INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id)
VALUES (${companyId}, ${legacyProjectId}, 'user', 'ordinary-reader')`;
await applyPendingMigrations(database.connectionString);
const [legacyProject] = await sql`SELECT privacy_owner_user_id FROM projects WHERE id = ${legacyProjectId}`;
expect(legacyProject.privacy_owner_user_id).toBe('original-owner');
expect(await sql`SELECT subject_id FROM project_access_members WHERE project_id = ${legacyProjectId} ORDER BY subject_id`)
.toEqual([{ subject_id: 'ordinary-reader' }, { subject_id: 'original-owner' }]);
const rows = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>`
SELECT "id", "issue_id", "scope_kind"
@@ -74,12 +74,12 @@ DO $$ BEGIN
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspace_operations_issue_id_issues_id_fk' AND conrelid = 'public.workspace_operations'::regclass) THEN
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_issue_created_idx" ON "heartbeat_runs" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
@@ -230,3 +230,65 @@ BEGIN
cursor_id := next_id;
END LOOP;
END $$;
--> statement-breakpoint
-- Preserve operation history while retaining every original task/run boundary.
CREATE OR REPLACE FUNCTION paperclip_keep_operation_privacy_sources() RETURNS trigger AS $$
DECLARE issue_sources jsonb := '{}'::jsonb; run_sources jsonb := '{}'::jsonb;
BEGIN
IF TG_OP = 'UPDATE' THEN
issue_sources := COALESCE(OLD.metadata->'_issuePrivacySources', '{}'::jsonb);
run_sources := COALESCE(OLD.metadata->'_runPrivacySources', '{}'::jsonb);
IF OLD.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(OLD.issue_id::text, true); END IF;
IF OLD.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(OLD.heartbeat_run_id::text, true); END IF;
END IF;
IF NEW.issue_id IS NOT NULL THEN issue_sources := issue_sources || jsonb_build_object(NEW.issue_id::text, true); END IF;
IF NEW.heartbeat_run_id IS NOT NULL THEN run_sources := run_sources || jsonb_build_object(NEW.heartbeat_run_id::text, true); END IF;
NEW.metadata := COALESCE(NEW.metadata, '{}'::jsonb) || jsonb_build_object('_issuePrivacySources', issue_sources, '_runPrivacySources', run_sources);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
DROP TRIGGER IF EXISTS workspace_operations_keep_privacy_sources ON workspace_operations;
--> statement-breakpoint
CREATE TRIGGER workspace_operations_keep_privacy_sources BEFORE INSERT OR UPDATE ON workspace_operations
FOR EACH ROW EXECUTE FUNCTION paperclip_keep_operation_privacy_sources();
--> statement-breakpoint
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[];
BEGIN
LOOP
SELECT array_agg(id ORDER BY id) INTO batch_ids FROM (
SELECT id FROM workspace_operations WHERE id > cursor_id ORDER BY id LIMIT 1000
) batch;
EXIT WHEN batch_ids IS NULL;
UPDATE workspace_operations SET metadata = metadata WHERE id = ANY(batch_ids);
cursor_id := batch_ids[array_length(batch_ids, 1)];
END LOOP;
END $$;
--> statement-breakpoint
ALTER TABLE projects ADD COLUMN IF NOT EXISTS privacy_owner_user_id text;
--> statement-breakpoint
-- Recover management authority from creation evidence, never from read grants.
DO $$
DECLARE cursor_id uuid := '00000000-0000-0000-0000-000000000000'; batch_ids uuid[];
BEGIN
LOOP
SELECT array_agg(id ORDER BY id) INTO batch_ids FROM (
SELECT id FROM projects WHERE id > cursor_id ORDER BY id LIMIT 1000
) batch;
EXIT WHEN batch_ids IS NULL;
UPDATE projects p SET privacy_owner_user_id = COALESCE(p.personal_owner_user_id, (
SELECT CASE WHEN a.actor_type = 'user' THEN a.actor_id ELSE r.responsible_user_id END
FROM activity_log a LEFT JOIN heartbeat_runs r ON r.id = a.run_id AND r.company_id = a.company_id
WHERE a.company_id = p.company_id AND a.entity_type = 'project' AND a.entity_id = p.id::text AND a.action = 'project.created'
ORDER BY a.created_at, a.id LIMIT 1
)) WHERE p.id = ANY(batch_ids) AND p.privacy_owner_user_id IS NULL;
INSERT INTO project_access_members (company_id, project_id, subject_type, subject_id)
SELECT company_id, id, 'user', privacy_owner_user_id FROM projects
WHERE id = ANY(batch_ids) AND visibility = 'private' AND privacy_owner_user_id IS NOT NULL
ON CONFLICT (project_id, subject_type, subject_id) DO NOTHING;
cursor_id := batch_ids[array_length(batch_ids, 1)];
END LOOP;
END $$;
+9 -3
View File
@@ -38512,6 +38512,12 @@
"notNull": true,
"default": "'open'"
},
"privacy_owner_user_id": {
"name": "privacy_owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"personal_owner_user_id": {
"name": "personal_owner_user_id",
"type": "text",
@@ -49984,7 +49990,7 @@
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onDelete": "set null",
"onUpdate": "no action"
},
"workspace_operations_issue_id_issues_id_fk": {
@@ -49997,7 +50003,7 @@
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onDelete": "set null",
"onUpdate": "no action"
}
},
@@ -50438,4 +50444,4 @@
"schemas": {},
"tables": {}
}
}
}
+1
View File
@@ -14,6 +14,7 @@ export const projects = pgTable(
name: text("name").notNull(),
description: text("description"),
visibility: text("visibility").notNull().default("open"),
privacyOwnerUserId: text("privacy_owner_user_id"),
personalOwnerUserId: text("personal_owner_user_id"),
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
@@ -23,10 +23,10 @@ export const workspaceOperations = pgTable(
onDelete: "set null",
}),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, {
onDelete: "cascade",
onDelete: "set null",
}),
issueId: uuid("issue_id").references(() => issues.id, {
onDelete: "cascade",
onDelete: "set null",
}),
phase: text("phase").notNull(),
command: text("command"),
+2
View File
@@ -102,6 +102,8 @@ export interface Project {
visibility?: ProjectVisibility;
/** Present only for the lazily-created per-user "My private tasks" project. */
personalOwnerUserId?: string | null;
/** Only this user and administrators may change the project audience. */
privacyOwnerUserId?: string | null;
status: ProjectStatus;
leadAgentId: string | null;
targetDate: string | null;
@@ -63,10 +63,10 @@ vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
decide: vi.fn(async (input: { action: string }) => ({
allowed: input.action === "project:read",
allowed: ["project:read", "issue:read"].includes(input.action),
action: input.action,
reason: input.action === "project:read" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "project:read" ? "Allowed by test default." : "Missing permission.",
reason: ["project:read", "issue:read"].includes(input.action) ? "allow_explicit_grant" : "deny_missing_grant",
explanation: ["project:read", "issue:read"].includes(input.action) ? "Allowed by test default." : "Missing permission.",
})),
hasPermission: vi.fn(),
}),
@@ -726,6 +726,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
claimedAt: now,
});
if (input?.includeIssue !== false) {
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover local adapter after lost process",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
}
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
@@ -753,19 +767,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
seq: 1, eventType: "adapter.invoke", payload: { adapterType: input?.adapterType ?? "codex_local" } });
if (input?.includeIssue !== false) {
await db.insert(issues).values({
id: issueId,
companyId,
title: "Recover local adapter after lost process",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
checkoutRunId: runId,
executionRunId: runId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
await db.update(issues).set({ checkoutRunId: runId, executionRunId: runId }).where(eq(issues.id, issueId));
}
return { companyId, agentId, runId, wakeupRequestId, issueId };
@@ -883,7 +883,7 @@ describe("agent issue mutation checkout ownership", () => {
it("allows mentioned peer agents to post comments without ownership of an active checkout", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment",
allowed: input.action === "issue:comment" || input.action === "issue:read",
action: input.action,
reason: input.action === "issue:comment" ? "allow_issue_mention_grant" : "deny_missing_grant",
explanation:
@@ -1037,7 +1037,7 @@ describe("agent issue mutation checkout ownership", () => {
it("allows visible issue field updates for peer agents", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:mutate",
allowed: input.action === "issue:comment" || input.action === "issue:mutate" || input.action === "issue:read",
action: input.action,
reason:
input.action === "issue:comment"
@@ -1336,6 +1336,9 @@ describe("agent issue mutation checkout ownership", () => {
});
it("defaults agent-created root follow-up issues to inherit the current run workspace", async () => {
mockProjectService.getById.mockResolvedValue({
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", companyId, visibility: "open",
} as never);
const app = await createApp(
ownerActor(),
createRunContextDb({
@@ -1636,7 +1639,7 @@ describe("agent issue mutation checkout ownership", () => {
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts",
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts" || input.action === "issue:read",
action: input.action,
reason:
input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts"
@@ -2011,7 +2014,9 @@ describe("agent issue mutation checkout ownership", () => {
});
it.each(["agent", "viewer"])("rejects %s export-only retries before admission", async kind => {
mockAccessService.decide.mockResolvedValue({ allowed: false, explanation: "No runtime access" });
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read", explanation: "The viewer can read but has no runtime access",
}));
const res = await request(await createApp(kind === "agent" ? ownerActor() : boardActor()))
.post(`/api/issues/${issueId}/recovery-actions/retry-workspace-export`)
.send({ actionId: recoveryActionId, runId: ownerRunId, repairNote: "Restored provider connectivity and preserved all saved files." });
@@ -29,6 +29,12 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(async (input: { action: string }) => ({
allowed: input.action === "issue:read" || input.action === "project:read",
action: input.action,
reason: "allow_test_read",
explanation: "The fixture actor can read the task.",
})),
hasPermission: vi.fn(),
}));
@@ -39,6 +39,12 @@ vi.mock("../services/index.js", () => ({
}),
accessService: () => ({
canUser: vi.fn(),
decide: vi.fn(async (input: { action: string }) => ({
allowed: input.action === "issue:read" || input.action === "project:read",
action: input.action,
reason: "allow_test_read",
explanation: "The fixture actor can read the task.",
})),
hasPermission: vi.fn(),
}),
agentService: () => ({
+1 -1
View File
@@ -5785,7 +5785,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
.where(eq(executionWorkspaces.id, executionWorkspaceId))
.then((rows) => rows[0] ?? null);
expect(workspace?.metadata).toEqual({
expect(workspace?.metadata).toMatchObject({
config: {
environmentId: null,
provisionCommand: "bash ./scripts/provision-new.sh",
@@ -1256,10 +1256,8 @@ describeEmbeddedPostgres(
expect(
unmentionedComment.status,
JSON.stringify(unmentionedComment.body),
).toBe(403);
expect(unmentionedComment.body.details.code).toBe(
"issue_write_actor_class_excluded",
);
).toBe(404);
expect(unmentionedComment.body).toEqual({ error: "Issue not found" });
});
it("propagates denied low-trust policy conflicts on control-plane guards", async () => {
@@ -1626,7 +1624,7 @@ describeEmbeddedPostgres(
const before = await snapshot(db);
const res = await attempt.req();
expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(
403,
["LT approvals", "LT-15/16", "LT-19", "LT-26 child", "LT-26 company issue", "LT-26 interaction", "LT-06 resume", "LT-06 blocker mutation"].includes(attempt.id) ? 403 : 404,
);
expectNoCanary(res.body, ...forbiddenMarkers);
const after = await snapshot(db);
@@ -1769,7 +1767,7 @@ describeEmbeddedPostgres(
for (const attempt of attempts) {
const res = await attempt.req();
expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(
403,
404,
);
expectNoCanary(res.body, ...forbiddenMarkers);
}
@@ -7,9 +7,11 @@ import request from "supertest";
import WebSocket from "ws";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, approvals, assets, chatEndpoints, chatExternalPrincipals, chatConversations, chatIdentityLinks, chatDeliveries, toolApplications, toolConnections, executionWorkspaces, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, projectAccessMembers, projects, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { workspaceOperations, agents, approvals, assets, chatEndpoints, chatExternalPrincipals, chatConversations, chatIdentityLinks, chatDeliveries, toolApplications, toolConnections, executionWorkspaces, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, projectAccessMembers, projects, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import { authorizationService, canPublishIssueToChatAudience, canActorReadIssuePrivacy, issueReadSqlCondition, canActorReadProjectPrivacy, projectReadSqlCondition, canActorReadApproval, approvalReadSqlCondition, canActorReadExecutionWorkspace, type AuthorizationActor } from "../services/authorization.js";
import { canActorReadWorkspaceOperation } from "../services/heartbeat-run-privacy.js";
// Production regressions and the approved downward-only sharing contract.
describe("private task production review", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
@@ -31,6 +33,35 @@ describe("private task production review", () => {
return { company, owner, outsider, agent, actor };
}
it("lets only the project privacy owner or an admin change its audience", async () => {
const f = await fixture();
const { projectRoutes } = await import("../routes/projects.js");
const { errorHandler } = await import("../middleware/index.js");
let actor = f.actor(f.owner);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", projectRoutes(db));
app.use(errorHandler);
const created = await request(app).post(`/api/companies/${f.company.id}/projects`).send({ name: "Private project", visibility: "private", privacyOwnerUserId: f.outsider });
expect(created.status).toBe(201);
expect(created.body.privacyOwnerUserId).toBe(f.owner);
const projectId = created.body.id;
const member = await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "user", subjectId: f.outsider });
expect(member.status).toBe(201);
actor = f.actor(f.outsider);
expect((await request(app).get(`/api/projects/${projectId}`)).status).toBe(200);
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(403);
expect((await request(app).post(`/api/projects/${projectId}/access-members`).send({ subjectType: "agent", subjectId: f.agent.id })).status).toBe(403);
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(403);
actor = f.actor(f.owner);
expect((await request(app).delete(`/api/projects/${projectId}/access-members/${member.body.id}`)).status).toBe(200);
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "open" })).status).toBe(200);
await db.update(companyMemberships).set({ membershipRole: "admin" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.outsider)));
actor = f.actor(f.outsider);
expect((await request(app).patch(`/api/projects/${projectId}`).send({ visibility: "private" })).status).toBe(200);
});
it("publishes private chat content only to a currently authorized linked DM recipient", async () => {
const f = await fixture();
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private chat", visibility: "private", responsibleUserId: f.owner }).returning();
@@ -208,6 +239,25 @@ describe("private task production review", () => {
expect(orphan).toMatchObject({ scopeKind: "issue", issueId: null });
});
it("keeps operation history private even with a company run and after task or run deletion", async () => {
const f = await fixture();
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded" }).returning();
expect(run.scopeKind).toBe("company");
const [operation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, issueId: task.id, heartbeatRunId: run.id, phase: "prepare", stdoutExcerpt: "PRIVATE_LOG" }).returning();
const access = authorizationService(db);
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), operation)).toBe(true);
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.outsider), operation)).toBe(false);
await db.delete(issues).where(eq(issues.id, task.id));
const [orphan] = await db.select().from(workspaceOperations).where(eq(workspaceOperations.id, operation.id));
expect(orphan).toMatchObject({ issueId: null, stdoutExcerpt: "PRIVATE_LOG", metadata: { _issuePrivacySources: { [task.id]: true } } });
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), orphan)).toBe(false);
await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
const [history] = await db.update(workspaceOperations).set({ metadata: {} }).where(eq(workspaceOperations.id, operation.id)).returning();
expect(history).toMatchObject({ heartbeatRunId: null, metadata: { _runPrivacySources: { [run.id]: true }, _issuePrivacySources: { [task.id]: true } } });
expect(await canActorReadWorkspaceOperation(db, access, f.actor(f.owner), history)).toBe(false);
});
it("delivers authorized live output, then stops delivery after revocation on the same socket", async () => {
const f = await fixture();
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
@@ -226,11 +276,15 @@ describe("private task production review", () => {
const publish = (chunk: string) => publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: task.id, runId: run.id, agentId: f.agent.id, chunk } });
publish("AUTHORIZED_CANARY");
await expect.poll(() => messages.some(message => message.includes("AUTHORIZED_CANARY"))).toBe(true);
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "authorized_activity", entityType: "issue", entityId: task.id } });
await expect.poll(() => messages.some(message => message.includes("authorized_activity"))).toBe(true);
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
publish("REVOKED_CANARY");
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "revoked_activity", entityType: "issue", entityId: task.id } });
publishLiveEvent({ companyId: f.company.id, type: "activity.logged", payload: { action: "queue_drained", entityType: "company", entityId: f.company.id } });
await expect.poll(() => messages.some(message => message.includes("queue_drained"))).toBe(true);
expect(messages.join(" ")).not.toContain("REVOKED_CANARY");
expect(messages.join(" ")).not.toContain("revoked_activity");
} finally {
socket.terminate();
await new Promise<void>(resolve => (wss as any).close(resolve));
+19 -8
View File
@@ -4,7 +4,7 @@ import { createRequire } from "node:module";
import type { Duplex } from "node:stream";
import { and, eq, isNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns } from "@paperclipai/db";
import { agentApiKeys, companyMemberships, instanceUserRoles, heartbeatRuns, issues, projects } from "@paperclipai/db";
import type { DeploymentMode, LiveEvent } from "@paperclipai/shared";
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
import { logger } from "../middleware/logger.js";
@@ -286,16 +286,27 @@ export function setupLiveEventsWebSocketServer(
if (!membership.length && !admin) return null;
}
const payload = event.payload;
const issueId = typeof payload.issueId === "string" ? payload.issueId : null;
if (issueId && !(await access.decide({ actor: context!.actor, action: "issue:read",
resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null;
const runId = typeof payload.runId === "string" ? payload.runId : null;
if (event.type.startsWith("heartbeat.run.")) {
if (!runId) return null;
const issueId = typeof payload.issueId === "string" ? payload.issueId
: payload.entityType === "issue" && typeof payload.entityId === "string" ? payload.entityId : null;
if (issueId) {
const [issue] = await db.select({ id: issues.id }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, event.companyId))).limit(1);
if (!issue || !(await access.decide({ actor: context!.actor, action: "issue:read",
resource: { type: "issue", companyId: event.companyId, issueId } })).allowed) return null;
}
if (payload.entityType === "project" && typeof payload.entityId === "string") {
const [project] = await db.select({ id: projects.id }).from(projects)
.where(and(eq(projects.id, payload.entityId), eq(projects.companyId, event.companyId))).limit(1);
if (!project || !(await access.decide({ actor: context!.actor, action: "project:read",
resource: { type: "project", companyId: event.companyId, projectId: project.id } })).allowed) return null;
}
const runId = typeof payload.runId === "string" ? payload.runId
: payload.entityType === "heartbeat_run" && typeof payload.entityId === "string" ? payload.entityId : null;
if (runId) {
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, event.companyId)));
if (!run || !(await canActorReadHeartbeatRun(db, access, context!.actor, run))) return null;
return event;
}
if (event.type.startsWith("heartbeat.run.")) return runId ? event : null;
if (event.type === "agent.session.goal.changed") return issueId ? event : null;
// Company-wide invalidations carry no task-derived content. Authorized
// clients obtain details through viewer-filtered HTTP reads.
+14 -13
View File
@@ -254,6 +254,7 @@ import {
} from "../services/change-consent-gate.js";
import {
canActorReadHeartbeatRun,
canActorReadWorkspaceOperation,
redactHeartbeatRunListRow,
} from "../services/heartbeat-run-privacy.js";
import {
@@ -7011,8 +7012,8 @@ export function agentRoutes(
const runId = readHeartbeatRunId(req);
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId);
const decoratedRun = heartbeat.decorateActiveRunStatus(run);
res.json(await runRedactions.redactForRun(
@@ -7488,8 +7489,8 @@ export function agentRoutes(
const runId = readHeartbeatRunId(req);
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
@@ -7508,8 +7509,8 @@ export function agentRoutes(
const runId = readHeartbeatRunId(req);
const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const offset = Number(req.query.offset ?? 0);
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
@@ -7526,28 +7527,28 @@ export function agentRoutes(
const runId = readHeartbeatRunId(req);
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions()));
const visibleOperations = [];
for (const operation of operations) {
if (await canActorReadWorkspaceOperation(db, access, req.actor, operation)) visibleOperations.push(operation);
}
res.json(redactCurrentUserValue(visibleOperations, await getCurrentUserRedactionOptions()));
});
router.get("/workspace-operations/:operationId/log", async (req, res) => {
const operationId = req.params.operationId as string;
const operation = await getAccessibleResource(req, res, workspaceOperations.getById(operationId), "Workspace operation not found");
if (!operation) return;
const operationRun = operation.heartbeatRunId
? await heartbeat.getRunLogAccess(operation.heartbeatRunId)
: null;
if (!(await assertRunReadAllowed(req, res, {
companyId: operation.companyId,
scopeKind: operationRun?.scopeKind ?? (operation.issueId ? "issue" : null),
issueId: operationRun?.issueId ?? operation.issueId ?? null,
}, "Workspace operation not found"))) return;
if (!(await assertRunTelemetryReadAllowed(req, res, operation.companyId))) return;
if (!(await canActorReadWorkspaceOperation(db, access, req.actor, operation))) {
res.status(404).json({ error: "Workspace operation not found" });
return;
}
const offset = Number(req.query.offset ?? 0);
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
+33 -4
View File
@@ -8,7 +8,7 @@ import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from
import { toolAccessService } from "../services/tool-access.js";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import { agents, authUsers, companyMemberships, projectAccessMembers } from "@paperclipai/db";
import { agents, authUsers, companyMemberships, instanceUserRoles, projectAccessMembers } from "@paperclipai/db";
import { inArray } from "drizzle-orm";
import {
addProjectAccessMemberSchema,
@@ -165,7 +165,30 @@ export function projectRoutes(db: Db) {
return false;
}
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string }, dbOrTx: Db = db) {
async function assertCanManageProjectPrivacy(req: Request, res: Response, project: {
companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null;
}) {
const actor = req.actor;
if (actor.type === "board" && actor.userId) {
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return true;
if (actor.userId === project.privacyOwnerUserId || actor.userId === project.personalOwnerUserId) return true;
const instanceAdmin = actor.source !== "cloud_tenant" && await db.select({ id: instanceUserRoles.id }).from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, actor.userId), eq(instanceUserRoles.role, "instance_admin"))).limit(1).then(rows => rows.length > 0);
if (instanceAdmin) return true;
const membership = await db.select({ role: companyMemberships.membershipRole }).from(companyMemberships)
.where(and(eq(companyMemberships.companyId, project.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"))).limit(1);
if (["owner", "admin"].includes(membership[0]?.role ?? "")) return true;
}
res.status(403).json({ error: "Only the project privacy owner or an administrator can change project access" });
return false;
}
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string; privacyOwnerUserId?: string | null; personalOwnerUserId?: string | null }, dbOrTx: Db = db) {
const ownerUserId = project.privacyOwnerUserId ?? project.personalOwnerUserId;
if (ownerUserId) await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId, projectId: project.id, subjectType: "user", subjectId: ownerUserId,
});
if (req.actor.type === "board" && req.actor.userId) {
await ensureProjectAccessMember(dbOrTx, {
companyId: project.companyId,
@@ -306,6 +329,7 @@ export function projectRoutes(db: Db) {
};
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
projectData.privacyOwnerUserId = req.actor.type === "board" ? req.actor.userId ?? null : req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : null;
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
? await projectToolContext(db, req.actor, true) : null;
await assertProjectEnvironmentSelection(
@@ -395,6 +419,9 @@ export function projectRoutes(db: Db) {
if (!existing) return;
if (!(await assertProjectReadAllowed(req, res, existing))) return;
const body = { ...req.body };
if (body.visibility !== undefined) {
if (!(await assertCanManageProjectPrivacy(req, res, existing))) return;
}
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
assertNoAgentHostWorkspaceCommandMutation(
req,
@@ -463,6 +490,7 @@ export function projectRoutes(db: Db) {
const id = req.params.id as string;
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
const subjectType = req.body.subjectType as "user" | "agent";
const subjectId = req.body.subjectId as string;
const subjectExists = subjectType === "agent"
@@ -507,6 +535,7 @@ export function projectRoutes(db: Db) {
const id = req.params.id as string;
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!(await assertCanManageProjectPrivacy(req, res, project))) return;
const member = await db.select().from(projectAccessMembers).where(and(
eq(projectAccessMembers.id, req.params.memberId as string),
eq(projectAccessMembers.companyId, project.companyId),
@@ -516,8 +545,8 @@ export function projectRoutes(db: Db) {
res.status(404).json({ error: "Project access member not found" });
return;
}
if (project.personalOwnerUserId === member.subjectId && member.subjectType === "user") {
res.status(422).json({ error: "The owner of My private tasks cannot be removed" });
if (member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId)) {
res.status(422).json({ error: "The project privacy owner cannot be removed" });
return;
}
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
+1
View File
@@ -174,6 +174,7 @@ export async function persistActivity(db: Db, input: LogActivityInput) {
}).returning({ id: activityLog.id });
const payload = {
issueId: input.issueId ?? (input.entityType === "issue" ? input.entityId : null),
actorType: input.actorType,
actorId: input.actorId,
action: input.action,
+31 -2
View File
@@ -1,7 +1,7 @@
import type { Db } from "@paperclipai/db";
import { issues } from "@paperclipai/db";
import { issues, heartbeatRuns } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import type { AuthorizationActor, AuthorizationDecision } from "./authorization.js";
import { canActorReadExecutionWorkspace, type AuthorizationActor, type AuthorizationDecision } from "./authorization.js";
type RunIssueBinding = {
companyId: string;
@@ -138,3 +138,32 @@ export function redactHeartbeatRunListRow<T extends Record<string, unknown> & {
redacted: true,
};
}
/** Check all durable sources, including tombstones left after a task/run is deleted. */
export async function canActorReadWorkspaceOperation(
db: Db,
access: Parameters<typeof canActorReadHeartbeatRun>[1],
actor: AuthorizationActor,
operation: { companyId: string; issueId?: string | null; heartbeatRunId?: string | null; executionWorkspaceId?: string | null; metadata?: Record<string, unknown> | null },
): Promise<boolean> {
const sourceIds = (key: string, current?: string | null) => {
const history = asRecord(operation.metadata?.[key]);
return new Set([...Object.keys(history ?? {}), ...(current ? [current] : [])]);
};
const issueIds = sourceIds("_issuePrivacySources", operation.issueId);
const runIds = sourceIds("_runPrivacySources", operation.heartbeatRunId);
for (const issueId of issueIds) {
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(issueId)) return false;
if (!(await canActorReadHeartbeatRun(db, access, actor, { companyId: operation.companyId, scopeKind: "issue", issueId }))) return false;
}
for (const runId of runIds) {
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(runId)) return false;
const [run] = await db.select().from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, operation.companyId))).limit(1);
if (!run || !(await canActorReadHeartbeatRun(db, access, actor, run))) return false;
}
if (operation.executionWorkspaceId) {
if (!(await canActorReadExecutionWorkspace(db, actor, operation.executionWorkspaceId))) return false;
}
return issueIds.size > 0 || runIds.size > 0 || Boolean(operation.executionWorkspaceId);
}
+1
View File
@@ -99,6 +99,7 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin
status: "in_progress",
visibility: "private",
personalOwnerUserId: userId,
privacyOwnerUserId: userId,
})
.onConflictDoNothing()
.returning()