diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 0fb6b2aee32..a8572935481 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -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", diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 1622b3d6f33..932cd75f56a 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -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. diff --git a/doc/ISSUE-PRIVACY.md b/doc/ISSUE-PRIVACY.md index 486f7a7f55c..b10ae014156 100644 --- a/doc/ISSUE-PRIVACY.md +++ b/doc/ISSUE-PRIVACY.md @@ -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. diff --git a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts index 922cf656015..cfae22ee170 100644 --- a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts +++ b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts @@ -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" diff --git a/packages/db/src/migrations/0291_private_task_access.sql b/packages/db/src/migrations/0291_private_task_access.sql index b0cef34ef4f..a5c4f2af7a6 100644 --- a/packages/db/src/migrations/0291_private_task_access.sql +++ b/packages/db/src/migrations/0291_private_task_access.sql @@ -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 $$; diff --git a/packages/db/src/migrations/meta/0291_snapshot.json b/packages/db/src/migrations/meta/0291_snapshot.json index 37136cc36c0..3dbe8ce13d2 100644 --- a/packages/db/src/migrations/meta/0291_snapshot.json +++ b/packages/db/src/migrations/meta/0291_snapshot.json @@ -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": {} } -} \ No newline at end of file +} diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 813b05ff511..8cd7174625c 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -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), diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index c81f4aad97f..8924d5e7b33 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -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"), diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 78a8ffcbec0..1221420f412 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -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; diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index bde31e318ef..20e55ea9d95 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -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(), }), diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 2f4c7891f23..f86f9e4c1a1 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -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 }; diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index a927af19482..4452045d774 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -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." }); diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index b15070a9065..fb61f930b78 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -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(), })); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index be388307da3..769de2b1a57 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -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: () => ({ diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 1c73b9f2a87..a71cc5b3b2d 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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", diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 5fb90ce4803..5309ab2ddc4 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -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); } diff --git a/server/src/__tests__/privacy-production-review.test.ts b/server/src/__tests__/privacy-production-review.test.ts index 6b4fa92cbbc..d49bd961f56 100644 --- a/server/src/__tests__/privacy-production-review.test.ts +++ b/server/src/__tests__/privacy-production-review.test.ts @@ -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>; @@ -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(resolve => (wss as any).close(resolve)); diff --git a/server/src/realtime/live-events-ws.ts b/server/src/realtime/live-events-ws.ts index 8898874fdb5..b9989cd2fa0 100644 --- a/server/src/realtime/live-events-ws.ts +++ b/server/src/realtime/live-events-ws.ts @@ -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. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 83880f96d06..6d0a0320a28 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 8ed77fdeb37..58b04400f0d 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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)); diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 4c75f075273..7021b3e3825 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -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, diff --git a/server/src/services/heartbeat-run-privacy.ts b/server/src/services/heartbeat-run-privacy.ts index ec77b1c0a26..64027132012 100644 --- a/server/src/services/heartbeat-run-privacy.ts +++ b/server/src/services/heartbeat-run-privacy.ts @@ -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 & { redacted: true, }; } + +/** Check all durable sources, including tombstones left after a task/run is deleted. */ +export async function canActorReadWorkspaceOperation( + db: Db, + access: Parameters[1], + actor: AuthorizationActor, + operation: { companyId: string; issueId?: string | null; heartbeatRunId?: string | null; executionWorkspaceId?: string | null; metadata?: Record | null }, +): Promise { + 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); +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 21ef47e2df6..dd08bfa7b4c 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -99,6 +99,7 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin status: "in_progress", visibility: "private", personalOwnerUserId: userId, + privacyOwnerUserId: userId, }) .onConflictDoNothing() .returning()