mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +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
@@ -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