mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314. Co-Authored-By: Paperclip <noreply@paperclip.ing>
488 lines
39 KiB
TypeScript
488 lines
39 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
import { Readable } from "node:stream";
|
|
import { once } from "node:events";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import WebSocket from "ws";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
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>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-review-");
|
|
db = createDb(tempDb.connectionString);
|
|
// Load the current route dependency graph during setup, outside assertion timeouts.
|
|
await Promise.all([import("../routes/issues.js"), import("../routes/projects.js")]);
|
|
}, 120_000);
|
|
afterAll(async () => { await tempDb?.cleanup(); });
|
|
|
|
async function fixture() {
|
|
const [company] = await db.insert(companies).values({ name: randomUUID(), issuePrefix: `T${randomUUID().slice(0, 5)}` }).returning();
|
|
const owner = randomUUID(), outsider = randomUUID();
|
|
await db.insert(authUsers).values([owner, outsider].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
|
await db.insert(companyMemberships).values([owner, outsider].map(principalId => ({ companyId: company.id, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
|
|
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Shared agent", role: "engineer", adapterType: "process", adapterConfig: {}, runtimeConfig: {}, permissions: {} }).returning();
|
|
const actor = (userId: string): AuthorizationActor => ({ type: "board", userId, source: "session", companyIds: [company.id] });
|
|
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();
|
|
const [application] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Test chat", type: "native" }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: application.id, name: "Test chat", uid: "test-chat", connectionPurpose: "channel", transport: "chat_sdk" }).returning();
|
|
const [endpoint] = await db.insert(chatEndpoints).values({ companyId: f.company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: f.agent.id }).returning();
|
|
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: f.company.id, provider: "slack", providerAccountId: "test", externalId: "person", kind: "user" }).returning();
|
|
const [conversation] = await db.insert(chatConversations).values({ companyId: f.company.id, endpointId: endpoint.id, issueId: task.id, externalConversationId: "dm", externalLabel: "Test DM", isDirectMessage: true }).returning();
|
|
const publication = { companyId: f.company.id, issueId: task.id, endpointId: endpoint.id, conversationId: conversation.id };
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.insert(chatDeliveries).values({ companyId: f.company.id, endpointId: endpoint.id, conversationId: conversation.id, principalId: principal.id, providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "message", normalizedEvent: {} as any });
|
|
const [link] = await db.insert(chatIdentityLinks).values({ companyId: f.company.id, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: f.outsider, status: "linked" }).returning();
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
|
await db.update(chatConversations).set({ isDirectMessage: false }).where(eq(chatConversations.id, conversation.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.update(chatConversations).set({ isDirectMessage: true }).where(eq(chatConversations.id, conversation.id));
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
await db.update(chatIdentityLinks).set({ paperclipUserId: f.owner }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(true);
|
|
await db.update(chatIdentityLinks).set({ revokedAt: new Date() }).where(eq(chatIdentityLinks.id, link.id));
|
|
expect(await canPublishIssueToChatAudience(db, publication)).toBe(false);
|
|
});
|
|
|
|
it("a shared agent cannot read an owner's private task on behalf of an unauthorized coworker", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, assigneeAgentId: f.agent.id }).returning();
|
|
const authz = authorizationService(db);
|
|
const resource = { type: "issue" as const, companyId: f.company.id, issueId: id };
|
|
expect((await authz.decide({ actor: f.actor(f.outsider), action: "issue:read", resource })).allowed).toBe(false);
|
|
expect((await authz.decide({ actor: { type: "agent", agentId: f.agent.id, companyId: f.company.id, source: "agent_jwt", onBehalfOfUserId: f.outsider }, action: "issue:read", resource })).allowed).toBe(false);
|
|
});
|
|
|
|
it("list SQL and direct checks agree after a project becomes open", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Previously private", visibility: "open" }).returning();
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
|
const id = randomUUID();
|
|
const [issue] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Still individually private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), issue)).toBe(false);
|
|
const found = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
|
expect(found).toEqual([]);
|
|
});
|
|
|
|
it("making a parent private also protects existing descendants", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Existing child", parentId: parent.id, createdByUserId: f.owner });
|
|
await svc.update(parent.id, { visibility: "private" });
|
|
const updated = await svc.getById(child.id);
|
|
expect(updated?.visibility).toBe("private");
|
|
});
|
|
|
|
it("restricting a project protects descendants outside it and preserves privacy when reopened", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const { projectService } = await import("../services/projects.js");
|
|
const svc = issueService(db);
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Project", visibility: "open" }).returning();
|
|
const root = await svc.create(f.company.id, { title: "Root", projectId: project.id, createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
|
await projectService(db).update(project.id, { visibility: "private" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
await projectService(db).update(project.id, { visibility: "open" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
expect((await svc.getById(child.id))?.visibility).toBe("private");
|
|
});
|
|
|
|
it("publishing a personal task leaves its private project while keeping its children private", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Personal", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Child", parentId: root.id, createdByUserId: f.owner });
|
|
const published = await svc.update(root.id, { visibility: "open" });
|
|
expect(published?.projectId).toBeNull();
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), published!)).toBe(true);
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
|
});
|
|
|
|
it("reparenting an open issue under a private task inherits privacy", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const parent = await svc.create(f.company.id, { title: "Parent", createdByUserId: f.owner, visibility: "private" });
|
|
const child = await svc.create(f.company.id, { title: "Moved child", createdByUserId: f.owner });
|
|
const updated = await svc.update(child.id, { parentId: parent.id });
|
|
expect(updated?.visibility).toBe("private");
|
|
});
|
|
|
|
it("allows task management hints in an unreadable personal project without disclosing its identity", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Protected personal context", visibility: "private", personalOwnerUserId: f.outsider, privacyOwnerUserId: f.outsider }).returning();
|
|
const id = randomUUID();
|
|
const [task] = await db.insert(issues).values({ id, companyId: f.company.id, title: "Owned task", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, projectId: project.id }).returning();
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const { projectRoutes } = await import("../routes/projects.js");
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any)); app.use("/api", projectRoutes(db));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/projects/${project.id}`).expect(404);
|
|
const response = await request(app).get(`/api/issues/${task.id}/privacy-constraints`).expect(200);
|
|
expect(response.body).toEqual({ publicBlockedBy: null, leavesPersonalProject: true });
|
|
const { issueService } = await import("../services/issues.js");
|
|
expect(await issueService(db).update(task.id, { visibility: "open" })).toMatchObject({ visibility: "open", projectId: null });
|
|
});
|
|
|
|
it("reports an inaccessible private parent as a move-first constraint without leaking it", async () => {
|
|
const f = await fixture();
|
|
const parentId = randomUUID(), childId = randomUUID();
|
|
await db.insert(issues).values({ id: parentId, companyId: f.company.id, title: "Hidden parent content", visibility: "private", privacyRootIssueId: parentId, responsibleUserId: f.outsider });
|
|
await db.insert(issues).values({ id: childId, companyId: f.company.id, title: "Owned child", visibility: "private", privacyRootIssueId: parentId, privacyParentIssueId: parentId, responsibleUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
await request(app).get(`/api/issues/${parentId}`).expect(404);
|
|
const response = await request(app).get(`/api/issues/${childId}/privacy-constraints`).expect(200);
|
|
expect(response.body).toEqual({ publicBlockedBy: "parent", leavesPersonalProject: false });
|
|
});
|
|
|
|
it("does not expose task management hints to a shared reader or unrelated company", async () => {
|
|
const f = await fixture(); const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
let actor = f.actor(f.outsider);
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
|
await db.insert(issueAccessGrants).values({ issueId: id, subjectType: "user", subjectId: f.outsider, source: "explicit" });
|
|
await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(403);
|
|
actor = { ...f.actor(f.owner), companyIds: [] };
|
|
await db.update(companyMemberships).set({ status: "archived" }).where(and(eq(companyMemberships.companyId, f.company.id), eq(companyMemberships.principalId, f.owner)));
|
|
const denied = await request(app).get(`/api/issues/${id}/privacy-constraints`).expect(404);
|
|
expect(denied.body.publicBlockedBy).toBeUndefined();
|
|
});
|
|
|
|
it("an unauthorized board user cannot fetch a private issue through PATCH", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Confidential acquisition", description: "Private email content", status: "backlog", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner, createdByUserId: f.owner });
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.outsider) as Express.Request["actor"]; next(); });
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { res.status(err.status ?? 500).json({ error: err.message }); });
|
|
await request(app).get(`/api/issues/${id}`).expect(404);
|
|
const response = await request(app).patch(`/api/issues/${id}`).send({ priority: "low" });
|
|
expect({ status: response.status, title: response.body.title }).toEqual({ status: 404, title: undefined });
|
|
});
|
|
|
|
it("sharing a child grants its descendants, never its parent or sibling, and revokes immediately", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Shared child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Grandchild", parentId: child.id, createdByUserId: f.owner });
|
|
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.owner });
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: child.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), task)).toBe(expected);
|
|
const rows = await db.select({ id: issues.id }).from(issues).where(and(eq(issues.id, task.id), await issueReadSqlCondition(db, f.actor(f.outsider))));
|
|
expect(rows.length > 0).toBe(expected);
|
|
}
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), child)).toBe(false);
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), grandchild)).toBe(false);
|
|
});
|
|
|
|
it("binds child-manager grant writes to the child and rejects ancestor or sibling grants", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const { issueRoutes } = await import("../routes/issues.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Ancestor", visibility: "private", createdByUserId: f.outsider });
|
|
const child = await svc.create(f.company.id, { title: "Managed child", parentId: root.id, createdByUserId: f.owner });
|
|
const grandchild = await svc.create(f.company.id, { title: "Descendant", parentId: child.id, createdByUserId: f.outsider });
|
|
const sibling = await svc.create(f.company.id, { title: "Sibling", parentId: root.id, createdByUserId: f.outsider });
|
|
const [rootGrant] = await db.insert(issueAccessGrants).values({ issueId: root.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
|
const [siblingGrant] = await db.insert(issueAccessGrants).values({ issueId: sibling.id, subjectType: "user", subjectId: f.owner, source: "explicit" }).returning();
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = f.actor(f.owner); next(); });
|
|
app.use("/api", issueRoutes(db, {} as any)); app.use(errorHandler);
|
|
const audience = await request(app).get(`/api/issues/${child.id}/access-grants`).expect(200);
|
|
expect(audience.body).toContainEqual(expect.objectContaining({ id: rootGrant.id, inherited: true }));
|
|
for (const grant of [rootGrant, siblingGrant]) {
|
|
await request(app).post(`/api/issues/${child.id}/access-grants/${grant.id}/revoke`).send({}).expect(404);
|
|
const [unchanged] = await db.select().from(issueAccessGrants).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(unchanged.revokedAt).toBeNull();
|
|
}
|
|
for (const task of [root, sibling]) {
|
|
await request(app).post(`/api/issues/${task.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(403);
|
|
}
|
|
const added = await request(app).post(`/api/issues/${child.id}/access-grants`).send({ subjectType: "agent", subjectId: f.agent.id }).expect(201);
|
|
expect(added.body.issueId).toBe(child.id);
|
|
const agent: AuthorizationActor = { type: "agent", companyId: f.company.id, agentId: f.agent.id };
|
|
for (const [task, expected] of [[root, false], [child, true], [grandchild, true], [sibling, false]] as const) {
|
|
expect(await canActorReadIssuePrivacy(db, agent, task)).toBe(expected);
|
|
}
|
|
await request(app).post(`/api/issues/${child.id}/access-grants/${added.body.id}/revoke`).send({}).expect(200);
|
|
expect(await canActorReadIssuePrivacy(db, agent, child)).toBe(false);
|
|
expect(await canActorReadIssuePrivacy(db, agent, grandchild)).toBe(false);
|
|
});
|
|
|
|
it("retains a child assignment grant after unassignment without granting the root", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const root = await svc.create(f.company.id, { title: "Root", visibility: "private", createdByUserId: f.owner });
|
|
const child = await svc.create(f.company.id, { title: "Assigned child", parentId: root.id, createdByUserId: f.owner, assigneeAgentId: f.agent.id });
|
|
await svc.update(child.id, { assigneeAgentId: null });
|
|
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id };
|
|
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(true);
|
|
expect(await canActorReadIssuePrivacy(db, actor, root)).toBe(false);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, child.id));
|
|
expect(await canActorReadIssuePrivacy(db, actor, child)).toBe(false);
|
|
});
|
|
|
|
it("protects standalone tasks created from a private run", async () => {
|
|
const f = await fixture();
|
|
const { issueService } = await import("../services/issues.js");
|
|
const svc = issueService(db);
|
|
const source = await svc.create(f.company.id, { title: "Private conversation", visibility: "private", createdByUserId: f.owner });
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: source.id }).returning();
|
|
const handoff = await svc.create(f.company.id, { title: "Standalone handoff", createdByUserId: f.owner, originRunId: run.id });
|
|
expect(handoff).toMatchObject({ parentId: null, privacyParentIssueId: source.id, visibility: "private" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor(f.outsider), handoff)).toBe(false);
|
|
});
|
|
|
|
it("intersects agent and responsible-user access to private projects in direct and list reads", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Private project", visibility: "private" }).returning();
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "agent", subjectId: f.agent.id });
|
|
const actor: AuthorizationActor = { type: "agent", agentId: f.agent.id, companyId: f.company.id, onBehalfOfUserId: f.outsider };
|
|
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(false);
|
|
expect(await db.select().from(projects).where(and(eq(projects.id, project.id), await projectReadSqlCondition(db, actor)))).toEqual([]);
|
|
await db.insert(projectAccessMembers).values({ companyId: f.company.id, projectId: project.id, subjectType: "user", subjectId: f.outsider });
|
|
expect(await canActorReadProjectPrivacy(db, actor, project)).toBe(true);
|
|
});
|
|
|
|
it("binds native runs and keeps missing-task history fail closed", 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", nativeIssueId: task.id }).returning();
|
|
expect(run).toMatchObject({ scopeKind: "issue", issueId: task.id });
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: {}, scopeKind: "company" }).where(eq(heartbeatRuns.id, run.id));
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id))).toMatchObject([{ scopeKind: "issue", issueId: task.id }]);
|
|
const [orphan] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: randomUUID() } }).returning();
|
|
expect(orphan).toMatchObject({ scopeKind: "issue", issueId: null });
|
|
});
|
|
|
|
it("filters private and orphaned operation excerpts from a shared workspace listing", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Shared" }).returning();
|
|
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Shared workspace" }).returning();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, issueId: task.id, phase: "prepare", stdoutExcerpt: "PRIVATE_OPERATION" });
|
|
const [publicOperation] = await db.insert(workspaceOperations).values({ companyId: f.company.id, executionWorkspaceId: workspace.id, phase: "prepare", stdoutExcerpt: "PUBLIC_OPERATION" }).returning();
|
|
const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js");
|
|
const { errorHandler } = await import("../middleware/index.js");
|
|
let actor = f.actor(f.outsider);
|
|
const app = express(); app.use(express.json());
|
|
app.use((req, _res, next) => { req.actor = actor; next(); });
|
|
app.use("/api", executionWorkspaceRoutes(db)); app.use(errorHandler);
|
|
const path = `/api/execution-workspaces/${workspace.id}/workspace-operations`;
|
|
const outside = await request(app).get(path);
|
|
expect(outside.status).toBe(200);
|
|
expect(outside.body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
|
actor = f.actor(f.owner);
|
|
expect((await request(app).get(path)).body).toHaveLength(2);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
expect((await request(app).get(path)).body.map((row: { id: string }) => row.id)).toEqual([publicOperation.id]);
|
|
});
|
|
|
|
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();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: f.agent.id, invocationSource: "on_demand", status: "succeeded", nativeIssueId: task.id }).returning();
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
|
const { publishLiveEvent } = await import("../services/live-events.js");
|
|
const server = createServer();
|
|
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/${f.company.id}/events/ws`);
|
|
const messages: string[] = [];
|
|
socket.on("message", data => messages.push(data.toString()));
|
|
try {
|
|
await once(socket, "open");
|
|
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));
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
|
|
it("protects linked approval payloads and execution workspace details", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.company.id, name: "Public project" }).returning();
|
|
const [task] = await db.insert(issues).values({ companyId: f.company.id, projectId: project.id, title: "Private", visibility: "private", responsibleUserId: f.owner }).returning();
|
|
const [approval] = await db.insert(approvals).values({ companyId: f.company.id, type: "hire_agent", payload: { sourceIssueId: task.id, instructions: "PRIVATE_PAYLOAD" } }).returning();
|
|
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.company.id, projectId: project.id, sourceIssueId: task.id, mode: "isolated_workspace", strategyType: "git_worktree", name: "Private workspace" }).returning();
|
|
expect(await canActorReadApproval(db, f.actor(f.owner), approval.id)).toBe(true);
|
|
expect(await canActorReadApproval(db, f.actor(f.outsider), approval.id)).toBe(false);
|
|
expect(await db.select().from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor(f.outsider))))).toEqual([]);
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.owner), workspace.id)).toBe(true);
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
await db.update(executionWorkspaces).set({ sourceIssueId: null, metadata: {} }).where(eq(executionWorkspaces.id, workspace.id));
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
expect(await canActorReadExecutionWorkspace(db, f.actor(f.outsider), workspace.id)).toBe(false);
|
|
});
|
|
|
|
it("keeps inline draft images owner-only until publication then follows the task grant", async () => {
|
|
const f = await fixture();
|
|
const [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/assets/issues/drafts/image.png`, contentType: "image/png", byteSize: 6, sha256: "test", createdByUserId: f.owner }).returning();
|
|
const { assetRoutes } = await import("../routes/assets.js");
|
|
const { issueService } = await import("../services/issues.js");
|
|
function appFor(userId: string) {
|
|
const app = express();
|
|
app.use((req, _res, next) => { req.actor = f.actor(userId) as any; next(); });
|
|
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
|
return app;
|
|
}
|
|
const url = `/api/assets/${asset.id}/content`;
|
|
await request(appFor(f.owner)).get(url).expect(200);
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
const task = await issueService(db).create(f.company.id, { title: "Private draft", visibility: "private", createdByUserId: f.owner, description: `` });
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: task.id, subjectType: "user", subjectId: f.outsider, source: "explicit" }).returning();
|
|
await request(appFor(f.outsider)).get(url).expect(200);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
await request(appFor(f.outsider)).get(url).expect(404);
|
|
});
|
|
|
|
it("protects attachment assets even after their attachment or task is deleted", 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 [asset] = await db.insert(assets).values({ companyId: f.company.id, provider: "local_disk", objectKey: `${f.company.id}/issues/${task.id}/file.txt`, contentType: "text/plain", byteSize: 6, sha256: "test" }).returning();
|
|
const { assetRoutes } = await import("../routes/assets.js");
|
|
const appFor = (userId: string) => {
|
|
const app = express();
|
|
app.use((req, _res, next) => { req.actor = f.actor(userId) as Express.Request["actor"]; next(); });
|
|
app.use("/api", assetRoutes(db, { getObject: async () => ({ stream: Readable.from("SECRET"), contentLength: 6 }) } as any));
|
|
return app;
|
|
};
|
|
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(200, "SECRET");
|
|
await request(appFor(f.outsider)).get(`/api/assets/${asset.id}/content`).expect(404);
|
|
await db.delete(issues).where(eq(issues.id, task.id));
|
|
await request(appFor(f.owner)).get(`/api/assets/${asset.id}/content`).expect(404);
|
|
});
|
|
|
|
it("live WebSocket delivery does not disclose a private run to an unauthorized member", async () => {
|
|
const f = await fixture();
|
|
const id = randomUUID();
|
|
await db.insert(issues).values({ id, companyId: f.company.id, title: "Private email", visibility: "private", privacyRootIssueId: id, responsibleUserId: f.owner });
|
|
expect((await authorizationService(db).decide({ actor: f.actor(f.outsider), action: "issue:read", resource: { type: "issue", companyId: f.company.id, issueId: id } })).allowed).toBe(false);
|
|
const { setupLiveEventsWebSocketServer } = await import("../realtime/live-events-ws.js");
|
|
const { publishLiveEvent } = await import("../services/live-events.js");
|
|
const server = createServer();
|
|
const wss = setupLiveEventsWebSocketServer(server, db, { deploymentMode: "authenticated", resolveCloudActor: async () => ({ userId: f.outsider, companyIds: [f.company.id] }) });
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address() as { port: number };
|
|
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/companies/${f.company.id}/events/ws`);
|
|
try {
|
|
await once(socket, "open");
|
|
const message = once(socket, "message");
|
|
publishLiveEvent({ companyId: f.company.id, type: "heartbeat.run.log", payload: { issueId: id, runId: randomUUID(), agentId: f.agent.id, chunk: "PRIVATE_EMAIL_CANARY" } });
|
|
const result = await Promise.race([message.then(([data]) => data.toString()), new Promise<string>(resolve => setTimeout(() => resolve(""), 150))]);
|
|
expect(result).not.toContain("PRIVATE_EMAIL_CANARY");
|
|
} finally {
|
|
socket.terminate();
|
|
await new Promise<void>(resolve => (wss as any).close(resolve));
|
|
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|