mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Update private task enforcement and storage dependency
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
2758159166
23 files changed
+302
-63
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: strin
|
||||
status: "in_progress",
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
privacyOwnerUserId: userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
|
||||
Reference in new issue
Block a user