mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat: add private project access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
df05b1a968
commit
c761282d76
29 files changed
+1197
-29
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
issueComments,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -65,12 +66,18 @@ async function createAgent(
|
||||
.then((rows) => rows[0]!);
|
||||
}
|
||||
|
||||
async function createProject(db: ReturnType<typeof createDb>, companyId: string, label: string) {
|
||||
async function createProject(
|
||||
db: ReturnType<typeof createDb>,
|
||||
companyId: string,
|
||||
label: string,
|
||||
visibility: "open" | "private" = "open",
|
||||
) {
|
||||
return db
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: `Project ${label} ${randomUUID()}`,
|
||||
visibility,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
@@ -2307,6 +2314,100 @@ describeEmbeddedPostgres("authorization service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses private-project membership as the same issue-read predicate while preserving issue grants", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const company = await createCompany(db, "ProjectPrivacy");
|
||||
const memberId = await createUser(db);
|
||||
const granteeId = await createUser(db);
|
||||
const outsiderId = await createUser(db);
|
||||
await db.insert(companyMemberships).values([memberId, granteeId, outsiderId].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "user" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const project = await createProject(db, company.id, "Private", "private");
|
||||
const memberAgent = await createAgent(db, company.id);
|
||||
const outsiderAgent = await createAgent(db, company.id);
|
||||
await db.insert(companyMemberships).values([memberAgent.id, outsiderAgent.id].map((principalId) => ({
|
||||
companyId: company.id,
|
||||
principalType: "agent" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
})));
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId: company.id,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: memberAgent.id,
|
||||
});
|
||||
const issue = await createIssue(db, company.id, { projectId: project.id });
|
||||
await db.insert(issueAccessGrants).values({
|
||||
issueId: issue.id,
|
||||
subjectType: "user",
|
||||
subjectId: granteeId,
|
||||
source: "explicit",
|
||||
grantedByUserId: memberId,
|
||||
});
|
||||
const authz = authorizationService(db);
|
||||
const decideProject = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssue = (userId: string) => authz.decide({
|
||||
actor: { type: "board", userId, source: "session" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
const decideProjectAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: company.id, projectId: project.id },
|
||||
});
|
||||
const decideIssueAsAgent = (agentId: string) => authz.decide({
|
||||
actor: { type: "agent", agentId, companyId: company.id, source: "agent_key" },
|
||||
action: "issue:read",
|
||||
resource: { type: "issue", companyId: company.id, issueId: issue.id },
|
||||
});
|
||||
|
||||
await expect(decideProject(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProject(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssue(memberId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(granteeId)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssue(outsiderId)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
await expect(decideProjectAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideIssueAsAgent(memberAgent.id)).resolves.toMatchObject({ allowed: true });
|
||||
await expect(decideProjectAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_project_private",
|
||||
});
|
||||
await expect(decideIssueAsAgent(outsiderAgent.id)).resolves.toMatchObject({
|
||||
allowed: false,
|
||||
reason: "deny_issue_private",
|
||||
});
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds the private issue grant cache across distinct issues", async () => {
|
||||
__clearIssuePrivacyGrantCacheForTests();
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
issueLabels,
|
||||
issues,
|
||||
labels,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import { companySearchQuerySchema, COMPANY_SEARCH_MAX_QUERY_LENGTH } from "@paperclipai/shared";
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
companySearchBranchFetchLimit,
|
||||
companySearchService,
|
||||
} from "../services/company-search.js";
|
||||
import { issueReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueReadSqlCondition, projectReadSqlCondition } from "../services/authorization.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
@@ -781,4 +782,45 @@ describeEmbeddedPostgres("companySearchService", () => {
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
|
||||
it("pushes private project filtering into project search and counts", async () => {
|
||||
const previousMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
try {
|
||||
const companyId = await createCompany();
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const outsiderId = `outsider-${randomUUID()}`;
|
||||
const openId = await createProject(companyId, { name: "privacy project open" });
|
||||
const privateId = await createProject(companyId, {
|
||||
name: "privacy project private",
|
||||
visibility: "private",
|
||||
});
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId,
|
||||
projectId: privateId,
|
||||
subjectType: "user",
|
||||
subjectId: memberId,
|
||||
});
|
||||
const conditionFor = (userId: string) => projectReadSqlCondition(db, {
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
});
|
||||
const query = companySearchQuerySchema.parse({ q: "privacy project", scope: "projects" });
|
||||
const outsiderResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(outsiderId),
|
||||
});
|
||||
expect(outsiderResult.results.map((row) => row.id)).toEqual([openId]);
|
||||
expect(outsiderResult.countsByType.project).toBe(1);
|
||||
|
||||
const memberResult = await svc.search(companyId, query, {
|
||||
projectReadCondition: await conditionFor(memberId),
|
||||
});
|
||||
expect(memberResult.results.map((row) => row.id)).toEqual(expect.arrayContaining([openId, privateId]));
|
||||
expect(memberResult.countsByType.project).toBe(2);
|
||||
} finally {
|
||||
if (previousMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousMode;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
issueComments,
|
||||
issueInboxArchives,
|
||||
issueDocuments,
|
||||
issueAccessGrants,
|
||||
issuePlanDecompositions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
@@ -2637,6 +2639,95 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => {
|
||||
expect(child.privacyRootIssueId).toBe(parentIssueId);
|
||||
});
|
||||
|
||||
it("creates one personal private project and parks private tasks there", async () => {
|
||||
const companyId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Personal private tasks company",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
svc.create(companyId, {
|
||||
title: "First personal private task",
|
||||
visibility: "private",
|
||||
createdByUserId: responsibleUserId,
|
||||
}),
|
||||
svc.create(companyId, {
|
||||
title: "Second personal private task",
|
||||
visibility: "private",
|
||||
createdByUserId: responsibleUserId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const personalProjects = await db.select().from(projects)
|
||||
.where(eq(projects.personalOwnerUserId, responsibleUserId));
|
||||
expect(personalProjects).toHaveLength(1);
|
||||
expect(personalProjects[0]).toMatchObject({
|
||||
companyId,
|
||||
name: "My private tasks",
|
||||
visibility: "private",
|
||||
});
|
||||
expect(first.projectId).toBe(personalProjects[0]!.id);
|
||||
expect(second.projectId).toBe(personalProjects[0]!.id);
|
||||
expect(first.privacyRootIssueId).toBe(first.id);
|
||||
expect(second.privacyRootIssueId).toBe(second.id);
|
||||
await expect(db.select().from(projectAccessMembers)
|
||||
.where(eq(projectAccessMembers.projectId, personalProjects[0]!.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a responsible human and grants an agent access to its explicitly private task", async () => {
|
||||
const companyId = randomUUID();
|
||||
const responsibleUserId = randomUUID();
|
||||
const creatorAgentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Agent private task company",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: creatorAgentId,
|
||||
companyId,
|
||||
name: "Private task creator",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
const issue = await svc.create(companyId, {
|
||||
title: "Agent email scratch task",
|
||||
visibility: "private",
|
||||
createdByAgentId: creatorAgentId,
|
||||
actorResponsibleUserId: responsibleUserId,
|
||||
});
|
||||
|
||||
expect(issue.responsibleUserId).toBe(responsibleUserId);
|
||||
const personalProject = await db.select().from(projects)
|
||||
.where(eq(projects.id, issue.projectId!))
|
||||
.then((rows) => rows[0]!);
|
||||
expect(personalProject.personalOwnerUserId).toBe(responsibleUserId);
|
||||
await expect(db.select().from(projectAccessMembers)
|
||||
.where(eq(projectAccessMembers.projectId, personalProject.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({ subjectType: "user", subjectId: responsibleUserId }),
|
||||
]);
|
||||
await expect(db.select().from(issueAccessGrants)
|
||||
.where(eq(issueAccessGrants.issueId, issue.id)))
|
||||
.resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
subjectType: "agent",
|
||||
subjectId: creatorAgentId,
|
||||
source: "explicit",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("only honors explicit responsibleUserId for trusted issue create callers", async () => {
|
||||
const companyId = randomUUID();
|
||||
const creatorUserId = randomUUID();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { companies, createDb, projects } from "@paperclipai/db";
|
||||
import { companies, companyMemberships, createDb, projectAccessMembers, projects } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
@@ -54,6 +54,7 @@ describeEmbeddedPostgres("project list archived route defaults", () => {
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
@@ -108,3 +109,74 @@ describeEmbeddedPostgres("project list archived route defaults", () => {
|
||||
expect(res.body.map((project: { id: string }) => project.id)).toEqual([activeProjectId, archivedProjectId]);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres.sequential("private project route visibility", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-private-project-routes-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(projects);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("omits private projects from non-member lists and returns 404 on direct fetch", async () => {
|
||||
const companyId = randomUUID();
|
||||
const privateProjectId = randomUUID();
|
||||
const memberUserId = "project-member";
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private projects",
|
||||
issuePrefix: `P${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
});
|
||||
await db.insert(projects).values([
|
||||
{ companyId, name: "Open project", visibility: "open" },
|
||||
{ id: privateProjectId, companyId, name: "Secret project", visibility: "private" },
|
||||
]);
|
||||
await db.insert(companyMemberships).values([memberUserId, "project-outsider"].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user" as const,
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "admin",
|
||||
})));
|
||||
await db.insert(projectAccessMembers).values({
|
||||
companyId,
|
||||
projectId: privateProjectId,
|
||||
subjectType: "user",
|
||||
subjectId: memberUserId,
|
||||
});
|
||||
const actorFor = (userId: string): Express.Request["actor"] => ({
|
||||
type: "board",
|
||||
userId,
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
|
||||
});
|
||||
|
||||
const outsiderApp = createApp(db, actorFor("project-outsider"));
|
||||
const outsiderList = await request(outsiderApp).get(`/api/companies/${companyId}/projects`);
|
||||
expect(outsiderList.status).toBe(200);
|
||||
expect(outsiderList.body.map((project: { id: string }) => project.id)).not.toContain(privateProjectId);
|
||||
await request(outsiderApp).get(`/api/projects/${privateProjectId}`).expect(404);
|
||||
|
||||
const memberApp = createApp(db, actorFor(memberUserId));
|
||||
const memberList = await request(memberApp).get(`/api/companies/${companyId}/projects`);
|
||||
expect(memberList.body.map((project: { id: string }) => project.id)).toContain(privateProjectId);
|
||||
await request(memberApp).get(`/api/projects/${privateProjectId}`).expect(200);
|
||||
});
|
||||
});
|
||||
@@ -176,9 +176,11 @@ import {
|
||||
authorizationDeniedDetails,
|
||||
invalidateIssuePrivacyGrantCache,
|
||||
issueReadSqlCondition,
|
||||
projectReadSqlCondition,
|
||||
} from "../services/authorization.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { ensurePersonalPrivateProject } from "../services/projects.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import {
|
||||
createCompanySearchRateLimiter,
|
||||
@@ -297,7 +299,7 @@ type CompanySearchService = {
|
||||
search(
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean> },
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchResponse>;
|
||||
};
|
||||
type ActivityIssueRelationSummary = {
|
||||
@@ -3490,6 +3492,14 @@ export function issueRoutes(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function canActorReadProject(req: Request, project: { id: string; companyId: string }) {
|
||||
return access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
||||
}).then((decision) => decision.allowed);
|
||||
}
|
||||
|
||||
async function assertCanManageIssuePrivacy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -4901,6 +4911,8 @@ export function issueRoutes(
|
||||
goals: project.goals,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
visibility: project.visibility,
|
||||
personalOwnerUserId: project.personalOwnerUserId,
|
||||
status: project.status,
|
||||
leadAgentId: project.leadAgentId,
|
||||
targetDate: project.targetDate,
|
||||
@@ -5099,6 +5111,7 @@ export function issueRoutes(
|
||||
}
|
||||
const result = await getSearchService().search(companyId, query, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
projectReadCondition: await projectReadSqlCondition(db, req.actor),
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
@@ -5873,6 +5886,13 @@ export function issueRoutes(
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const [visibleProject, mentionedProjectVisibility] = await Promise.all([
|
||||
project
|
||||
? canActorReadProject(req, project).then((allowed) => allowed ? project : null)
|
||||
: Promise.resolve(null),
|
||||
Promise.all(mentionedProjects.map((mentionedProject) => canActorReadProject(req, mentionedProject))),
|
||||
]);
|
||||
const visibleMentionedProjects = mentionedProjects.filter((_, index) => mentionedProjectVisibility[index]);
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
@@ -5892,9 +5912,9 @@ export function issueRoutes(
|
||||
relatedWork: visibleReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
...documentPayload,
|
||||
project: compactIssueProject(project),
|
||||
project: compactIssueProject(visibleProject),
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
mentionedProjects: visibleMentionedProjects,
|
||||
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
|
||||
workProducts,
|
||||
linkedCases,
|
||||
@@ -7515,6 +7535,22 @@ export function issueRoutes(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (createBody.projectId) {
|
||||
const selectedProject = await projectsSvc.getById(createBody.projectId);
|
||||
if (!selectedProject || selectedProject.companyId !== companyId) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const projectDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId, projectId: selectedProject.id },
|
||||
});
|
||||
if (!projectDecision.allowed) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
|
||||
const createAssignmentScope = {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
@@ -8249,6 +8285,34 @@ export function issueRoutes(
|
||||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
if (req.body.visibility === "private" && !existing.projectId && updateFields.projectId === undefined) {
|
||||
const responsibleUserId = existing.responsibleUserId ?? (req.actor.type === "board"
|
||||
? req.actor.userId ?? null
|
||||
: req.actor.onBehalfOfUserId ?? null);
|
||||
if (!responsibleUserId) {
|
||||
res.status(422).json({ error: "Private tasks require a responsible user" });
|
||||
return;
|
||||
}
|
||||
const personalProject = await db.transaction((tx) =>
|
||||
ensurePersonalPrivateProject(tx, existing.companyId, responsibleUserId));
|
||||
updateFields.projectId = personalProject.id;
|
||||
}
|
||||
if (updateFields.projectId) {
|
||||
const selectedProject = await projectsSvc.getById(updateFields.projectId as string);
|
||||
if (!selectedProject || selectedProject.companyId !== existing.companyId) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const projectDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: existing.companyId, projectId: selectedProject.id },
|
||||
});
|
||||
if (!projectDecision.allowed) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const shouldCancelActiveRunForCancelledStatus =
|
||||
existing.status !== "cancelled" && updateFields.status === "cancelled";
|
||||
if (resumeRequested === true && !commentBody) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, authUsers, companyMemberships, projectAccessMembers } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
deriveProjectUrlKey,
|
||||
findWorkspaceCommandDefinition,
|
||||
isUuidLike,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
normalizeProjectUrlKey,
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
@@ -17,7 +22,8 @@ import { accessService, projectService, logActivity, workspaceOperationService }
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { externalObjectService } from "../services/external-objects.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { ensureProjectAccessMember } from "../services/projects.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
@@ -88,11 +94,17 @@ export function projectRoutes(db: Db) {
|
||||
if (isUuidLike(rawId)) return rawId;
|
||||
const companyId = await resolveCompanyIdForProjectReference(req);
|
||||
if (!companyId) return rawId;
|
||||
const resolved = await svc.resolveByReference(companyId, rawId);
|
||||
if (resolved.ambiguous) {
|
||||
const urlKey = normalizeProjectUrlKey(rawId);
|
||||
if (!urlKey) return rawId;
|
||||
const visibleProjects = await filterProjectsForActor(
|
||||
req,
|
||||
await svc.list(companyId, { includeArchived: true }),
|
||||
);
|
||||
const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey);
|
||||
if (matches.length > 1) {
|
||||
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
||||
}
|
||||
return resolved.project?.id ?? rawId;
|
||||
return matches[0]?.id ?? rawId;
|
||||
}
|
||||
|
||||
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
||||
@@ -102,10 +114,64 @@ export function projectRoutes(db: Db) {
|
||||
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
||||
});
|
||||
if (decision.allowed) return true;
|
||||
res.status(403).json({ error: "Project is outside this actor's authorization boundary" });
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string }) {
|
||||
if (req.actor.type === "board" && req.actor.userId) {
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.actor.type === "agent" && req.actor.agentId) {
|
||||
if (req.actor.onBehalfOfUserId) {
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.onBehalfOfUserId,
|
||||
});
|
||||
}
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: req.actor.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichProjectAccessMembers(rows: Array<typeof projectAccessMembers.$inferSelect>) {
|
||||
const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId);
|
||||
const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId);
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)),
|
||||
userIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds)),
|
||||
]);
|
||||
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
|
||||
const usersById = new Map(userRows.map((row) => [row.id, row]));
|
||||
return rows.map((row) => {
|
||||
const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null;
|
||||
const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null;
|
||||
return {
|
||||
...row,
|
||||
subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null,
|
||||
subjectAvatarUrl: user?.image ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function filterProjectsForActor<T extends { id: string; companyId: string }>(req: Request, rows: T[]) {
|
||||
const decisions = await Promise.all(rows.map((project) =>
|
||||
access.decide({
|
||||
@@ -146,6 +212,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) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
||||
res.json(summary);
|
||||
});
|
||||
@@ -177,6 +244,7 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project);
|
||||
if (project.env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
@@ -222,7 +290,9 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const body = { ...req.body };
|
||||
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
@@ -274,10 +344,99 @@ export function projectRoutes(db: Db) {
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/access-members", async (req, res) => {
|
||||
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;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectAccessMembers)
|
||||
.where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id)));
|
||||
res.json(await enrichProjectAccessMembers(rows));
|
||||
});
|
||||
|
||||
router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
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;
|
||||
const subjectType = req.body.subjectType as "user" | "agent";
|
||||
const subjectId = req.body.subjectId as string;
|
||||
const subjectExists = subjectType === "agent"
|
||||
? await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId)))
|
||||
.then((rows) => rows.length > 0)
|
||||
: await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, subjectId),
|
||||
eq(companyMemberships.companyId, project.companyId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
))
|
||||
.then((rows) => rows.length > 0);
|
||||
if (!subjectExists) {
|
||||
res.status(422).json({ error: "Project access member must belong to this company" });
|
||||
return;
|
||||
}
|
||||
await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId });
|
||||
const rows = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
eq(projectAccessMembers.subjectType, subjectType),
|
||||
eq(projectAccessMembers.subjectId, subjectId),
|
||||
));
|
||||
const [member] = await enrichProjectAccessMembers(rows);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_added",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType, subjectId },
|
||||
});
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
router.delete("/projects/:id/access-members/:memberId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
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;
|
||||
const member = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.id, req.params.memberId as string),
|
||||
eq(projectAccessMembers.companyId, project.companyId),
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
if (!member) {
|
||||
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" });
|
||||
return;
|
||||
}
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_removed",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType: member.subjectType, subjectId: member.subjectId },
|
||||
});
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/workspaces", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspaces = await svc.listWorkspaces(id);
|
||||
res.json(workspaces);
|
||||
});
|
||||
@@ -286,6 +445,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -324,6 +484,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -369,6 +530,7 @@ export function projectRoutes(db: Db) {
|
||||
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
|
||||
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
@@ -637,6 +799,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspace = await svc.removeWorkspace(id, workspaceId);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
@@ -665,6 +828,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const project = await svc.remove(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
issueAccessGrants,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -121,6 +122,7 @@ export type AuthorizationDecision = {
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_issue_private"
|
||||
| "deny_project_private"
|
||||
| "deny_scope"
|
||||
| "deny_unsupported_action";
|
||||
grant?: {
|
||||
@@ -237,7 +239,7 @@ type ProjectAuthorizationRow = {
|
||||
type IssueAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectId?: string | null;
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -468,6 +470,13 @@ export type IssuePrivacyRow = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectPrivacyRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
};
|
||||
|
||||
export const ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES = 256;
|
||||
@@ -535,6 +544,36 @@ function issuePrivacyPrincipal(actor: AuthorizationActor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function actorHasProjectAccess(
|
||||
db: Db,
|
||||
actor: AuthorizationActor,
|
||||
companyId: string,
|
||||
projectId: string,
|
||||
) {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return false;
|
||||
return db
|
||||
.select({ id: projectAccessMembers.id })
|
||||
.from(projectAccessMembers)
|
||||
.where(and(
|
||||
eq(projectAccessMembers.companyId, companyId),
|
||||
eq(projectAccessMembers.projectId, projectId),
|
||||
eq(projectAccessMembers.subjectType, principal.type),
|
||||
eq(projectAccessMembers.subjectId, principal.id),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
export async function canActorReadProjectPrivacy(
|
||||
db: Db,
|
||||
actor: AuthorizationActor,
|
||||
project: ProjectPrivacyRow,
|
||||
) {
|
||||
if (project.visibility !== "private") return true;
|
||||
return actorHasProjectAccess(db, actor, project.companyId, project.id);
|
||||
}
|
||||
|
||||
function actorIsImplicitIssuePrincipal(actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return false;
|
||||
@@ -578,9 +617,17 @@ async function actorHasIssuePrivacyGrant(db: Db, actor: AuthorizationActor, issu
|
||||
|
||||
/** Canonical row-level issue privacy predicate, also consumed by run-derived checks. */
|
||||
export async function canActorReadIssuePrivacy(db: Db, actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
||||
if (issue.visibility !== "private") return true;
|
||||
if (actorIsImplicitIssuePrincipal(actor, issue)) return true;
|
||||
return actorHasIssuePrivacyGrant(db, actor, issue);
|
||||
if (await actorHasIssuePrivacyGrant(db, actor, issue)) return true;
|
||||
if (issue.projectId) {
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project?.visibility === "private") return actorHasProjectAccess(db, actor, project.companyId, project.id);
|
||||
}
|
||||
return issue.visibility !== "private";
|
||||
}
|
||||
|
||||
/** SQL equivalent of canActorReadIssuePrivacy for list/count/search query pushdown. */
|
||||
@@ -621,7 +668,59 @@ export async function issueReadSqlCondition(db: Db, actor: AuthorizationActor):
|
||||
and ${issueAccessGrants.issueId} in (${issues.id}, coalesce(${issues.privacyRootIssueId}, ${issues.id}))
|
||||
)`
|
||||
: sql<boolean>`false`;
|
||||
return sql<boolean>`(${issues.visibility} = 'open' or ${implicit} or ${grant})`;
|
||||
const projectMember = principal
|
||||
? sql<boolean>`exists (
|
||||
select 1 from ${projectAccessMembers}
|
||||
where ${projectAccessMembers.companyId} = ${issues.companyId}
|
||||
and ${projectAccessMembers.projectId} = ${issues.projectId}
|
||||
and ${projectAccessMembers.subjectType} = ${principal.type}
|
||||
and ${projectAccessMembers.subjectId} = ${principal.id}
|
||||
)`
|
||||
: sql<boolean>`false`;
|
||||
const projectIsPrivate = sql<boolean>`exists (
|
||||
select 1 from ${projects}
|
||||
where ${projects.id} = ${issues.projectId}
|
||||
and ${projects.companyId} = ${issues.companyId}
|
||||
and ${projects.visibility} = 'private'
|
||||
)`;
|
||||
return sql<boolean>`(
|
||||
${implicit}
|
||||
or ${grant}
|
||||
or ${projectMember}
|
||||
or (${issues.visibility} = 'open' and not ${projectIsPrivate})
|
||||
)`;
|
||||
}
|
||||
|
||||
/** SQL equivalent used for project list/search pushdown. */
|
||||
export async function projectReadSqlCondition(db: Db, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
||||
if (
|
||||
actor.type === "board"
|
||||
&& !actor.ignoreInstanceAdmin
|
||||
&& actor.source !== "cloud_tenant"
|
||||
&& Boolean(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[0] ?? null))
|
||||
) return sql<boolean>`true`;
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return sql<boolean>`${projects.visibility} = 'open'`;
|
||||
return sql<boolean>`(
|
||||
${projects.visibility} = 'open'
|
||||
or exists (
|
||||
select 1 from ${projectAccessMembers}
|
||||
where ${projectAccessMembers.companyId} = ${projects.companyId}
|
||||
and ${projectAccessMembers.projectId} = ${projects.id}
|
||||
and ${projectAccessMembers.subjectType} = ${principal.type}
|
||||
and ${projectAccessMembers.subjectId} = ${principal.id}
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
function responsibleUserSnapshotTtlMs() {
|
||||
@@ -2345,6 +2444,39 @@ export function authorizationService(db: Db) {
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const agentDecision = await decideBase(input);
|
||||
const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision);
|
||||
if (
|
||||
input.action === "project:read"
|
||||
&& input.resource.type === "project"
|
||||
&& input.resource.projectId
|
||||
&& intersectedDecision.allowed
|
||||
&& intersectedDecision.reason !== "allow_instance_admin"
|
||||
&& intersectedDecision.reason !== "allow_local_board"
|
||||
&& issuePrivacyMode() !== "off"
|
||||
) {
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) {
|
||||
const denied = deny({
|
||||
action: input.action,
|
||||
reason: "deny_project_private",
|
||||
explanation: "Project is private and the actor is not an access member.",
|
||||
});
|
||||
logger.warn({
|
||||
authzMode: issuePrivacyMode(),
|
||||
action: input.action,
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
actorType: input.actor.type,
|
||||
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
||||
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
reason: denied.reason,
|
||||
}, "project privacy would deny read");
|
||||
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.action !== "issue:read"
|
||||
|| input.resource.type !== "issue"
|
||||
@@ -2365,6 +2497,7 @@ export function authorizationService(db: Db) {
|
||||
createdByUserId: issues.createdByUserId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId)))
|
||||
|
||||
@@ -561,7 +561,7 @@ export function companySearchService(db: Db) {
|
||||
search: async (
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean> },
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchResponse> => {
|
||||
const normalizedQuery = normalizeQuery(query.q);
|
||||
const hasSearchText = normalizedQuery.length > 0;
|
||||
@@ -1086,6 +1086,7 @@ export function companySearchService(db: Db) {
|
||||
sql`${projects.name}`,
|
||||
sql`${projects.description}`,
|
||||
], containsPattern, tokenPatternArray);
|
||||
const projectReadCondition = options?.projectReadCondition ?? sql<boolean>`true`;
|
||||
|
||||
async function fetchAgentRows() {
|
||||
if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
|
||||
@@ -1115,7 +1116,7 @@ export function companySearchService(db: Db) {
|
||||
updatedAt: projects.updatedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition))
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition))
|
||||
.orderBy(desc(projects.updatedAt), desc(projects.id))
|
||||
.limit(fetchLimit);
|
||||
}
|
||||
@@ -1202,7 +1203,7 @@ export function companySearchService(db: Db) {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition));
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition));
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalizatio
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { invalidateIssuePrivacyGrantCache } from "./authorization.js";
|
||||
import { buildIssueChanges } from "./issue-change-receipt.js";
|
||||
import { ensurePersonalPrivateProject } from "./projects.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
@@ -6659,6 +6660,7 @@ export function issueService(db: Db) {
|
||||
|
||||
let visibility = issueData.visibility ?? "open";
|
||||
let privacyRootIssueId: string | null = null;
|
||||
let inheritedPrivateSubtree = false;
|
||||
if (issueData.parentId) {
|
||||
const parentPrivacy = await tx
|
||||
.select({
|
||||
@@ -6671,6 +6673,7 @@ export function issueService(db: Db) {
|
||||
if (parentPrivacy?.visibility === "private") {
|
||||
visibility = "private";
|
||||
privacyRootIssueId = parentPrivacy.privacyRootIssueId ?? issueData.parentId;
|
||||
inheritedPrivateSubtree = true;
|
||||
}
|
||||
}
|
||||
if (visibility === "private" && !privacyRootIssueId) {
|
||||
@@ -6678,6 +6681,30 @@ export function issueService(db: Db) {
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await tx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
visibility = "private";
|
||||
if (!privacyRootIssueId) {
|
||||
issueData.id ??= randomUUID();
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibility === "private" && !issueData.projectId && !inheritedPrivateSubtree) {
|
||||
if (!responsibleUserId) {
|
||||
throw unprocessable("Private tasks require a responsible user");
|
||||
}
|
||||
const personalProject = await ensurePersonalPrivateProject(tx, companyId, responsibleUserId);
|
||||
issueData.projectId = personalProject.id;
|
||||
}
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
visibility,
|
||||
@@ -6719,6 +6746,15 @@ export function issueService(db: Db) {
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
await tx.insert(issueAccessGrants).values({
|
||||
issueId: issue.privacyRootIssueId ?? issue.id,
|
||||
subjectType: "agent",
|
||||
subjectId: issueData.createdByAgentId,
|
||||
source: "explicit",
|
||||
grantedByAgentId: issueData.createdByAgentId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await ensureAssignmentIssueAccessGrant(tx, issue, null, {
|
||||
agentId: issueData.createdByAgentId ?? null,
|
||||
userId: issueData.createdByUserId ?? null,
|
||||
@@ -7045,6 +7081,18 @@ export function issueService(db: Db) {
|
||||
} else if (issueData.visibility === "open") {
|
||||
patch.privacyRootIssueId = null;
|
||||
}
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await dbOrTx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, existing.companyId)))
|
||||
.then((rows: Array<{ visibility: string }>) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
patch.visibility = "private";
|
||||
patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id;
|
||||
}
|
||||
}
|
||||
if (existing.status !== "blocked" && issueData.status === "blocked") {
|
||||
patch.blockedTransitionAt = patch.updatedAt;
|
||||
patch.blockedOwnerNotifiedAt = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
projects,
|
||||
projectAccessMembers,
|
||||
projectGoals,
|
||||
goals,
|
||||
issues,
|
||||
@@ -56,6 +57,68 @@ type CreateWorkspaceInput = {
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
export async function ensureProjectAccessMember(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
subjectType: "user" | "agent";
|
||||
subjectId: string;
|
||||
},
|
||||
) {
|
||||
return dbOrTx
|
||||
.insert(projectAccessMembers)
|
||||
.values(input)
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: Array<typeof projectAccessMembers.$inferSelect>) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrency-safe lazy creation for the per-user private parking project.
|
||||
* The partial unique index is the final arbiter; the advisory lock avoids
|
||||
* wasting project ids and keeps the membership insert in the same transaction.
|
||||
*/
|
||||
export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: string, userId: string) {
|
||||
const lockKey = `personal-private-project:${companyId}:${userId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
let project = await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
if (!project) {
|
||||
project = await dbOrTx
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: "My private tasks",
|
||||
description: "Private tasks visible only to people and agents explicitly granted access.",
|
||||
status: "in_progress",
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
project ??= await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
}
|
||||
if (!project) throw new Error("Failed to create personal private project");
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: userId,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
|
||||
Reference in new issue
Block a user