feat: add private issue sharing grants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent d18b769fe4
commit d92ef1e4b4
9 files changed
+829 -22

No files matched your search

+13
View File
@@ -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;
+79
View File
@@ -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);