mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat: add private issue sharing grants
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d18b769fe4
commit
d92ef1e4b4
9 files changed
+829
-22
No files matched your search
@@ -0,0 +1,349 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueAccessGrants,
|
||||
issueReferenceMentions,
|
||||
issueRelations,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
vi.mock("../services/issue-assignment-wakeup.js", () => ({
|
||||
queueIssueAssignmentWakeup: vi.fn(),
|
||||
}));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
function boardActor(companyId: string, userId: string): Express.Request["actor"] {
|
||||
return {
|
||||
type: "board",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
}
|
||||
|
||||
function agentActor(companyId: string, agentId: string): Express.Request["actor"] {
|
||||
return { type: "agent", agentId, companyId, runId: null, source: "agent_jwt" };
|
||||
}
|
||||
|
||||
async function createApp(db: Db, actor: Express.Request["actor"]) {
|
||||
const { issueRoutes } = await import("../routes/issues.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? "Internal server error" });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres.sequential("issue access grant and locked edge routes", () => {
|
||||
let db!: Db;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-access-grants-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueReferenceMentions);
|
||||
await db.delete(issueRelations);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueAccessGrants);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(authUsers);
|
||||
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();
|
||||
});
|
||||
|
||||
async function seedFixture() {
|
||||
const companyId = randomUUID();
|
||||
const ownerId = `owner-${randomUUID()}`;
|
||||
const memberId = `member-${randomUUID()}`;
|
||||
const sharedUserId = `shared-${randomUUID()}`;
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Private issue grants",
|
||||
issuePrefix: `P${randomUUID().replace(/-/g, "").slice(0, 5).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values([
|
||||
{ id: ownerId, name: "Olivia Owner", email: `${ownerId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{ id: memberId, name: "Nora Nonreader", email: `${memberId}@example.test`, createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: sharedUserId,
|
||||
name: "Uma User",
|
||||
email: `${sharedUserId}@example.test`,
|
||||
image: "https://example.test/uma.png",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
await db.insert(companyMemberships).values([ownerId, memberId, sharedUserId].map((principalId) => ({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId,
|
||||
status: "active",
|
||||
membershipRole: "operator",
|
||||
})));
|
||||
const [assignedAgent, sharedAgent] = await db.insert(agents).values([
|
||||
{
|
||||
companyId,
|
||||
name: "Assigned Agent",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
name: "Private Helper",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {
|
||||
authorizationPolicy: {
|
||||
agentVisibility: { mode: "private", hiddenFromDefaultDirectory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
]).returning();
|
||||
const privateIssueId = randomUUID();
|
||||
await db.insert(issues).values({
|
||||
id: privateIssueId,
|
||||
companyId,
|
||||
identifier: "PRV-1",
|
||||
title: "Confidential root",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
createdByUserId: ownerId,
|
||||
});
|
||||
const privateChild = await db.insert(issues).values({
|
||||
companyId,
|
||||
parentId: privateIssueId,
|
||||
identifier: "PRV-1A",
|
||||
title: "Confidential child",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
visibility: "private",
|
||||
privacyRootIssueId: privateIssueId,
|
||||
responsibleUserId: ownerId,
|
||||
createdByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const openIssue = await db.insert(issues).values({
|
||||
companyId,
|
||||
identifier: "PRV-2",
|
||||
title: "Open issue",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
createdByUserId: ownerId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
return {
|
||||
companyId,
|
||||
ownerId,
|
||||
memberId,
|
||||
sharedUserId,
|
||||
assignedAgent: assignedAgent!,
|
||||
sharedAgent: sharedAgent!,
|
||||
privateIssueId,
|
||||
privateChild,
|
||||
openIssue,
|
||||
};
|
||||
}
|
||||
|
||||
it("auto-grants private-root access on assignment and keeps it sticky until revoke", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
|
||||
|
||||
const assigned = await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id });
|
||||
expect(assigned.status, JSON.stringify(assigned.body)).toBe(200);
|
||||
|
||||
await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id })
|
||||
.expect(200);
|
||||
await request(ownerApp)
|
||||
.patch(`/api/issues/${fixture.privateChild.id}`)
|
||||
.send({ assigneeAgentId: null })
|
||||
.expect(200);
|
||||
const unassignedIssue = await db.select().from(issues).then((rows) => rows.find((row) => row.id === fixture.privateChild.id));
|
||||
expect(unassignedIssue?.assigneeAgentId).toBeNull();
|
||||
|
||||
const grants = await db.select().from(issueAccessGrants);
|
||||
expect(grants).toHaveLength(1);
|
||||
expect(grants[0]).toMatchObject({
|
||||
issueId: fixture.privateIssueId,
|
||||
subjectType: "agent",
|
||||
subjectId: fixture.assignedAgent.id,
|
||||
source: "assignment",
|
||||
grantedByUserId: fixture.ownerId,
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
const readableBeforeRevoke = await request(await createApp(
|
||||
db,
|
||||
agentActor(fixture.companyId, fixture.assignedAgent.id),
|
||||
)).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(readableBeforeRevoke.status).toBe(200);
|
||||
|
||||
await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateChild.id}/access-grants/${grants[0]!.id}/revoke`)
|
||||
.send({})
|
||||
.expect(200);
|
||||
const deniedAfterRevoke = await request(await createApp(
|
||||
db,
|
||||
agentActor(fixture.companyId, fixture.assignedAgent.id),
|
||||
)).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(deniedAfterRevoke.status).toBe(404);
|
||||
expect(deniedAfterRevoke.body.error).toBe("Issue not found");
|
||||
}, 20_000);
|
||||
|
||||
it("creates and enriches explicit grants idempotently while rejecting unauthorized and open shares", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const ownerApp = await createApp(db, boardActor(fixture.companyId, fixture.ownerId));
|
||||
const nonreaderApp = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
|
||||
await request(nonreaderApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id })
|
||||
.expect(404);
|
||||
await request(nonreaderApp)
|
||||
.patch(`/api/issues/${fixture.privateIssueId}`)
|
||||
.send({ assigneeAgentId: fixture.assignedAgent.id })
|
||||
.expect(404);
|
||||
const openShare = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.openIssue.id}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(openShare.status).toBe(409);
|
||||
expect(openShare.body.error).toContain("Open issues");
|
||||
|
||||
const first = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(first.status, JSON.stringify(first.body)).toBe(201);
|
||||
expect(first.body).toMatchObject({
|
||||
issueId: fixture.privateIssueId,
|
||||
source: "explicit",
|
||||
subjectDisplayName: "Private Helper",
|
||||
subjectInitials: "PH",
|
||||
subjectAvatarUrl: null,
|
||||
agentVisibility: "private",
|
||||
});
|
||||
const duplicate = await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "agent", subjectId: fixture.sharedAgent.id });
|
||||
expect(duplicate.status).toBe(200);
|
||||
expect(duplicate.body.id).toBe(first.body.id);
|
||||
|
||||
await request(ownerApp)
|
||||
.post(`/api/issues/${fixture.privateIssueId}/access-grants`)
|
||||
.send({ subjectType: "user", subjectId: fixture.sharedUserId })
|
||||
.expect(201);
|
||||
const listed = await request(ownerApp).get(`/api/issues/${fixture.privateIssueId}/access-grants`);
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subjectId: fixture.sharedUserId,
|
||||
subjectDisplayName: "Uma User",
|
||||
subjectAvatarUrl: "https://example.test/uma.png",
|
||||
subjectInitials: "UU",
|
||||
agentVisibility: null,
|
||||
}),
|
||||
]));
|
||||
expect(await db.select().from(issueAccessGrants)).toHaveLength(2);
|
||||
const createdActivities = await db.select().from(activityLog);
|
||||
expect(createdActivities.filter((row) => row.action === "issue_access_grant.created")).toHaveLength(2);
|
||||
}, 20_000);
|
||||
|
||||
it("returns exact locked stubs only through visible blocker and mention edges", async () => {
|
||||
const fixture = await seedFixture();
|
||||
const visibleIssue = await db.insert(issues).values({
|
||||
companyId: fixture.companyId,
|
||||
identifier: "PRV-3",
|
||||
title: "Visible coordination issue",
|
||||
description: "Depends on PRV-1",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
createdByUserId: fixture.memberId,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(issueRelations).values({
|
||||
companyId: fixture.companyId,
|
||||
issueId: fixture.privateIssueId,
|
||||
relatedIssueId: visibleIssue.id,
|
||||
type: "blocks",
|
||||
});
|
||||
await db.insert(issueReferenceMentions).values({
|
||||
companyId: fixture.companyId,
|
||||
sourceIssueId: visibleIssue.id,
|
||||
targetIssueId: fixture.privateIssueId,
|
||||
sourceKind: "description",
|
||||
sourceRecordId: null,
|
||||
documentKey: null,
|
||||
matchedText: "PRV-1",
|
||||
});
|
||||
|
||||
const app = await createApp(db, boardActor(fixture.companyId, fixture.memberId));
|
||||
const detail = await request(app).get(`/api/issues/${visibleIssue.id}`);
|
||||
expect(detail.status, JSON.stringify(detail.body)).toBe(200);
|
||||
const expectedStub = { id: fixture.privateIssueId, identifier: "PRV-1", locked: true };
|
||||
expect(detail.body.blockedBy).toEqual([expectedStub]);
|
||||
expect(detail.body.relatedWork.outbound[0].issue).toEqual(expectedStub);
|
||||
expect(JSON.stringify(detail.body)).not.toContain("Confidential root");
|
||||
|
||||
const direct = await request(app).get(`/api/issues/${fixture.privateIssueId}`);
|
||||
expect(direct.status).toBe(404);
|
||||
|
||||
const list = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/issues?includeBlockedBy=true`);
|
||||
expect(list.status, JSON.stringify(list.body)).toBe(200);
|
||||
expect(list.body.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
|
||||
const visibleRow = list.body.find((row: { id: string }) => row.id === visibleIssue.id);
|
||||
expect(visibleRow.blockedBy).toEqual([expectedStub]);
|
||||
|
||||
const search = await request(app)
|
||||
.get(`/api/companies/${fixture.companyId}/search?q=Confidential%20root&scope=issues`);
|
||||
expect(search.status).toBe(200);
|
||||
expect(search.body.results.map((row: { id: string }) => row.id)).not.toContain(fixture.privateIssueId);
|
||||
}, 20_000);
|
||||
});
|
||||
+344
-22
@@ -2,12 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import { z } from "zod";
|
||||
import { and, asc, desc, eq, inArray, isNull, notInArray, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, notInArray, sql, type SQL } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
approvals,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
@@ -90,6 +91,7 @@ import {
|
||||
type IssueWakeDiagnosticWakeRequest,
|
||||
type IssueWakeDiagnosticsResponse,
|
||||
type IssueRelationIssueSummary,
|
||||
type IssueLockedStub,
|
||||
type IssueCommentPresentation,
|
||||
type IssueWatchdogDiscoveryKind,
|
||||
type ProjectWorkspace,
|
||||
@@ -170,7 +172,11 @@ import {
|
||||
ISSUE_WAKE_DIAGNOSTICS_MAX_WAKE_REQUESTS,
|
||||
readAcceptedPlanConfirmationTarget,
|
||||
} from "../services/issues.js";
|
||||
import { authorizationDeniedDetails, issueReadSqlCondition } from "../services/authorization.js";
|
||||
import {
|
||||
authorizationDeniedDetails,
|
||||
invalidateIssuePrivacyGrantCache,
|
||||
issueReadSqlCondition,
|
||||
} from "../services/authorization.js";
|
||||
import { environmentService } from "../services/environments.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
@@ -223,6 +229,10 @@ const externalObjectSummariesSchema = z.object({
|
||||
issueIds: z.array(z.string().uuid()).max(1000),
|
||||
}).strict();
|
||||
const revokeIssueAccessGrantSchema = z.object({}).strict().default({});
|
||||
const createIssueAccessGrantSchema = z.object({
|
||||
subjectType: z.enum(["user", "agent"]),
|
||||
subjectId: z.string().trim().min(1),
|
||||
}).strict();
|
||||
|
||||
const promoteLowTrustOutputSchema = z.object({
|
||||
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
|
||||
@@ -3541,6 +3551,243 @@ export function issueRoutes(
|
||||
return privacyRoot;
|
||||
}
|
||||
|
||||
function subjectInitials(displayName: string | null) {
|
||||
if (!displayName) return null;
|
||||
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return parts.slice(0, 2).map((part) => part[0]!.toUpperCase()).join("");
|
||||
}
|
||||
|
||||
function issueGrantAgentVisibility(permissions: unknown): "discoverable" | "private" | null {
|
||||
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null;
|
||||
const authorizationPolicy = (permissions as Record<string, unknown>).authorizationPolicy;
|
||||
if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) return null;
|
||||
const agentVisibility = (authorizationPolicy as Record<string, unknown>).agentVisibility;
|
||||
if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null;
|
||||
const mode = (agentVisibility as Record<string, unknown>).mode;
|
||||
return mode === "private" || mode === "discoverable" ? mode : null;
|
||||
}
|
||||
|
||||
async function ensureActiveIssueAccessGrant(
|
||||
dbOrTx: any,
|
||||
input: {
|
||||
issueId: string;
|
||||
subjectType: "user" | "agent";
|
||||
subjectId: string;
|
||||
source: "explicit" | "assignment";
|
||||
grantedByUserId: string | null;
|
||||
grantedByAgentId: string | null;
|
||||
},
|
||||
) {
|
||||
// No partial unique constraint is required for rollout compatibility. The
|
||||
// transaction-scoped lock serializes the active-row check for this tuple,
|
||||
// including the first insert where SELECT FOR UPDATE cannot lock a row.
|
||||
const lockKey = `issue-access-grant:${input.issueId}:${input.subjectType}:${input.subjectId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
const active = await dbOrTx
|
||||
.select()
|
||||
.from(issueAccessGrants)
|
||||
.where(and(
|
||||
eq(issueAccessGrants.issueId, input.issueId),
|
||||
eq(issueAccessGrants.subjectType, input.subjectType),
|
||||
eq(issueAccessGrants.subjectId, input.subjectId),
|
||||
isNull(issueAccessGrants.revokedAt),
|
||||
))
|
||||
.orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id))
|
||||
.limit(1)
|
||||
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0] ?? null);
|
||||
if (active) return { grant: active, created: false as const };
|
||||
|
||||
const grant = await dbOrTx
|
||||
.insert(issueAccessGrants)
|
||||
.values(input)
|
||||
.returning()
|
||||
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0]!);
|
||||
invalidateIssuePrivacyGrantCache({
|
||||
subjectType: input.subjectType,
|
||||
subjectId: input.subjectId,
|
||||
issueId: input.issueId,
|
||||
});
|
||||
return { grant, created: true as const };
|
||||
}
|
||||
|
||||
async function enrichIssueAccessGrants(
|
||||
companyId: string,
|
||||
grants: Array<typeof issueAccessGrants.$inferSelect>,
|
||||
) {
|
||||
const agentIds = [...new Set(grants.filter((grant) => grant.subjectType === "agent").map((grant) => grant.subjectId))];
|
||||
const userIds = [...new Set(grants.filter((grant) => grant.subjectType === "user").map((grant) => grant.subjectId))];
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({
|
||||
id: agents.id,
|
||||
name: agents.name,
|
||||
permissions: agents.permissions,
|
||||
}).from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))),
|
||||
userIds.length === 0
|
||||
? Promise.resolve([])
|
||||
: db.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds)),
|
||||
]);
|
||||
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
const usersById = new Map(userRows.map((user) => [user.id, user]));
|
||||
|
||||
return grants.map((grant) => {
|
||||
const agent = grant.subjectType === "agent" ? agentsById.get(grant.subjectId) : null;
|
||||
const user = grant.subjectType === "user" ? usersById.get(grant.subjectId) : null;
|
||||
const subjectDisplayName = agent?.name ?? user?.name ?? null;
|
||||
return {
|
||||
...grant,
|
||||
subjectDisplayName,
|
||||
subjectAvatarUrl: user?.image ?? null,
|
||||
subjectInitials: subjectInitials(subjectDisplayName),
|
||||
agentVisibility: agent ? issueGrantAgentVisibility(agent.permissions) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function assertIssueGrantSubjectExists(
|
||||
companyId: string,
|
||||
subjectType: "user" | "agent",
|
||||
subjectId: string,
|
||||
) {
|
||||
if (subjectType === "agent") {
|
||||
const agent = await db
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), eq(agents.id, subjectId)))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!agent) throw unprocessable("Grant subject must be an agent in the issue company");
|
||||
return;
|
||||
}
|
||||
const member = await db
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, subjectId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!member) throw unprocessable("Grant subject must be an active user in the issue company");
|
||||
}
|
||||
|
||||
type IssueEdgeReference = IssueRelationIssueSummary | IssueLockedStub;
|
||||
|
||||
async function readableIssueEdgeIds(req: Request, companyId: string, ids: string[]) {
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
if (uniqueIds.length === 0) return new Set<string>();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issueRows.id,
|
||||
companyId: issueRows.companyId,
|
||||
projectId: issueRows.projectId,
|
||||
parentId: issueRows.parentId,
|
||||
assigneeAgentId: issueRows.assigneeAgentId,
|
||||
assigneeUserId: issueRows.assigneeUserId,
|
||||
status: issueRows.status,
|
||||
})
|
||||
.from(issueRows)
|
||||
.where(and(eq(issueRows.companyId, companyId), inArray(issueRows.id, uniqueIds)));
|
||||
const decisions = await Promise.all(rows.map(async (row) => ({
|
||||
id: row.id,
|
||||
allowed: (await decideIssueAccess(req, row, "issue:read")).allowed,
|
||||
})));
|
||||
return new Set(decisions.filter((decision) => decision.allowed).map((decision) => decision.id));
|
||||
}
|
||||
|
||||
function lockedIssueStub(issue: Pick<IssueRelationIssueSummary, "id" | "identifier">): IssueLockedStub {
|
||||
return { id: issue.id, identifier: issue.identifier, locked: true };
|
||||
}
|
||||
|
||||
async function redactIssueRelationEdges(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
||||
): Promise<{ blockedBy: IssueEdgeReference[]; blocks: IssueEdgeReference[] }> {
|
||||
const all = [...relations.blockedBy, ...relations.blocks];
|
||||
const edgeIds = new Set<string>();
|
||||
const pending = [...all];
|
||||
while (pending.length > 0) {
|
||||
const issue = pending.pop()!;
|
||||
if (edgeIds.has(issue.id)) continue;
|
||||
edgeIds.add(issue.id);
|
||||
pending.push(...(issue.terminalBlockers ?? []));
|
||||
}
|
||||
const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]);
|
||||
const redact = (issue: IssueRelationIssueSummary, ancestors = new Set<string>()): IssueEdgeReference => {
|
||||
if (!readableIds.has(issue.id)) return lockedIssueStub(issue);
|
||||
const { terminalBlockers, ...visibleFields } = issue;
|
||||
if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary;
|
||||
const nextAncestors = new Set(ancestors).add(issue.id);
|
||||
return {
|
||||
...visibleFields,
|
||||
...(terminalBlockers
|
||||
? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
return {
|
||||
blockedBy: relations.blockedBy.map((issue) => redact(issue)),
|
||||
blocks: relations.blocks.map((issue) => redact(issue)),
|
||||
};
|
||||
}
|
||||
|
||||
async function redactIssueReferenceEdges<T extends {
|
||||
outbound: Array<{ issue: IssueRelationIssueSummary }>;
|
||||
inbound: Array<{ issue: IssueRelationIssueSummary }>;
|
||||
}>(req: Request, companyId: string, summary: T) {
|
||||
const items = [...summary.outbound, ...summary.inbound];
|
||||
const readableIds = await readableIssueEdgeIds(req, companyId, items.map((item) => item.issue.id));
|
||||
const redactItems = <I extends { issue: IssueRelationIssueSummary }>(rows: I[]) => rows.map((item) => ({
|
||||
...item,
|
||||
issue: readableIds.has(item.issue.id) ? item.issue : lockedIssueStub(item.issue),
|
||||
}));
|
||||
return {
|
||||
outbound: redactItems(summary.outbound),
|
||||
inbound: redactItems(summary.inbound),
|
||||
};
|
||||
}
|
||||
|
||||
async function redactIssueRelationEdgesOnRows<T extends {
|
||||
id: string;
|
||||
blockedBy?: IssueRelationIssueSummary[];
|
||||
blocks?: IssueRelationIssueSummary[];
|
||||
}>(req: Request, companyId: string, rows: T[]): Promise<T[]> {
|
||||
const related = rows.flatMap((row) => [...(row.blockedBy ?? []), ...(row.blocks ?? [])]);
|
||||
const edgeIds = new Set<string>();
|
||||
const pending = [...related];
|
||||
while (pending.length > 0) {
|
||||
const issue = pending.pop()!;
|
||||
if (edgeIds.has(issue.id)) continue;
|
||||
edgeIds.add(issue.id);
|
||||
pending.push(...(issue.terminalBlockers ?? []));
|
||||
}
|
||||
const readableIds = await readableIssueEdgeIds(req, companyId, [...edgeIds]);
|
||||
const redact = (issue: IssueRelationIssueSummary, ancestors = new Set<string>()): IssueEdgeReference => {
|
||||
if (!readableIds.has(issue.id)) return lockedIssueStub(issue);
|
||||
const { terminalBlockers, ...visibleFields } = issue;
|
||||
if (ancestors.has(issue.id)) return visibleFields as IssueRelationIssueSummary;
|
||||
const nextAncestors = new Set(ancestors).add(issue.id);
|
||||
return {
|
||||
...visibleFields,
|
||||
...(terminalBlockers
|
||||
? { terminalBlockers: terminalBlockers.map((child) => redact(child, nextAncestors)) as IssueRelationIssueSummary[] }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
...(row.blockedBy ? { blockedBy: row.blockedBy.map((issue) => redact(issue)) } : {}),
|
||||
...(row.blocks ? { blocks: row.blocks.map((issue) => redact(issue)) } : {}),
|
||||
})) as T[];
|
||||
}
|
||||
|
||||
async function assertAgentIssueCommentAllowed(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -5006,11 +5253,15 @@ export function issueRoutes(
|
||||
sortField: sortField === "updated" ? "updated" : undefined,
|
||||
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
|
||||
};
|
||||
// readCondition is a Drizzle SQL object with cyclic internal references;
|
||||
// actor identity already scopes the coordinator key, so only serializable
|
||||
// request filters belong in its normalized query.
|
||||
const { readCondition: _readCondition, ...requestKeyFilters } = listFilters;
|
||||
const requestKey = issueListRequestKey({
|
||||
req,
|
||||
companyId,
|
||||
normalizedQuery: {
|
||||
...listFilters,
|
||||
...requestKeyFilters,
|
||||
view: compactView ? "compact" : undefined,
|
||||
},
|
||||
});
|
||||
@@ -5022,9 +5273,10 @@ export function issueRoutes(
|
||||
diagnostics: opts.issueListDiagnostics,
|
||||
compute: async () => {
|
||||
const rawResult = await svc.list(companyId, listFilters);
|
||||
const result = await actorCanReadCompanyScope(req, companyId)
|
||||
const visibleRows = await actorCanReadCompanyScope(req, companyId)
|
||||
? rawResult
|
||||
: await filterIssuesForActor(req, rawResult);
|
||||
const result = await redactIssueRelationEdgesOnRows(req, companyId, visibleRows);
|
||||
const issueIds = result.map((issue) => issue.id);
|
||||
if (compactView) {
|
||||
const [handoffStates, recoveryActionByIssue] = await Promise.all([
|
||||
@@ -5336,6 +5588,7 @@ export function issueRoutes(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const visibleRelations = await redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions);
|
||||
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
@@ -5377,8 +5630,8 @@ export function issueRoutes(
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
blockedBy: visibleRelations.blockedBy,
|
||||
blocks: visibleRelations.blocks,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
originKind: issue.originKind,
|
||||
@@ -5607,6 +5860,10 @@ export function issueRoutes(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const [visibleRelations, visibleReferenceSummary] = await Promise.all([
|
||||
redactIssueRelationEdges(req, issue.companyId, relationsWithRecoveryActions),
|
||||
redactIssueReferenceEdges(req, issue.companyId, referenceSummary),
|
||||
]);
|
||||
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
@@ -5630,10 +5887,10 @@ export function issueRoutes(
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
blockedBy: visibleRelations.blockedBy,
|
||||
blocks: visibleRelations.blocks,
|
||||
relatedWork: visibleReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
...documentPayload,
|
||||
project: compactIssueProject(project),
|
||||
goal: goal ?? null,
|
||||
@@ -7307,12 +7564,13 @@ export function issueRoutes(
|
||||
});
|
||||
if (deduplicationReason) {
|
||||
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
||||
const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary);
|
||||
res.status(200).json({
|
||||
...issue,
|
||||
deduplicated: true,
|
||||
deduplicationReason,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
relatedWork: visibleReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -7414,10 +7672,11 @@ export function issueRoutes(
|
||||
});
|
||||
await queueTaskWatchdogEvaluation(issue, actor.runId);
|
||||
|
||||
const visibleReferenceSummary = await redactIssueReferenceEdges(req, companyId, referenceSummary);
|
||||
res.status(201).json({
|
||||
...issue,
|
||||
relatedWork: referenceSummary,
|
||||
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
relatedWork: visibleReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleReferenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7861,9 +8120,57 @@ export function issueRoutes(
|
||||
.from(issueAccessGrants)
|
||||
.where(inArray(issueAccessGrants.issueId, grantIssueIds))
|
||||
.orderBy(desc(issueAccessGrants.createdAt), desc(issueAccessGrants.id));
|
||||
res.json(grants);
|
||||
res.json(await enrichIssueAccessGrants(issue.companyId, grants));
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/issues/:id/access-grants",
|
||||
validate(createIssueAccessGrantSchema),
|
||||
async (req, res) => {
|
||||
const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found");
|
||||
if (!issue || !(await assertIssueReadAllowed(req, res, issue))) return;
|
||||
const privacyRoot = await resolveIssuePrivacyManagementRoot(req, res, issue);
|
||||
if (!privacyRoot) return;
|
||||
if (privacyRoot.privacyRootIssueId === null) {
|
||||
res.status(409).json({ error: "Open issues do not require access grants" });
|
||||
return;
|
||||
}
|
||||
const subjectType = req.body.subjectType as "user" | "agent";
|
||||
const subjectId = req.body.subjectId as string;
|
||||
await assertIssueGrantSubjectExists(issue.companyId, subjectType, subjectId);
|
||||
const actor = getActorInfo(req);
|
||||
const result = await db.transaction((tx) => ensureActiveIssueAccessGrant(tx, {
|
||||
issueId: privacyRoot.id,
|
||||
subjectType,
|
||||
subjectId,
|
||||
source: "explicit",
|
||||
grantedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
grantedByAgentId: actor.agentId,
|
||||
}));
|
||||
if (result.created) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "issue_access_grant.created",
|
||||
entityType: "issue_access_grant",
|
||||
entityId: result.grant.id,
|
||||
details: {
|
||||
issueId: result.grant.issueId,
|
||||
subjectType: result.grant.subjectType,
|
||||
subjectId: result.grant.subjectId,
|
||||
source: result.grant.source,
|
||||
},
|
||||
});
|
||||
}
|
||||
const [enriched] = await enrichIssueAccessGrants(issue.companyId, [result.grant]);
|
||||
res.status(result.created ? 201 : 200).json(enriched);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/issues/:id/access-grants/:grantId/revoke",
|
||||
validate(revokeIssueAccessGrantSchema),
|
||||
@@ -7886,6 +8193,11 @@ export function issueRoutes(
|
||||
res.status(404).json({ error: "Issue access grant not found" });
|
||||
return;
|
||||
}
|
||||
invalidateIssuePrivacyGrantCache({
|
||||
subjectType: grant.subjectType as "user" | "agent",
|
||||
subjectId: grant.subjectId,
|
||||
issueId: grant.issueId,
|
||||
});
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
@@ -7907,6 +8219,9 @@ export function issueRoutes(
|
||||
const id = req.params.id as string;
|
||||
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
|
||||
if (!existing) return;
|
||||
const assigneeMutationRequested =
|
||||
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
|
||||
if (assigneeMutationRequested && !(await assertIssueReadAllowed(req, res, existing))) return;
|
||||
if (req.body.visibility !== undefined && !(await resolveIssuePrivacyManagementRoot(req, res, existing))) return;
|
||||
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
||||
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
||||
@@ -8447,18 +8762,19 @@ export function issueRoutes(
|
||||
blockedBy?: unknown;
|
||||
blocks?: unknown;
|
||||
activeRecoveryAction?: unknown;
|
||||
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
|
||||
relatedWork?: unknown;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
} = issue;
|
||||
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
||||
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||
updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||
const visibleUpdatedRelations = await redactIssueRelationEdges(req, issue.companyId, updatedRelations);
|
||||
issueResponse = {
|
||||
...issue,
|
||||
blockedByIssueIds:
|
||||
issue.blockedByIssueIds ?? [...new Set(req.body.blockedByIssueIds as string[])].sort(),
|
||||
blockedBy: updatedRelations.blockedBy,
|
||||
blocks: updatedRelations.blocks,
|
||||
blockedBy: visibleUpdatedRelations.blockedBy,
|
||||
blocks: visibleUpdatedRelations.blocks,
|
||||
};
|
||||
}
|
||||
await routinesSvc.syncRunStatusForIssue(issue.id);
|
||||
@@ -8788,10 +9104,15 @@ export function issueRoutes(
|
||||
commentReferenceSummaryBefore,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
const visibleCommentReferenceSummary = await redactIssueReferenceEdges(
|
||||
req,
|
||||
issue.companyId,
|
||||
commentReferenceSummaryAfter,
|
||||
);
|
||||
issueResponse = {
|
||||
...issueResponse,
|
||||
relatedWork: commentReferenceSummaryAfter,
|
||||
referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map(
|
||||
relatedWork: visibleCommentReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleCommentReferenceSummary.outbound.map(
|
||||
(item) => item.issue.identifier ?? item.issue.id,
|
||||
),
|
||||
};
|
||||
@@ -8846,10 +9167,11 @@ export function issueRoutes(
|
||||
});
|
||||
|
||||
} else if (updateReferenceSummaryAfter) {
|
||||
const visibleUpdateReferenceSummary = await redactIssueReferenceEdges(req, issue.companyId, updateReferenceSummaryAfter);
|
||||
issueResponse = {
|
||||
...issueResponse,
|
||||
relatedWork: updateReferenceSummaryAfter,
|
||||
referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map(
|
||||
relatedWork: visibleUpdateReferenceSummary,
|
||||
referencedIssueIdentifiers: visibleUpdateReferenceSummary.outbound.map(
|
||||
(item) => item.issue.identifier ?? item.issue.id,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1159,6 +1159,28 @@ registry.registerPath({
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/issues/{id}/access-grants",
|
||||
tags: ["issues"],
|
||||
summary: "Grant a user or agent access to a private issue subtree",
|
||||
request: {
|
||||
params: z.object({ id: z.string() }),
|
||||
body: jsonBody(z.object({
|
||||
subjectType: z.enum(["user", "agent"]),
|
||||
subjectId: z.string().min(1),
|
||||
}).strict()),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
201: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/openapi.json",
|
||||
|
||||
@@ -503,6 +503,19 @@ function setIssuePrivacyGrantCacheEntry(
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateIssuePrivacyGrantCache(input: {
|
||||
subjectType: "user" | "agent";
|
||||
subjectId: string;
|
||||
issueId: string;
|
||||
}) {
|
||||
const prefix = `${input.subjectType}:${input.subjectId}:`;
|
||||
for (const key of issuePrivacyGrantCache.keys()) {
|
||||
if (key.startsWith(prefix) && key.split(":").includes(input.issueId)) {
|
||||
issuePrivacyGrantCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function issuePrivacyMode(): "off" | "shadow" | "enforce" {
|
||||
const mode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE?.trim().toLowerCase();
|
||||
if (mode === "off" || mode === "enforce") return mode;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
routineRuns,
|
||||
executionWorkspaces,
|
||||
issueApprovals,
|
||||
issueAccessGrants,
|
||||
issueAttachments,
|
||||
issueCreateIdempotencyKeys,
|
||||
issueInboxArchives,
|
||||
@@ -116,6 +117,7 @@ import { visibleIssueCondition } from "./issue-visibility.js";
|
||||
import { finalizeStatusCardsForStalledGeneration } from "./status-card-finalization.js";
|
||||
import { finalizeSummarySlotsForTerminalIssue } from "./summary-slot-finalization.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { invalidateIssuePrivacyGrantCache } from "./authorization.js";
|
||||
import { buildIssueChanges } from "./issue-change-receipt.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
@@ -2014,6 +2016,75 @@ function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationI
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureAssignmentIssueAccessGrant(
|
||||
dbOrTx: any,
|
||||
issue: typeof issues.$inferSelect,
|
||||
previous: Pick<typeof issues.$inferSelect, "assigneeAgentId" | "assigneeUserId"> | null,
|
||||
actor: { agentId?: string | null; userId?: string | null },
|
||||
) {
|
||||
if (issue.visibility !== "private") return null;
|
||||
const subject = issue.assigneeAgentId
|
||||
? { subjectType: "agent" as const, subjectId: issue.assigneeAgentId }
|
||||
: issue.assigneeUserId
|
||||
? { subjectType: "user" as const, subjectId: issue.assigneeUserId }
|
||||
: null;
|
||||
if (!subject) return null;
|
||||
if (
|
||||
previous
|
||||
&& previous.assigneeAgentId === issue.assigneeAgentId
|
||||
&& previous.assigneeUserId === issue.assigneeUserId
|
||||
) return null;
|
||||
|
||||
const grantIssueId = issue.privacyRootIssueId ?? issue.id;
|
||||
const lockKey = `issue-access-grant:${grantIssueId}:${subject.subjectType}:${subject.subjectId}`;
|
||||
await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
const active = await dbOrTx
|
||||
.select({ id: issueAccessGrants.id })
|
||||
.from(issueAccessGrants)
|
||||
.where(and(
|
||||
eq(issueAccessGrants.issueId, grantIssueId),
|
||||
eq(issueAccessGrants.subjectType, subject.subjectType),
|
||||
eq(issueAccessGrants.subjectId, subject.subjectId),
|
||||
isNull(issueAccessGrants.revokedAt),
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows: Array<{ id: string }>) => rows[0] ?? null);
|
||||
if (active) return null;
|
||||
|
||||
const grant = await dbOrTx
|
||||
.insert(issueAccessGrants)
|
||||
.values({
|
||||
issueId: grantIssueId,
|
||||
...subject,
|
||||
source: "assignment",
|
||||
grantedByUserId: actor.userId ?? null,
|
||||
grantedByAgentId: actor.agentId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows: Array<typeof issueAccessGrants.$inferSelect>) => rows[0]!);
|
||||
invalidateIssuePrivacyGrantCache({
|
||||
subjectType: subject.subjectType,
|
||||
subjectId: subject.subjectId,
|
||||
issueId: grantIssueId,
|
||||
});
|
||||
await logActivity(dbOrTx as Db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.agentId ? "agent" : actor.userId ? "user" : "system",
|
||||
actorId: actor.agentId ?? actor.userId ?? "issue_service",
|
||||
agentId: actor.agentId ?? null,
|
||||
action: "issue_access_grant.created",
|
||||
entityType: "issue_access_grant",
|
||||
entityId: grant.id,
|
||||
details: {
|
||||
issueId: grant.issueId,
|
||||
subjectType: grant.subjectType,
|
||||
subjectId: grant.subjectId,
|
||||
source: grant.source,
|
||||
},
|
||||
});
|
||||
return grant;
|
||||
}
|
||||
|
||||
async function terminalExplicitBlockersByRoot(
|
||||
companyId: string,
|
||||
roots: IssueRelationIssueSummary[],
|
||||
@@ -6648,6 +6719,10 @@ export function issueService(db: Db) {
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
await ensureAssignmentIssueAccessGrant(tx, issue, null, {
|
||||
agentId: issueData.createdByAgentId ?? null,
|
||||
userId: issueData.createdByUserId ?? null,
|
||||
});
|
||||
if (idempotencyKey) {
|
||||
await tx.insert(issueCreateIdempotencyKeys).values({
|
||||
companyId,
|
||||
@@ -7142,6 +7217,10 @@ export function issueService(db: Db) {
|
||||
.returning()
|
||||
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
await ensureAssignmentIssueAccessGrant(tx, updated, receiptExisting, {
|
||||
agentId: actorAgentId ?? null,
|
||||
userId: actorUserId ?? null,
|
||||
});
|
||||
if (existing.status !== updated.status) {
|
||||
if (updated.status === "done" || updated.status === "cancelled") {
|
||||
await finalizeSummarySlotsForTerminalIssue(tx, updated);
|
||||
|
||||
Reference in new issue
Block a user