diff --git a/doc/DATABASE.md b/doc/DATABASE.md index c7aa8676357..0f8cbd28203 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -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. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 23abfd65ec8..7fd18c27ba9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index bfdd569ee24..9643daa706f 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -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 diff --git a/packages/db/src/migrations/0200_private_projects.sql b/packages/db/src/migrations/0200_private_projects.sql new file mode 100644 index 00000000000..c39a61d1e3f --- /dev/null +++ b/packages/db/src/migrations/0200_private_projects.sql @@ -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"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ce8bf7b8f0d..6806b3a39c5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 65dd2e29cfa..e8f6df1b0cc 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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"; diff --git a/packages/db/src/schema/project_access_members.ts b/packages/db/src/schema/project_access_members.ts new file mode 100644 index 00000000000..6f5b3912958 --- /dev/null +++ b/packages/db/src/schema/project_access_members.ts @@ -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, + ), + }), +); diff --git a/packages/db/src/schema/projects.ts b/packages/db/src/schema/projects.ts index 38910bc766f..813b05ff511 100644 --- a/packages/db/src/schema/projects.ts +++ b/packages/db/src/schema/projects.ts @@ -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`), }), ); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e9b59efa82b..ee695a9e062 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 51ca70bfaaa..a746e5e6258 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index d1f51148227..a56d2c25b93 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index ed562e0e45a..b4b866c4864 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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, diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 45b2d0e146d..c212373abfb 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -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; +export const addProjectAccessMemberSchema = z.object({ + subjectType: z.enum(["user", "agent"]), + subjectId: z.string().min(1), +}); + +export type AddProjectAccessMember = z.infer; + export type ProjectExecutionWorkspacePolicy = z.infer; diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 40c80c11162..11a2086a149 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -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, companyId: string, label: string) { +async function createProject( + db: ReturnType, + 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 { diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index cda0b18d825..acc9d5173fc 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -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; + } + }); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 4a34a19dcf4..ceb90dbb329 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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(); diff --git a/server/src/__tests__/projects-list-archived-routes.test.ts b/server/src/__tests__/projects-list-archived-routes.test.ts index b8201552f79..221e1eda4ad 100644 --- a/server/src/__tests__/projects-list-archived-routes.test.ts +++ b/server/src/__tests__/projects-list-archived-routes.test.ts @@ -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; + let tempDb: Awaited> | 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); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b65a59a6a3f..02a25cd26d3 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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 }, + options?: { issueReadCondition?: SQL; projectReadCondition?: SQL }, ): Promise; }; 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) { diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 5476db84f5f..5e247c9d123 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -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) { + 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(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" }); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 04b00841373..d296cb48d42 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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`false`; - return sql`(${issues.visibility} = 'open' or ${implicit} or ${grant})`; + const projectMember = principal + ? sql`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`false`; + const projectIsPrivate = sql`exists ( + select 1 from ${projects} + where ${projects.id} = ${issues.projectId} + and ${projects.companyId} = ${issues.companyId} + and ${projects.visibility} = 'private' + )`; + return sql`( + ${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> { + if (issuePrivacyMode() !== "enforce") return sql`true`; + if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql`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`true`; + const principal = issuePrivacyPrincipal(actor); + if (!principal) return sql`${projects.visibility} = 'open'`; + return sql`( + ${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 { 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))) diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 0e77f9a8bd8..830213f0e2f 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -561,7 +561,7 @@ export function companySearchService(db: Db) { search: async ( companyId: string, query: CompanySearchQuery, - options?: { issueReadCondition?: SQL }, + options?: { issueReadCondition?: SQL; projectReadCondition?: SQL }, ): Promise => { 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`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`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); } diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 23818291e9c..547d1538081 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 5ac1b50e4c1..6b4aabc45d6 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -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; +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) => 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 { urlKey: string; goalIds: string[]; diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index af1c736a52d..a93d4364d94 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,5 +1,6 @@ import type { Project, + ProjectAccessMember, ProjectWorkspace, WorkspaceOperation, WorkspaceRuntimeControlTarget, @@ -29,6 +30,17 @@ export const projectsApi = { api.post(`/companies/${companyId}/projects`, data), update: (id: string, data: Record, companyId?: string) => api.patch(projectPath(id, companyId), data), + listAccessMembers: (id: string, companyId?: string) => + api.get(projectPath(id, companyId, "/access-members")), + addAccessMember: ( + id: string, + data: { subjectType: "user" | "agent"; subjectId: string }, + companyId?: string, + ) => api.post(projectPath(id, companyId, "/access-members"), data), + removeAccessMember: (id: string, memberId: string, companyId?: string) => + api.delete( + projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`), + ), listWorkspaces: (projectId: string, companyId?: string) => api.get(projectPath(projectId, companyId, "/workspaces")), createWorkspace: (projectId: string, data: Record, companyId?: string) => diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index cf5fe5227a6..7a2fa542b53 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -230,8 +230,12 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/toggle-switch", () => ({ - ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => ( - + ToggleSwitch: ({ checked, onCheckedChange, ...props }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + "aria-label"?: string; + }) => ( + ), })); @@ -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('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(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 8bf4440ce82..5e5243f32d5 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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() { { + 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" /> diff --git a/ui/src/components/ProjectAccessMembers.tsx b/ui/src/components/ProjectAccessMembers.tsx new file mode 100644 index 00000000000..fc73958ed6d --- /dev/null +++ b/ui/src/components/ProjectAccessMembers.tsx @@ -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(() => { + 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 ( + <> + + + + + Private project access + + Members can discover this project and read every task in it. A task can still be shared directly. + + +
+ {directoryQuery.isError || agentsQuery.isError ? ( +

Couldn't load the company access directory.

+ ) : null} +
+ 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" + /> + +
+
+ {(membersQuery.data ?? []).map((member) => { + const isPersonalOwner = project.personalOwnerUserId === member.subjectId && member.subjectType === "user"; + return ( +
+
+
{member.subjectDisplayName ?? "Unknown member"}
+
+ {isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"} +
+
+ {!isPersonalOwner ? ( + + ) : null} +
+ ); + })} + {membersQuery.isLoading ?

Loading access…

: null} + {membersQuery.isError ? ( +

Couldn't load project access members.

+ ) : null} +
+
+
+
+ + ); +} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index c078e317dbf..554bf7f2fee 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -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 )} + } alignStart> +
+ +

+ Only access members can discover this project. Tasks shared directly remain readable on their own. +

+ {project.visibility === "private" ? : null} +
+
{project.leadAgentId && ( {project.leadAgentId.slice(0, 8)} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 70aff60500a..14bfecd70df 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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,