feat: add private project access

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
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);
});
});
+67 -3
View File
@@ -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) {
+169 -5
View File
@@ -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" });
+137 -4
View File
@@ -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)))
+4 -3
View File
@@ -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);
}
+48
View File
@@ -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;
+63
View File
@@ -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[];