mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat: add private project access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
df05b1a968
commit
c761282d76
29 files changed
+1197
-29
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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`),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -176,9 +176,11 @@ import {
|
||||
authorizationDeniedDetails,
|
||||
invalidateIssuePrivacyGrantCache,
|
||||
issueReadSqlCondition,
|
||||
projectReadSqlCondition,
|
||||
} from "../services/authorization.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { ensurePersonalPrivateProject } from "../services/projects.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import {
|
||||
createCompanySearchRateLimiter,
|
||||
@@ -297,7 +299,7 @@ type CompanySearchService = {
|
||||
search(
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean> },
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchResponse>;
|
||||
};
|
||||
type ActivityIssueRelationSummary = {
|
||||
@@ -3490,6 +3492,14 @@ export function issueRoutes(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function canActorReadProject(req: Request, project: { id: string; companyId: string }) {
|
||||
return access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
||||
}).then((decision) => decision.allowed);
|
||||
}
|
||||
|
||||
async function assertCanManageIssuePrivacy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -4901,6 +4911,8 @@ export function issueRoutes(
|
||||
goals: project.goals,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
visibility: project.visibility,
|
||||
personalOwnerUserId: project.personalOwnerUserId,
|
||||
status: project.status,
|
||||
leadAgentId: project.leadAgentId,
|
||||
targetDate: project.targetDate,
|
||||
@@ -5099,6 +5111,7 @@ export function issueRoutes(
|
||||
}
|
||||
const result = await getSearchService().search(companyId, query, {
|
||||
issueReadCondition: await issueReadSqlCondition(db, req.actor),
|
||||
projectReadCondition: await projectReadSqlCondition(db, req.actor),
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
@@ -5873,6 +5886,13 @@ export function issueRoutes(
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
const [visibleProject, mentionedProjectVisibility] = await Promise.all([
|
||||
project
|
||||
? canActorReadProject(req, project).then((allowed) => allowed ? project : null)
|
||||
: Promise.resolve(null),
|
||||
Promise.all(mentionedProjects.map((mentionedProject) => canActorReadProject(req, mentionedProject))),
|
||||
]);
|
||||
const visibleMentionedProjects = mentionedProjects.filter((_, index) => mentionedProjectVisibility[index]);
|
||||
const currentExecutionWorkspace = issue.executionWorkspaceId
|
||||
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
||||
: null;
|
||||
@@ -5892,9 +5912,9 @@ export function issueRoutes(
|
||||
relatedWork: visibleReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
...documentPayload,
|
||||
project: compactIssueProject(project),
|
||||
project: compactIssueProject(visibleProject),
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
mentionedProjects: visibleMentionedProjects,
|
||||
currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace),
|
||||
workProducts,
|
||||
linkedCases,
|
||||
@@ -7515,6 +7535,22 @@ export function issueRoutes(
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (createBody.projectId) {
|
||||
const selectedProject = await projectsSvc.getById(createBody.projectId);
|
||||
if (!selectedProject || selectedProject.companyId !== companyId) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const projectDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId, projectId: selectedProject.id },
|
||||
});
|
||||
if (!projectDecision.allowed) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, createBody))) return;
|
||||
const createAssignmentScope = {
|
||||
projectId: await resolveAssignmentProjectId({
|
||||
@@ -8249,6 +8285,34 @@ export function issueRoutes(
|
||||
hiddenAt: hiddenAtRaw,
|
||||
...updateFields
|
||||
} = req.body;
|
||||
if (req.body.visibility === "private" && !existing.projectId && updateFields.projectId === undefined) {
|
||||
const responsibleUserId = existing.responsibleUserId ?? (req.actor.type === "board"
|
||||
? req.actor.userId ?? null
|
||||
: req.actor.onBehalfOfUserId ?? null);
|
||||
if (!responsibleUserId) {
|
||||
res.status(422).json({ error: "Private tasks require a responsible user" });
|
||||
return;
|
||||
}
|
||||
const personalProject = await db.transaction((tx) =>
|
||||
ensurePersonalPrivateProject(tx, existing.companyId, responsibleUserId));
|
||||
updateFields.projectId = personalProject.id;
|
||||
}
|
||||
if (updateFields.projectId) {
|
||||
const selectedProject = await projectsSvc.getById(updateFields.projectId as string);
|
||||
if (!selectedProject || selectedProject.companyId !== existing.companyId) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
const projectDecision = await access.decide({
|
||||
actor: req.actor,
|
||||
action: "project:read",
|
||||
resource: { type: "project", companyId: existing.companyId, projectId: selectedProject.id },
|
||||
});
|
||||
if (!projectDecision.allowed) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const shouldCancelActiveRunForCancelledStatus =
|
||||
existing.status !== "cancelled" && updateFields.status === "cancelled";
|
||||
if (resumeRequested === true && !commentBody) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, authUsers, companyMemberships, projectAccessMembers } from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
addProjectAccessMemberSchema,
|
||||
createProjectSchema,
|
||||
createProjectWorkspaceSchema,
|
||||
deriveProjectUrlKey,
|
||||
findWorkspaceCommandDefinition,
|
||||
isUuidLike,
|
||||
matchWorkspaceRuntimeServiceToCommand,
|
||||
normalizeProjectUrlKey,
|
||||
updateProjectSchema,
|
||||
updateProjectWorkspaceSchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
@@ -17,7 +22,8 @@ import { accessService, projectService, logActivity, workspaceOperationService }
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { externalObjectService } from "../services/external-objects.js";
|
||||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
|
||||
import { ensureProjectAccessMember } from "../services/projects.js";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
@@ -88,11 +94,17 @@ export function projectRoutes(db: Db) {
|
||||
if (isUuidLike(rawId)) return rawId;
|
||||
const companyId = await resolveCompanyIdForProjectReference(req);
|
||||
if (!companyId) return rawId;
|
||||
const resolved = await svc.resolveByReference(companyId, rawId);
|
||||
if (resolved.ambiguous) {
|
||||
const urlKey = normalizeProjectUrlKey(rawId);
|
||||
if (!urlKey) return rawId;
|
||||
const visibleProjects = await filterProjectsForActor(
|
||||
req,
|
||||
await svc.list(companyId, { includeArchived: true }),
|
||||
);
|
||||
const matches = visibleProjects.filter((project) => deriveProjectUrlKey(project.name, project.id) === urlKey);
|
||||
if (matches.length > 1) {
|
||||
throw conflict("Project shortname is ambiguous in this company. Use the project ID.");
|
||||
}
|
||||
return resolved.project?.id ?? rawId;
|
||||
return matches[0]?.id ?? rawId;
|
||||
}
|
||||
|
||||
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
|
||||
@@ -102,10 +114,64 @@ export function projectRoutes(db: Db) {
|
||||
resource: { type: "project", companyId: project.companyId, projectId: project.id },
|
||||
});
|
||||
if (decision.allowed) return true;
|
||||
res.status(403).json({ error: "Project is outside this actor's authorization boundary" });
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addActorAsPrivateProjectPrincipal(req: Request, project: { id: string; companyId: string }) {
|
||||
if (req.actor.type === "board" && req.actor.userId) {
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.actor.type === "agent" && req.actor.agentId) {
|
||||
if (req.actor.onBehalfOfUserId) {
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: req.actor.onBehalfOfUserId,
|
||||
});
|
||||
}
|
||||
await ensureProjectAccessMember(db, {
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "agent",
|
||||
subjectId: req.actor.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichProjectAccessMembers(rows: Array<typeof projectAccessMembers.$inferSelect>) {
|
||||
const agentIds = rows.filter((row) => row.subjectType === "agent").map((row) => row.subjectId);
|
||||
const userIds = rows.filter((row) => row.subjectType === "user").map((row) => row.subjectId);
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: agents.id, name: agents.name }).from(agents).where(inArray(agents.id, agentIds)),
|
||||
userIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email, image: authUsers.image })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds)),
|
||||
]);
|
||||
const agentsById = new Map(agentRows.map((row) => [row.id, row]));
|
||||
const usersById = new Map(userRows.map((row) => [row.id, row]));
|
||||
return rows.map((row) => {
|
||||
const agent = row.subjectType === "agent" ? agentsById.get(row.subjectId) : null;
|
||||
const user = row.subjectType === "user" ? usersById.get(row.subjectId) : null;
|
||||
return {
|
||||
...row,
|
||||
subjectDisplayName: agent?.name ?? user?.name ?? user?.email ?? null,
|
||||
subjectAvatarUrl: user?.image ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function filterProjectsForActor<T extends { id: string; companyId: string }>(req: Request, rows: T[]) {
|
||||
const decisions = await Promise.all(rows.map((project) =>
|
||||
access.decide({
|
||||
@@ -146,6 +212,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const summary = await externalObjectsSvc.getProjectSummary(project.id);
|
||||
res.json(summary);
|
||||
});
|
||||
@@ -177,6 +244,7 @@ export function projectRoutes(db: Db) {
|
||||
);
|
||||
}
|
||||
const project = await svc.create(companyId, projectData);
|
||||
if (project.visibility === "private") await addActorAsPrivateProjectPrincipal(req, project);
|
||||
if (project.env) {
|
||||
await secretsSvc.syncEnvBindingsForTarget?.(
|
||||
companyId,
|
||||
@@ -222,7 +290,9 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const body = { ...req.body };
|
||||
if (body.visibility === "private") await addActorAsPrivateProjectPrincipal(req, existing);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||
@@ -274,10 +344,99 @@ export function projectRoutes(db: Db) {
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/access-members", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(projectAccessMembers)
|
||||
.where(and(eq(projectAccessMembers.companyId, project.companyId), eq(projectAccessMembers.projectId, project.id)));
|
||||
res.json(await enrichProjectAccessMembers(rows));
|
||||
});
|
||||
|
||||
router.post("/projects/:id/access-members", validate(addProjectAccessMemberSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const subjectType = req.body.subjectType as "user" | "agent";
|
||||
const subjectId = req.body.subjectId as string;
|
||||
const subjectExists = subjectType === "agent"
|
||||
? await db.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.id, subjectId), eq(agents.companyId, project.companyId)))
|
||||
.then((rows) => rows.length > 0)
|
||||
: await db.select({ id: companyMemberships.id }).from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, subjectId),
|
||||
eq(companyMemberships.companyId, project.companyId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
))
|
||||
.then((rows) => rows.length > 0);
|
||||
if (!subjectExists) {
|
||||
res.status(422).json({ error: "Project access member must belong to this company" });
|
||||
return;
|
||||
}
|
||||
await ensureProjectAccessMember(db, { companyId: project.companyId, projectId: project.id, subjectType, subjectId });
|
||||
const rows = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
eq(projectAccessMembers.subjectType, subjectType),
|
||||
eq(projectAccessMembers.subjectId, subjectId),
|
||||
));
|
||||
const [member] = await enrichProjectAccessMembers(rows);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_added",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType, subjectId },
|
||||
});
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
router.delete("/projects/:id/access-members/:memberId", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
|
||||
const member = await db.select().from(projectAccessMembers).where(and(
|
||||
eq(projectAccessMembers.id, req.params.memberId as string),
|
||||
eq(projectAccessMembers.companyId, project.companyId),
|
||||
eq(projectAccessMembers.projectId, project.id),
|
||||
)).then((rows) => rows[0] ?? null);
|
||||
if (!member) {
|
||||
res.status(404).json({ error: "Project access member not found" });
|
||||
return;
|
||||
}
|
||||
if (project.personalOwnerUserId === member.subjectId && member.subjectType === "user") {
|
||||
res.status(422).json({ error: "The owner of My private tasks cannot be removed" });
|
||||
return;
|
||||
}
|
||||
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, member.id));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: project.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
action: "project.access_member_removed",
|
||||
entityType: "project",
|
||||
entityId: project.id,
|
||||
details: { subjectType: member.subjectType, subjectId: member.subjectId },
|
||||
});
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
router.get("/projects/:id/workspaces", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspaces = await svc.listWorkspaces(id);
|
||||
res.json(workspaces);
|
||||
});
|
||||
@@ -286,6 +445,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -324,6 +484,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectProjectWorkspaceCommandPaths(req.body),
|
||||
@@ -369,6 +530,7 @@ export function projectRoutes(db: Db) {
|
||||
|
||||
const project = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!project) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, project))) return;
|
||||
|
||||
const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
@@ -637,6 +799,7 @@ export function projectRoutes(db: Db) {
|
||||
const workspaceId = req.params.workspaceId as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const workspace = await svc.removeWorkspace(id, workspaceId);
|
||||
if (!workspace) {
|
||||
res.status(404).json({ error: "Project workspace not found" });
|
||||
@@ -665,6 +828,7 @@ export function projectRoutes(db: Db) {
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Project not found");
|
||||
if (!existing) return;
|
||||
if (!(await assertProjectReadAllowed(req, res, existing))) return;
|
||||
const project = await svc.remove(id);
|
||||
if (!project) {
|
||||
res.status(404).json({ error: "Project not found" });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
issueAccessGrants,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projectAccessMembers,
|
||||
projects,
|
||||
userInboxAgentPolicies,
|
||||
} from "@paperclipai/db";
|
||||
@@ -121,6 +122,7 @@ export type AuthorizationDecision = {
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_issue_private"
|
||||
| "deny_project_private"
|
||||
| "deny_scope"
|
||||
| "deny_unsupported_action";
|
||||
grant?: {
|
||||
@@ -237,7 +239,7 @@ type ProjectAuthorizationRow = {
|
||||
type IssueAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
projectId?: string | null;
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -468,6 +470,13 @@ export type IssuePrivacyRow = {
|
||||
createdByUserId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
projectId?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectPrivacyRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
visibility: string;
|
||||
};
|
||||
|
||||
export const ISSUE_PRIVACY_GRANT_CACHE_MAX_ENTRIES = 256;
|
||||
@@ -535,6 +544,36 @@ function issuePrivacyPrincipal(actor: AuthorizationActor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function actorHasProjectAccess(
|
||||
db: Db,
|
||||
actor: AuthorizationActor,
|
||||
companyId: string,
|
||||
projectId: string,
|
||||
) {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return false;
|
||||
return db
|
||||
.select({ id: projectAccessMembers.id })
|
||||
.from(projectAccessMembers)
|
||||
.where(and(
|
||||
eq(projectAccessMembers.companyId, companyId),
|
||||
eq(projectAccessMembers.projectId, projectId),
|
||||
eq(projectAccessMembers.subjectType, principal.type),
|
||||
eq(projectAccessMembers.subjectId, principal.id),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
export async function canActorReadProjectPrivacy(
|
||||
db: Db,
|
||||
actor: AuthorizationActor,
|
||||
project: ProjectPrivacyRow,
|
||||
) {
|
||||
if (project.visibility !== "private") return true;
|
||||
return actorHasProjectAccess(db, actor, project.companyId, project.id);
|
||||
}
|
||||
|
||||
function actorIsImplicitIssuePrincipal(actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return false;
|
||||
@@ -578,9 +617,17 @@ async function actorHasIssuePrivacyGrant(db: Db, actor: AuthorizationActor, issu
|
||||
|
||||
/** Canonical row-level issue privacy predicate, also consumed by run-derived checks. */
|
||||
export async function canActorReadIssuePrivacy(db: Db, actor: AuthorizationActor, issue: IssuePrivacyRow) {
|
||||
if (issue.visibility !== "private") return true;
|
||||
if (actorIsImplicitIssuePrincipal(actor, issue)) return true;
|
||||
return actorHasIssuePrivacyGrant(db, actor, issue);
|
||||
if (await actorHasIssuePrivacyGrant(db, actor, issue)) return true;
|
||||
if (issue.projectId) {
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project?.visibility === "private") return actorHasProjectAccess(db, actor, project.companyId, project.id);
|
||||
}
|
||||
return issue.visibility !== "private";
|
||||
}
|
||||
|
||||
/** SQL equivalent of canActorReadIssuePrivacy for list/count/search query pushdown. */
|
||||
@@ -621,7 +668,59 @@ export async function issueReadSqlCondition(db: Db, actor: AuthorizationActor):
|
||||
and ${issueAccessGrants.issueId} in (${issues.id}, coalesce(${issues.privacyRootIssueId}, ${issues.id}))
|
||||
)`
|
||||
: sql<boolean>`false`;
|
||||
return sql<boolean>`(${issues.visibility} = 'open' or ${implicit} or ${grant})`;
|
||||
const projectMember = principal
|
||||
? sql<boolean>`exists (
|
||||
select 1 from ${projectAccessMembers}
|
||||
where ${projectAccessMembers.companyId} = ${issues.companyId}
|
||||
and ${projectAccessMembers.projectId} = ${issues.projectId}
|
||||
and ${projectAccessMembers.subjectType} = ${principal.type}
|
||||
and ${projectAccessMembers.subjectId} = ${principal.id}
|
||||
)`
|
||||
: sql<boolean>`false`;
|
||||
const projectIsPrivate = sql<boolean>`exists (
|
||||
select 1 from ${projects}
|
||||
where ${projects.id} = ${issues.projectId}
|
||||
and ${projects.companyId} = ${issues.companyId}
|
||||
and ${projects.visibility} = 'private'
|
||||
)`;
|
||||
return sql<boolean>`(
|
||||
${implicit}
|
||||
or ${grant}
|
||||
or ${projectMember}
|
||||
or (${issues.visibility} = 'open' and not ${projectIsPrivate})
|
||||
)`;
|
||||
}
|
||||
|
||||
/** SQL equivalent used for project list/search pushdown. */
|
||||
export async function projectReadSqlCondition(db: Db, actor: AuthorizationActor): Promise<SQL<boolean>> {
|
||||
if (issuePrivacyMode() !== "enforce") return sql<boolean>`true`;
|
||||
if (actor.source === "local_implicit" || actor.isInstanceAdmin) return sql<boolean>`true`;
|
||||
if (
|
||||
actor.type === "board"
|
||||
&& !actor.ignoreInstanceAdmin
|
||||
&& actor.source !== "cloud_tenant"
|
||||
&& Boolean(await db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(
|
||||
eq(instanceUserRoles.userId, actor.userId ?? ""),
|
||||
eq(instanceUserRoles.role, "instance_admin"),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null))
|
||||
) return sql<boolean>`true`;
|
||||
const principal = issuePrivacyPrincipal(actor);
|
||||
if (!principal) return sql<boolean>`${projects.visibility} = 'open'`;
|
||||
return sql<boolean>`(
|
||||
${projects.visibility} = 'open'
|
||||
or exists (
|
||||
select 1 from ${projectAccessMembers}
|
||||
where ${projectAccessMembers.companyId} = ${projects.companyId}
|
||||
and ${projectAccessMembers.projectId} = ${projects.id}
|
||||
and ${projectAccessMembers.subjectType} = ${principal.type}
|
||||
and ${projectAccessMembers.subjectId} = ${principal.id}
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
function responsibleUserSnapshotTtlMs() {
|
||||
@@ -2345,6 +2444,39 @@ export function authorizationService(db: Db) {
|
||||
}): Promise<AuthorizationDecision> {
|
||||
const agentDecision = await decideBase(input);
|
||||
const intersectedDecision = await applyResponsibleUserIntersection(input, agentDecision);
|
||||
if (
|
||||
input.action === "project:read"
|
||||
&& input.resource.type === "project"
|
||||
&& input.resource.projectId
|
||||
&& intersectedDecision.allowed
|
||||
&& intersectedDecision.reason !== "allow_instance_admin"
|
||||
&& intersectedDecision.reason !== "allow_local_board"
|
||||
&& issuePrivacyMode() !== "off"
|
||||
) {
|
||||
const project = await db
|
||||
.select({ id: projects.id, companyId: projects.companyId, visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, input.resource.projectId), eq(projects.companyId, input.resource.companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (project && !(await canActorReadProjectPrivacy(db, input.actor, project))) {
|
||||
const denied = deny({
|
||||
action: input.action,
|
||||
reason: "deny_project_private",
|
||||
explanation: "Project is private and the actor is not an access member.",
|
||||
});
|
||||
logger.warn({
|
||||
authzMode: issuePrivacyMode(),
|
||||
action: input.action,
|
||||
companyId: project.companyId,
|
||||
projectId: project.id,
|
||||
actorType: input.actor.type,
|
||||
actorUserId: input.actor.type === "board" ? input.actor.userId ?? null : null,
|
||||
actorAgentId: input.actor.type === "agent" ? input.actor.agentId ?? null : null,
|
||||
reason: denied.reason,
|
||||
}, "project privacy would deny read");
|
||||
return issuePrivacyMode() === "shadow" ? intersectedDecision : denied;
|
||||
}
|
||||
}
|
||||
if (
|
||||
input.action !== "issue:read"
|
||||
|| input.resource.type !== "issue"
|
||||
@@ -2365,6 +2497,7 @@ export function authorizationService(db: Db) {
|
||||
createdByUserId: issues.createdByUserId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
projectId: issues.projectId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, input.resource.issueId), eq(issues.companyId, input.resource.companyId)))
|
||||
|
||||
@@ -561,7 +561,7 @@ export function companySearchService(db: Db) {
|
||||
search: async (
|
||||
companyId: string,
|
||||
query: CompanySearchQuery,
|
||||
options?: { issueReadCondition?: SQL<boolean> },
|
||||
options?: { issueReadCondition?: SQL<boolean>; projectReadCondition?: SQL<boolean> },
|
||||
): Promise<CompanySearchResponse> => {
|
||||
const normalizedQuery = normalizeQuery(query.q);
|
||||
const hasSearchText = normalizedQuery.length > 0;
|
||||
@@ -1086,6 +1086,7 @@ export function companySearchService(db: Db) {
|
||||
sql`${projects.name}`,
|
||||
sql`${projects.description}`,
|
||||
], containsPattern, tokenPatternArray);
|
||||
const projectReadCondition = options?.projectReadCondition ?? sql<boolean>`true`;
|
||||
|
||||
async function fetchAgentRows() {
|
||||
if (!hasSearchText || !scopeIncludesAgents(scope) || hasIssueOnlyFilters) return [];
|
||||
@@ -1115,7 +1116,7 @@ export function companySearchService(db: Db) {
|
||||
updatedAt: projects.updatedAt,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition))
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition))
|
||||
.orderBy(desc(projects.updatedAt), desc(projects.id))
|
||||
.limit(fetchLimit);
|
||||
}
|
||||
@@ -1202,7 +1203,7 @@ export function companySearchService(db: Db) {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition));
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.archivedAt), projectCondition, projectReadCondition));
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalizatio
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { invalidateIssuePrivacyGrantCache } from "./authorization.js";
|
||||
import { buildIssueChanges } from "./issue-change-receipt.js";
|
||||
import { ensurePersonalPrivateProject } from "./projects.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500;
|
||||
@@ -6659,6 +6660,7 @@ export function issueService(db: Db) {
|
||||
|
||||
let visibility = issueData.visibility ?? "open";
|
||||
let privacyRootIssueId: string | null = null;
|
||||
let inheritedPrivateSubtree = false;
|
||||
if (issueData.parentId) {
|
||||
const parentPrivacy = await tx
|
||||
.select({
|
||||
@@ -6671,6 +6673,7 @@ export function issueService(db: Db) {
|
||||
if (parentPrivacy?.visibility === "private") {
|
||||
visibility = "private";
|
||||
privacyRootIssueId = parentPrivacy.privacyRootIssueId ?? issueData.parentId;
|
||||
inheritedPrivateSubtree = true;
|
||||
}
|
||||
}
|
||||
if (visibility === "private" && !privacyRootIssueId) {
|
||||
@@ -6678,6 +6681,30 @@ export function issueService(db: Db) {
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await tx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
visibility = "private";
|
||||
if (!privacyRootIssueId) {
|
||||
issueData.id ??= randomUUID();
|
||||
privacyRootIssueId = issueData.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibility === "private" && !issueData.projectId && !inheritedPrivateSubtree) {
|
||||
if (!responsibleUserId) {
|
||||
throw unprocessable("Private tasks require a responsible user");
|
||||
}
|
||||
const personalProject = await ensurePersonalPrivateProject(tx, companyId, responsibleUserId);
|
||||
issueData.projectId = personalProject.id;
|
||||
}
|
||||
|
||||
const values = {
|
||||
...issueData,
|
||||
visibility,
|
||||
@@ -6719,6 +6746,15 @@ export function issueService(db: Db) {
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
if (issue.visibility === "private" && !inheritedPrivateSubtree && issueData.createdByAgentId) {
|
||||
await tx.insert(issueAccessGrants).values({
|
||||
issueId: issue.privacyRootIssueId ?? issue.id,
|
||||
subjectType: "agent",
|
||||
subjectId: issueData.createdByAgentId,
|
||||
source: "explicit",
|
||||
grantedByAgentId: issueData.createdByAgentId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
await ensureAssignmentIssueAccessGrant(tx, issue, null, {
|
||||
agentId: issueData.createdByAgentId ?? null,
|
||||
userId: issueData.createdByUserId ?? null,
|
||||
@@ -7045,6 +7081,18 @@ export function issueService(db: Db) {
|
||||
} else if (issueData.visibility === "open") {
|
||||
patch.privacyRootIssueId = null;
|
||||
}
|
||||
if (issueData.projectId) {
|
||||
const selectedProject = await dbOrTx
|
||||
.select({ visibility: projects.visibility })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, existing.companyId)))
|
||||
.then((rows: Array<{ visibility: string }>) => rows[0] ?? null);
|
||||
if (!selectedProject) throw notFound("Project not found");
|
||||
if (selectedProject.visibility === "private") {
|
||||
patch.visibility = "private";
|
||||
patch.privacyRootIssueId = existing.privacyRootIssueId ?? existing.id;
|
||||
}
|
||||
}
|
||||
if (existing.status !== "blocked" && issueData.status === "blocked") {
|
||||
patch.blockedTransitionAt = patch.updatedAt;
|
||||
patch.blockedOwnerNotifiedAt = null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
projects,
|
||||
projectAccessMembers,
|
||||
projectGoals,
|
||||
goals,
|
||||
issues,
|
||||
@@ -56,6 +57,68 @@ type CreateWorkspaceInput = {
|
||||
};
|
||||
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
||||
|
||||
export async function ensureProjectAccessMember(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
subjectType: "user" | "agent";
|
||||
subjectId: string;
|
||||
},
|
||||
) {
|
||||
return dbOrTx
|
||||
.insert(projectAccessMembers)
|
||||
.values(input)
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: Array<typeof projectAccessMembers.$inferSelect>) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concurrency-safe lazy creation for the per-user private parking project.
|
||||
* The partial unique index is the final arbiter; the advisory lock avoids
|
||||
* wasting project ids and keeps the membership insert in the same transaction.
|
||||
*/
|
||||
export async function ensurePersonalPrivateProject(dbOrTx: any, companyId: string, userId: string) {
|
||||
const lockKey = `personal-private-project:${companyId}:${userId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
let project = await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
if (!project) {
|
||||
project = await dbOrTx
|
||||
.insert(projects)
|
||||
.values({
|
||||
companyId,
|
||||
name: "My private tasks",
|
||||
description: "Private tasks visible only to people and agents explicitly granted access.",
|
||||
status: "in_progress",
|
||||
visibility: "private",
|
||||
personalOwnerUserId: userId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
project ??= await dbOrTx
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.personalOwnerUserId, userId)))
|
||||
.limit(1)
|
||||
.then((rows: ProjectRow[]) => rows[0] ?? null);
|
||||
}
|
||||
if (!project) throw new Error("Failed to create personal private project");
|
||||
await ensureProjectAccessMember(dbOrTx, {
|
||||
companyId,
|
||||
projectId: project.id,
|
||||
subjectType: "user",
|
||||
subjectId: userId,
|
||||
});
|
||||
return project;
|
||||
}
|
||||
|
||||
interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy"> {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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't load project access members.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user