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

+2
View File
@@ -167,6 +167,8 @@ These rows are company-scoped and user-scoped. A missing row means the user is j
Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes.
Private-project authorization uses the separate `project_access_members` table. Its user/agent rows are security grants, not sidebar preferences, and are evaluated by the same issue-read predicate as issue-level grants. Do not merge or overload these two concepts.
## Decision training snapshot retention
`decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot.
+2 -2
View File
@@ -332,10 +332,10 @@ No Docker or external database is required for this mode.
## Issue Privacy Rollout
`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the opt-in private-issue read predicate:
`PAPERCLIP_ISSUE_PRIVACY_MODE` controls the canonical opt-in issue/project privacy predicate:
- `shadow` (default): log structured would-deny decisions but preserve existing reads
- `enforce`: return private issues only to implicit principals and active grantees
- `enforce`: return private issues only to implicit principals, issue grantees, or private-project access members; hide private projects from non-members
- `off`: skip the predicate and shadow logging
Grant lookups use a five-second cache by default; override it with `PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS` when testing revocation timing.
+25 -4
View File
@@ -207,6 +207,8 @@ Invariant: at least one root `company` level goal per company.
- `goal_id` uuid fk `goals.id` null
- `name` text not null
- `description` text null
- `visibility` enum-like text: `open | private`, default `open`
- `personal_owner_user_id` text null; unique with `company_id` when present
- `status` enum: `backlog | planned | in_progress | completed | cancelled`
- `lead_agent_id` uuid fk `agents.id` null
- `target_date` date null
@@ -281,7 +283,21 @@ Invariants:
- `created_at` timestamptz not null
- `revoked_at` timestamptz null
An issue is readable when it is open, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), or the actor has an unrevoked grant on the issue/private subtree root. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access.
An issue is readable when it is open outside a private project, the actor is an implicit principal (`responsible_user_id`, `created_by_user_id`, `assignee_user_id`, or current `assignee_agent_id`), the actor has an unrevoked grant on the issue/private subtree root, or the actor is an access member of its private project. This is one canonical predicate shared by detail, list, count, search, and run-derived reads. Instance administrators retain platform-wide access; company owner/admin roles do not imply private-issue read access.
## 7.7.2 `project_access_members`
Authorization membership for private projects, distinct from the personal-sidebar `project_memberships` table:
- `id` uuid pk
- `company_id` uuid fk `companies.id` not null
- `project_id` uuid fk `projects.id` not null
- `subject_type` enum-like text: `user | agent`
- `subject_id` text not null
- `created_at` timestamptz not null
- unique `(project_id, subject_type, subject_id)`
Private projects are absent from list and search results for non-members, and direct reads return `404`. An issue-level grant can still expose its issue without exposing the containing project. A user's `My private tasks` project is created lazily and concurrency-safely on their first unprojected private task; the owning user is its initial and non-removable project member. An agent-created private root also grants that creator issue-level access while retaining the responsible user as the human principal.
## 7.8 `heartbeat_runs`
@@ -580,8 +596,8 @@ The approved term set is:
## 9.5 Core V1 Rule: what “private” means
- A **private marker** on an agent profile (where represented) does **not** make company-visible work private.
- Company-visible work objects (issues, comments, work products, costs, activity, project/task state) remain visible to the board and in-company agents by default.
- Project/issue-level privacy, scoped assignment-only object visibility, and organization-wide custom ACLs are deferred to Pro/Enterprise controls.
- Work objects remain company-open by default. An issue or project can opt into the shared private-read predicate.
- Organization-wide custom ACL policy remains deferred to Pro/Enterprise controls.
## 9.6 V1 vs Pro/Enterprise Controls (recommended target split)
@@ -592,7 +608,7 @@ The approved term set is:
| Profile visibility | Full profile visibility for coordination and audit | Optional profile redaction / selective sharing for external surfaces |
| Config visibility | Board full read with redacted secret fields; agent config read/write constrained by own agent identity | Scoped config visibility controls and central policy enforcement |
| Assignment/invocation | Assignment creates execution authority; board can reassign or force release | Delegation policies and scoped invokers with deny-listed tool classes |
| Work-object visibility | All issues and projects in-company are visible to board and agents | Project/issue ACLs and reviewer-only channels |
| Work-object visibility | Company-open by default; opt-in private issues/projects with explicit user or agent access | Policy-driven ACLs and reviewer-only channels |
| Tool/secret policy | Secret refs, log redaction, and adapter-level command/webhook restrictions | Tool allowlists with centralized policy evaluation |
| Company skills | Open to authenticated company agents; core enforces invariants and any stored restriction policy | Paperclip EE policy editor, protected-skill controls, presets, simulation, and policy audit UX |
| Inbox management | Responsible agent may archive/unarchive its responsible user's Mine items under a default-open user policy; cross-user access requires `inbox:manage`; all mutations are audited | Policy administration UX, organization presets, simulations, bulk controls, and richer audit/reporting surfaces |
@@ -925,6 +941,11 @@ Server behavior:
- `POST /companies/:companyId/projects`
- `GET /projects/:projectId`
- `PATCH /projects/:projectId`
- `GET /projects/:projectId/access-members`
- `POST /projects/:projectId/access-members`
- `DELETE /projects/:projectId/access-members/:memberId`
Project list, search, direct reads, and nested project routes apply private-project access. Non-members receive the same not-found behavior as missing projects.
## 10.6 Current-user Resource Memberships
@@ -0,0 +1,41 @@
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "visibility" text DEFAULT 'open' NOT NULL;
ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "personal_owner_user_id" text;
DO $$ BEGIN
ALTER TABLE "projects"
ADD CONSTRAINT "projects_visibility_check" CHECK ("visibility" IN ('open', 'private'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "projects_company_personal_owner_uq"
ON "projects" USING btree ("company_id", "personal_owner_user_id")
WHERE "personal_owner_user_id" IS NOT NULL;
CREATE TABLE IF NOT EXISTS "project_access_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"subject_type" text NOT NULL,
"subject_id" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_access_members_subject_type_check" CHECK ("subject_type" IN ('user', 'agent'))
);
DO $$ BEGIN
ALTER TABLE "project_access_members"
ADD CONSTRAINT "project_access_members_company_id_companies_id_fk"
FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE "project_access_members"
ADD CONSTRAINT "project_access_members_project_id_projects_id_fk"
FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS "project_access_members_project_subject_uq"
ON "project_access_members" USING btree ("project_id", "subject_type", "subject_id");
CREATE INDEX IF NOT EXISTS "project_access_members_subject_lookup_idx"
ON "project_access_members" USING btree ("company_id", "subject_type", "subject_id", "project_id");
@@ -1387,6 +1387,13 @@
"when": 1785628800000,
"tag": "0199_heartbeat_run_issue_privacy",
"breakpoints": true
},
{
"idx": 200,
"version": "7",
"when": 1785715200000,
"tag": "0200_private_projects",
"breakpoints": true
}
]
}
+1
View File
@@ -24,6 +24,7 @@ export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";
export { projectAccessMembers } from "./project_access_members.js";
export { documentMemberships } from "./document_memberships.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { executionWorkspaces } from "./execution_workspaces.js";
@@ -0,0 +1,34 @@
import { sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
/** Authorization membership for private projects; unrelated to sidebar project_memberships. */
export const projectAccessMembers = pgTable(
"project_access_members",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
subjectType: text("subject_type").notNull(),
subjectId: text("subject_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
subjectTypeCheck: check(
"project_access_members_subject_type_check",
sql`${table.subjectType} in ('user', 'agent')`,
),
projectSubjectUq: uniqueIndex("project_access_members_project_subject_uq").on(
table.projectId,
table.subjectType,
table.subjectId,
),
subjectLookupIdx: index("project_access_members_subject_lookup_idx").on(
table.companyId,
table.subjectType,
table.subjectId,
table.projectId,
),
}),
);
+8 -1
View File
@@ -1,4 +1,5 @@
import { pgTable, uuid, text, timestamp, date, index, jsonb } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { pgTable, uuid, text, timestamp, date, index, jsonb, check, uniqueIndex } from "drizzle-orm/pg-core";
import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
@@ -12,6 +13,8 @@ export const projects = pgTable(
goalId: uuid("goal_id").references(() => goals.id),
name: text("name").notNull(),
description: text("description"),
visibility: text("visibility").notNull().default("open"),
personalOwnerUserId: text("personal_owner_user_id"),
status: text("status").notNull().default("backlog"),
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
@@ -27,5 +30,9 @@ export const projects = pgTable(
},
(table) => ({
companyIdx: index("projects_company_idx").on(table.companyId),
visibilityCheck: check("projects_visibility_check", sql`${table.visibility} in ('open', 'private')`),
personalOwnerUq: uniqueIndex("projects_company_personal_owner_uq")
.on(table.companyId, table.personalOwnerUserId)
.where(sql`${table.personalOwnerUserId} is not null`),
}),
);
+5
View File
@@ -739,11 +739,14 @@ export type {
AdapterEnvironmentTestResult,
AssetImage,
Project,
ProjectAccessMember,
ProjectAccessSubjectType,
ProjectBudgetSummary,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
CompanySearchCountType,
CompanySearchExtractIssueResult,
@@ -1571,10 +1574,12 @@ export {
type UpdateAgentPermissions,
createProjectSchema,
updateProjectSchema,
addProjectAccessMemberSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
type CreateProject,
type UpdateProject,
type AddProjectAccessMember,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
projectExecutionWorkspacePolicySchema,
+12 -1
View File
@@ -287,7 +287,18 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
Project,
ProjectAccessMember,
ProjectAccessSubjectType,
ProjectBudgetSummary,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
ProjectManagedByPlugin,
ProjectVisibility,
ProjectWorkspace,
} from "./project.js";
export type {
CompanySearchCountType,
CompanySearchExtractIssueResult,
+17
View File
@@ -8,6 +8,19 @@ import type { AgentEnvConfig } from "./secrets.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
export type ProjectVisibility = "open" | "private";
export type ProjectAccessSubjectType = "user" | "agent";
export interface ProjectAccessMember {
id: string;
companyId: string;
projectId: string;
subjectType: ProjectAccessSubjectType;
subjectId: string;
subjectDisplayName: string | null;
subjectAvatarUrl: string | null;
createdAt: Date;
}
export interface ProjectGoalRef {
id: string;
@@ -85,6 +98,10 @@ export interface Project {
goals: ProjectGoalRef[];
name: string;
description: string | null;
/** Omitted by older servers/fixtures; clients must treat omission as open. */
visibility?: ProjectVisibility;
/** Present only for the lazily-created per-user "My private tasks" project. */
personalOwnerUserId?: string | null;
status: ProjectStatus;
leadAgentId: string | null;
targetDate: string | null;
+2
View File
@@ -363,12 +363,14 @@ export {
export {
createProjectSchema,
updateProjectSchema,
addProjectAccessMemberSchema,
createProjectWorkspaceSchema,
updateProjectWorkspaceSchema,
projectExecutionWorkspacePolicySchema,
projectWorkspaceRuntimeConfigSchema,
type CreateProject,
type UpdateProject,
type AddProjectAccessMember,
type CreateProjectWorkspace,
type UpdateProjectWorkspace,
type ProjectExecutionWorkspacePolicy,
@@ -103,6 +103,7 @@ const projectFields = {
goalIds: z.array(z.string().uuid()).optional(),
name: z.string().min(1),
description: z.string().optional().nullable(),
visibility: z.enum(["open", "private"]).optional().default("open"),
status: z.enum(PROJECT_STATUSES).optional().default("backlog"),
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
@@ -124,4 +125,11 @@ export const updateProjectSchema = z.object(projectFields).partial();
export type UpdateProject = z.infer<typeof updateProjectSchema>;
export const addProjectAccessMemberSchema = z.object({
subjectType: z.enum(["user", "agent"]),
subjectId: z.string().min(1),
});
export type AddProjectAccessMember = z.infer<typeof addProjectAccessMemberSchema>;
export type ProjectExecutionWorkspacePolicy = z.infer<typeof projectExecutionWorkspacePolicySchema>;
@@ -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[];
+12
View File
@@ -1,5 +1,6 @@
import type {
Project,
ProjectAccessMember,
ProjectWorkspace,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
@@ -29,6 +30,17 @@ export const projectsApi = {
api.post<Project>(`/companies/${companyId}/projects`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Project>(projectPath(id, companyId), data),
listAccessMembers: (id: string, companyId?: string) =>
api.get<ProjectAccessMember[]>(projectPath(id, companyId, "/access-members")),
addAccessMember: (
id: string,
data: { subjectType: "user" | "agent"; subjectId: string },
companyId?: string,
) => api.post<ProjectAccessMember>(projectPath(id, companyId, "/access-members"), data),
removeAccessMember: (id: string, memberId: string, companyId?: string) =>
api.delete<ProjectAccessMember>(
projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`),
),
listWorkspaces: (projectId: string, companyId?: string) =>
api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
+44 -2
View File
@@ -230,8 +230,12 @@ vi.mock("@/components/ui/button", () => ({
}));
vi.mock("@/components/ui/toggle-switch", () => ({
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
ToggleSwitch: ({ checked, onCheckedChange, ...props }: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
"aria-label"?: string;
}) => (
<button type="button" aria-pressed={checked} onClick={() => onCheckedChange(!checked)} {...props}>toggle</button>
),
}));
@@ -476,6 +480,44 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("defaults a private task to the current user's personal private project", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "personal-project",
companyId: "company-1",
name: "My private tasks",
visibility: "private",
personalOwnerUserId: "user-1",
archivedAt: null,
},
]);
dialogState.newIssueDefaults = { title: "Private scratch task" };
const { root } = renderDialog(container);
await flush();
await flush();
const privateToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Private task"]');
expect(privateToggle).not.toBeNull();
await act(async () => privateToggle!.click());
await flush();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Task"));
await waitForAssertion(() => expect(submitButton?.hasAttribute("disabled")).toBe(false));
await act(async () => submitButton!.click());
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Private scratch task",
visibility: "private",
projectId: "personal-project",
}),
);
act(() => root.unmount());
});
it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
const { root } = renderDialog(container);
await flush();
+14 -1
View File
@@ -1226,6 +1226,7 @@ export function NewIssueDialog() {
if (nextProjectId) trackRecentProject(nextProjectId);
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
if (nextProject?.visibility === "private") setIsPrivate(true);
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
@@ -1957,7 +1958,19 @@ export function NewIssueDialog() {
</div>
<ToggleSwitch
checked={isPrivate}
onCheckedChange={setIsPrivate}
onCheckedChange={(checked) => {
setIsPrivate(checked);
if (!checked) {
const selectedProject = orderedProjects.find((project) => project.id === projectId);
if (selectedProject?.visibility === "private") handleProjectChange("");
return;
}
if (projectId || !currentUserId) return;
const personalProject = orderedProjects.find(
(project) => project.personalOwnerUserId === currentUserId,
);
if (personalProject) handleProjectChange(personalProject.id);
}}
aria-label="Private task"
/>
</div>
+144
View File
@@ -0,0 +1,144 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Trash2, UserPlus } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { useToastActions } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
export function ProjectAccessMembers({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState("");
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const membersKey = queryKeys.projects.accessMembers(project.id);
const membersQuery = useQuery({
queryKey: membersKey,
queryFn: () => projectsApi.listAccessMembers(project.id, project.companyId),
enabled: open,
});
const directoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(project.companyId),
queryFn: () => accessApi.listUserDirectory(project.companyId),
enabled: open,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(project.companyId),
queryFn: () => agentsApi.list(project.companyId),
enabled: open,
});
const activeKeys = useMemo(
() => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)),
[membersQuery.data],
);
const groups = useMemo<SearchableSelectGroup[]>(() => {
const people = (directoryQuery.data?.users ?? [])
.filter((entry) => entry.user && !activeKeys.has(`user:${entry.user.id}`))
.map((entry) => ({
key: `user:${entry.user!.id}`,
value: `user:${entry.user!.id}`,
label: entry.user!.name ?? entry.user!.email ?? "Unknown user",
searchText: entry.user!.email ?? "",
}));
const agentOptions = (agentsQuery.data ?? [])
.filter((agent) => !activeKeys.has(`agent:${agent.id}`))
.map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name }));
return [
...(people.length ? [{ id: "people", label: "People", options: people }] : []),
...(agentOptions.length ? [{ id: "agents", label: "Agents", options: agentOptions }] : []),
];
}, [activeKeys, agentsQuery.data, directoryQuery.data]);
const addMember = useMutation({
mutationFn: async () => {
const [subjectType, subjectId] = selection.split(/:(.+)/) as ["user" | "agent", string];
return projectsApi.addAccessMember(project.id, { subjectType, subjectId }, project.companyId);
},
onSuccess: () => {
setSelection("");
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access added", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }),
});
const removeMember = useMutation({
mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access removed", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }),
});
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Lock className="h-3.5 w-3.5" aria-hidden="true" /> Manage access
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Private project access</DialogTitle>
<DialogDescription>
Members can discover this project and read every task in it. A task can still be shared directly.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{directoryQuery.isError || agentsQuery.isError ? (
<p className="text-sm text-destructive">Couldn&apos;t load the company access directory.</p>
) : null}
<div className="flex items-end gap-2">
<SearchableSelect
value={selection}
groups={groups}
onValueChange={(value) => setSelection(value)}
placeholder="Add a person or agent"
searchPlaceholder="Search people and agents"
emptyMessage="No more company members to add"
loading={directoryQuery.isLoading || agentsQuery.isLoading}
className="min-w-0 flex-1"
/>
<Button size="sm" disabled={!selection || addMember.isPending} onClick={() => addMember.mutate()}>
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add
</Button>
</div>
<div className="divide-y divide-border/60">
{(membersQuery.data ?? []).map((member) => {
const isPersonalOwner = project.personalOwnerUserId === member.subjectId && member.subjectType === "user";
return (
<div key={member.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{member.subjectDisplayName ?? "Unknown member"}</div>
<div className="text-xs text-muted-foreground">
{isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"}
</div>
</div>
{!isPersonalOwner ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${member.subjectDisplayName ?? "member"}`}
disabled={removeMember.isPending}
onClick={() => removeMember.mutate(member.id)}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
);
})}
{membersQuery.isLoading ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
{membersQuery.isError ? (
<p className="py-3 text-sm text-destructive">Couldn&apos;t load project access members.</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+21
View File
@@ -23,6 +23,7 @@ import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
import { ProjectAccessMembers } from "./ProjectAccessMembers";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -46,6 +47,7 @@ export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "visibility"
| "goals"
| "env"
| "execution_workspace_enabled"
@@ -559,6 +561,25 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<StatusBadge status={project.status} />
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Visibility" state={fieldState("visibility")} />} alignStart>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<ToggleSwitch
checked={project.visibility === "private"}
onCheckedChange={(checked) => {
if (!checked && !window.confirm("Make this project open to everyone in the company?")) return;
commitField("visibility", { visibility: checked ? "private" : "open" });
}}
disabled={!onUpdate && !onFieldUpdate}
/>
<span>{project.visibility === "private" ? "Private" : "Open to company"}</span>
</label>
<p className="text-(length:--text-micro) text-muted-foreground">
Only access members can discover this project. Tasks shared directly remain readable on their own.
</p>
{project.visibility === "private" ? <ProjectAccessMembers project={project} /> : null}
</div>
</PropertyRow>
{project.leadAgentId && (
<PropertyRow label="Lead">
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
+1
View File
@@ -285,6 +285,7 @@ export const queryKeys = {
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
["projects", companyId, { includeArchived: opts.includeArchived === true }] as const,
detail: (id: string) => ["projects", "detail", id] as const,
accessMembers: (id: string) => ["projects", "access-members", id] as const,
},
cases: {
list: (companyId: string) => ["cases", companyId] as const,