mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
refactor(tools): isolate connection access and removal (#17167)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connection grants and installations control which identities and agents can reach tools. > - Removal must close those access paths and stop runtimes before cleaning up owned secrets. > - These responsibilities still shared the setup coordinator after earlier extractions. > - This pull request gives grants, installations, credential validation and removal explicit owners. > - The benefit is a smaller coordinator and focused access-lifecycle modules with existing security ordering intact. ## Linked Issues or Issue Description Depends on #17165. Earlier stack: #17144 → #17146 → #17147 → #17155 → #17157 → #17159 → #17161 → #17162 → #17165 → this PR. **What existing behavior does this improve?** Maintenance of connection audiences, delegation, installation and removal. **Current behavior** The 6,419-line service still owns grant mutation, installation reach and resumable credential cleanup alongside app setup. **Proposed behavior** A grant owner manages audiences and delegation. An installation owner manages reach and its profile bindings. A removal owner keeps access revocation, runtime shutdown, secret classification and cleanup together. The coordinator delegates through typed APIs. **Reason and benefit** The entry point falls to 4,821 lines / 198,065 bytes, meeting the requested under-5,000-line target. Across the full stack it falls from 21,525 lines / 829,519 bytes. App setup remains together in the coordinator. ## What Changed - Extract credential validation (75 lines), grants (603), installations (350) and removal (658). - Preserve company boundaries, audience advisory locks, membership locks, assignment locks and audit order. - Keep the existing runtime supervisor and secret client. Retain live HTTP options for provider shutdown. - Keep Slack credential-mutation leasing and resumable owned-secret cleanup with removal. ## Verification - Removal, grant-resolution and the stronger real HTTP OAuth fixture pass before extraction (13 cases); the existing service and publication baselines also pass. - The original combined targeted run passed 525 cases across seven suites. After replaying onto master, 601 cases pass across nine suites, including Braintrust lifecycle and connector-policy coverage, with one database worker. - Full repository typecheck and build passed before merge integration. After integration, server typecheck, module-boundary and whitespace gates pass; fresh CI covers the full repository gates. - All 94 pre-existing function/method bodies match by normalized syntax comparison. A full-stack TypeScript symbol audit preserves all 18 exports and 89 service methods. - The earlier local full-suite run was interrupted after 79 minutes and remains unverified. All current-head CI gates pass on `d5307fcea2365e51a75d49db82deeed236d19951`, including the full sharded test matrix, browser suites, typecheck, build and canary dry run. Greptile is 5/5 with no open review threads after merge integration. - GitHub canceled duplicate CI run [39716841632](https://github.com/paperclipai/paperclip/actions/runs/39716841632) during the atomic stack update. Replacement run [39716843243](https://github.com/paperclipai/paperclip/actions/runs/39716843243) passed on the same head; no failing test was retried. ## Risks - Audience replacement must not erase an inactive member marker and widen authority. Its advisory and membership locks remain in the grant owner. - Uninstall must remove only its own profile bindings. Existing operator-authored bindings keep their current behavior. - Removal must remain fail-closed and retryable after provider failures. Its order, shared-secret retention, supervisor and cleanup references are unchanged and covered by removal tests. - No schema, public API, policy or scheduling change is intended. The first eight PRs have landed; this PR follows the updated catalog extraction. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f7210cd31f
commit
4dfcb4c9b8
6 files changed
+1722
-1620
No files matched your search
+22
-1620
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets } from "@paperclipai/db";
|
||||
import { CLASS3_STATIC_LEASE_ALLOWLIST } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../../errors.js";
|
||||
|
||||
function assertClass3ToolCredentialRefAllowed(ref: {
|
||||
configPath?: string | null;
|
||||
projectionClass?: string | null;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}) {
|
||||
const projectionClass = ref.projectionClass ?? "unclassified";
|
||||
if (projectionClass !== "class_3_static_lease") return;
|
||||
if (!ref.configPath?.trim() || !ref.projectionAllowlistKey?.trim()) {
|
||||
throw unprocessable(
|
||||
"Class-3 static lease tool credentials require an allowlist key and config path",
|
||||
{
|
||||
code: "class_3_static_lease_allowlist_required",
|
||||
targetType: "tool_connection",
|
||||
configPath: ref.configPath ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some(
|
||||
(entry) =>
|
||||
entry.key === ref.projectionAllowlistKey &&
|
||||
entry.targetType === "tool_connection" &&
|
||||
entry.configPath === ref.configPath,
|
||||
);
|
||||
if (!allowed) {
|
||||
throw unprocessable(
|
||||
"Class-3 static lease tool credential is outside the approved allowlist",
|
||||
{
|
||||
code: "class_3_static_lease_not_allowed",
|
||||
allowlistKey: ref.projectionAllowlistKey,
|
||||
targetType: "tool_connection",
|
||||
configPath: ref.configPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createConnectionCredentialValidation(db: Db) {
|
||||
async function assertSecretRefs(
|
||||
companyId: string,
|
||||
refs: Array<{
|
||||
secretId: string;
|
||||
configPath?: string | null;
|
||||
projectionClass?: string | null;
|
||||
projectionAllowlistKey?: string | null;
|
||||
}>,
|
||||
) {
|
||||
if (refs.length === 0) return;
|
||||
for (const ref of refs) {
|
||||
assertClass3ToolCredentialRefAllowed(ref);
|
||||
}
|
||||
const secretIds = [...new Set(refs.map((ref) => ref.secretId))];
|
||||
for (const secretId of secretIds) {
|
||||
const [secret] = await db
|
||||
.select({ id: companySecrets.id })
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.id, secretId),
|
||||
eq(companySecrets.companyId, companyId),
|
||||
),
|
||||
);
|
||||
if (!secret)
|
||||
throw unprocessable(
|
||||
"Tool connection credential secrets must belong to the same company",
|
||||
);
|
||||
}
|
||||
}
|
||||
return assertSecretRefs;
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { toConnectionGrant } from "./projection.js";
|
||||
import { actorBinding } from "../actor.js";
|
||||
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { oauthConfig } from "../oauth/metadata.js";
|
||||
import { and, asc, desc, eq, inArray, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, connectionGrantMembers, connectionGrantDelegations, connectionGrants, authUsers, companyMemberships, toolAccessAuditEvents, toolConnections } from "@paperclipai/db";
|
||||
import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../errors.js";
|
||||
import { isPaperclipCloudConnectorStrategy } from "../../paperclip-cloud-connector.js";
|
||||
import { vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js";
|
||||
|
||||
import type { createConnectionCredentialValidation } from "./credential-validation.js";
|
||||
type GrantDependencies = {
|
||||
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
|
||||
assertSecretRefs: ReturnType<typeof createConnectionCredentialValidation>;
|
||||
vercelConnect: VercelConnectClient | null;
|
||||
};
|
||||
|
||||
export function createConnectionGrants(db: Db, { getConnectionRow, assertSecretRefs, vercelConnect }: GrantDependencies) {
|
||||
return {
|
||||
listConnectionGrants: async (idOrUid: string, companyId?: string) => {
|
||||
const connection = await getConnectionRow(idOrUid, companyId);
|
||||
const grants = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
desc(connectionGrants.isDefault),
|
||||
desc(connectionGrants.updatedAt),
|
||||
);
|
||||
const grantIds = grants.map((grant) => grant.id);
|
||||
const [members, delegations] =
|
||||
grantIds.length === 0
|
||||
? [[], []]
|
||||
: await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(connectionGrantMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantMembers.companyId, connection.companyId),
|
||||
inArray(connectionGrantMembers.grantId, grantIds),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select()
|
||||
.from(connectionGrantDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
connectionGrantDelegations.companyId,
|
||||
connection.companyId,
|
||||
),
|
||||
inArray(connectionGrantDelegations.grantId, grantIds),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
connection: { id: connection.id, uid: connection.uid },
|
||||
grants: grants.map((grant) => ({
|
||||
...toConnectionGrant(grant),
|
||||
members: members.filter((member) => member.grantId === grant.id),
|
||||
delegations: delegations.filter(
|
||||
(delegation) => delegation.grantId === grant.id,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Company members eligible to appear in an organization grant's audience.
|
||||
* The audience editor needs display names, and the client must not have to
|
||||
* cross-reference a second endpoint to render "12 selected members".
|
||||
*/
|
||||
listConnectionAudienceMembers: async (companyId: string) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: companyMemberships.principalId,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
);
|
||||
return rows
|
||||
.map((row) => ({
|
||||
userId: row.userId,
|
||||
name: row.name ?? null,
|
||||
email: row.email ?? null,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
(a.name ?? a.email ?? a.userId).localeCompare(
|
||||
b.name ?? b.email ?? b.userId,
|
||||
),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Replace an organization grant's audience atomically (PAP-17835).
|
||||
*
|
||||
* An empty `memberUserIds` persists as zero rows, which the resolver already
|
||||
* treats as "every organization member". Replacement is delete-then-insert
|
||||
* inside one transaction so a partially-applied audience can never widen or
|
||||
* narrow access, and every member id is checked against active company
|
||||
* membership first so an audience cannot name an outsider. Existing audience
|
||||
* members are locked too: membership cleanup deliberately retains a sole
|
||||
* inactive audience row, and a concurrent empty replacement must not erase
|
||||
* that fail-closed marker after cleanup wins. Once cleanup has retained that
|
||||
* marker, callers must restore the member or replace it with an active named
|
||||
* audience before widening access to the whole company.
|
||||
*/
|
||||
replaceConnectionGrantMembers: async (
|
||||
idOrUid: string,
|
||||
grantId: string,
|
||||
memberUserIds: string[],
|
||||
actor?: ActorInfo,
|
||||
) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
const [grant] = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, grantId),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!grant) throw notFound("Connection grant not found");
|
||||
if (grant.kind !== "organization") {
|
||||
throw badRequest(
|
||||
"Only an organization identity has an audience; a personal identity belongs to its owner",
|
||||
);
|
||||
}
|
||||
const requested = [
|
||||
...new Set(memberUserIds.map((id) => id.trim()).filter(Boolean)),
|
||||
];
|
||||
const binding = actorBinding(actor);
|
||||
const members = await db.transaction(async (tx) => {
|
||||
// Serialize replacements before taking the current audience snapshot.
|
||||
// Otherwise a second replacement could read the old rows, wait behind
|
||||
// an in-flight replacement, and then miss locking a newly-added member.
|
||||
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${"paperclip.connection_grant_audience:" + grant.id}, 0))`);
|
||||
const existingAudience = await tx
|
||||
.select()
|
||||
.from(connectionGrantMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantMembers.companyId, connection.companyId),
|
||||
eq(connectionGrantMembers.grantId, grant.id),
|
||||
eq(connectionGrantMembers.subjectType, "user"),
|
||||
),
|
||||
);
|
||||
const existingUserIds = [
|
||||
...new Set(existingAudience.map((row) => row.subjectId)),
|
||||
];
|
||||
const membershipUserIds = [
|
||||
...new Set([...existingUserIds, ...requested]),
|
||||
];
|
||||
if (membershipUserIds.length > 0) {
|
||||
// Membership suspension/archive/removal takes the same row lock before
|
||||
// sweeping grant audiences. Lock both the old and new audience so an
|
||||
// empty replacement also serializes with cleanup of its existing sole
|
||||
// member. Whichever transaction wins is then authoritative.
|
||||
const memberships = await tx
|
||||
.select({
|
||||
principalId: companyMemberships.principalId,
|
||||
status: companyMemberships.status,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, connection.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
inArray(companyMemberships.principalId, membershipUserIds),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(companyMemberships.id))
|
||||
.for("update");
|
||||
const active = new Set(
|
||||
memberships
|
||||
.filter((row) => row.status === "active")
|
||||
.map((row) => row.principalId),
|
||||
);
|
||||
const unknown = requested.filter((id) => !active.has(id));
|
||||
if (unknown.length > 0) {
|
||||
throw unprocessable(
|
||||
"Every audience member must be an active member of this company",
|
||||
{
|
||||
code: "audience_member_not_in_company",
|
||||
unknownUserIds: unknown,
|
||||
},
|
||||
);
|
||||
}
|
||||
const inactiveExisting = existingUserIds.filter(
|
||||
(id) => !active.has(id),
|
||||
);
|
||||
if (requested.length === 0 && inactiveExisting.length > 0) {
|
||||
throw conflict(
|
||||
"Replace inactive audience members before widening access to the whole company",
|
||||
{
|
||||
code: "audience_widening_blocked",
|
||||
inactiveUserIds: inactiveExisting,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await tx
|
||||
.select({ id: connectionGrants.id })
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, grant.id),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
const existingUserSet = new Set(existingUserIds);
|
||||
if (requested.length === existingUserSet.size && requested.every(id => existingUserSet.has(id))) return existingAudience;
|
||||
await tx
|
||||
.delete(connectionGrantMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantMembers.companyId, connection.companyId),
|
||||
eq(connectionGrantMembers.grantId, grant.id),
|
||||
),
|
||||
);
|
||||
const inserted =
|
||||
requested.length === 0
|
||||
? []
|
||||
: await tx
|
||||
.insert(connectionGrantMembers)
|
||||
.values(
|
||||
requested.map((subjectId) => ({
|
||||
companyId: connection.companyId,
|
||||
grantId: grant.id,
|
||||
subjectType: "user" as const,
|
||||
subjectId,
|
||||
})),
|
||||
)
|
||||
.returning();
|
||||
await tx
|
||||
.update(connectionGrants)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(connectionGrants.id, grant.id));
|
||||
return inserted;
|
||||
});
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.audience_replaced",
|
||||
outcome: "success",
|
||||
reasonCode: "audience_replaced",
|
||||
details: {
|
||||
grantId: grant.id,
|
||||
memberCount: members.length,
|
||||
memberUserIds: requested,
|
||||
},
|
||||
});
|
||||
return { ...toConnectionGrant(grant), members };
|
||||
},
|
||||
createConnectionGrantDelegation: async (
|
||||
idOrUid: string,
|
||||
grantId: string,
|
||||
agentId: string,
|
||||
ownerUserId: string,
|
||||
) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
if (connection.connectionPurpose === "ai") {
|
||||
throw badRequest("AI credentials use the connection's human access settings, not agent delegation");
|
||||
}
|
||||
return db.transaction(async (tx) => {
|
||||
// Membership removal/suspension takes this same row lock before sweeping
|
||||
// personal grants. Whichever operation wins is therefore authoritative:
|
||||
// removal deletes a delegation committed first, while creation that runs
|
||||
// second observes the inactive membership and fails closed.
|
||||
const [membership] = await tx
|
||||
.select({ id: companyMemberships.id })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, connection.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, ownerUserId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (!membership) {
|
||||
throw forbidden(
|
||||
"Only an active company member can delegate their personal grant",
|
||||
);
|
||||
}
|
||||
|
||||
const [grant] = await tx
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, grantId),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, ownerUserId),
|
||||
eq(connectionGrants.status, "active"),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.limit(1);
|
||||
if (!grant)
|
||||
throw forbidden(
|
||||
"Only the active personal grant owner can create a delegation",
|
||||
);
|
||||
await tx.select({ id: toolConnections.id }).from(toolConnections)
|
||||
.where(eq(toolConnections.id, connection.id)).for("key share");
|
||||
const [targetAgent] = await tx
|
||||
.select({ id: agents.id })
|
||||
.from(agents)
|
||||
.where(
|
||||
and(
|
||||
eq(agents.id, agentId),
|
||||
eq(agents.companyId, connection.companyId),
|
||||
),
|
||||
)
|
||||
.for("no key update")
|
||||
.limit(1);
|
||||
if (!targetAgent) throw notFound("Agent not found");
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(connectionGrantDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantDelegations.grantId, grant.id),
|
||||
eq(connectionGrantDelegations.agentId, agentId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return existing;
|
||||
const [delegation] = await tx
|
||||
.insert(connectionGrantDelegations)
|
||||
.values({
|
||||
companyId: connection.companyId,
|
||||
grantId: grant.id,
|
||||
agentId,
|
||||
createdByUserId: ownerUserId,
|
||||
})
|
||||
.returning();
|
||||
await tx.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: "user",
|
||||
actorId: ownerUserId,
|
||||
action: "connection_grant.delegated",
|
||||
outcome: "success",
|
||||
reasonCode: "delegation_created",
|
||||
details: { grantId: grant.id, delegationId: delegation!.id, agentId },
|
||||
});
|
||||
return delegation!;
|
||||
});
|
||||
},
|
||||
revokeConnectionGrantDelegation: async (
|
||||
idOrUid: string,
|
||||
grantId: string,
|
||||
delegationId: string,
|
||||
actor?: ActorInfo,
|
||||
) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
const delegation = await db.transaction(async tx => {
|
||||
const [grant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
||||
.where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id)))
|
||||
.for("update");
|
||||
if (!grant) return undefined;
|
||||
const [target] = await tx.select({ agentId: connectionGrantDelegations.agentId }).from(connectionGrantDelegations)
|
||||
.where(and(eq(connectionGrantDelegations.id, delegationId), eq(connectionGrantDelegations.companyId, connection.companyId), eq(connectionGrantDelegations.grantId, grantId)));
|
||||
if (!target) return undefined;
|
||||
await tx.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.companyId, connection.companyId), eq(agents.id, target.agentId))).for("no key update");
|
||||
const [removed] = await tx
|
||||
.delete(connectionGrantDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantDelegations.id, delegationId),
|
||||
eq(connectionGrantDelegations.companyId, connection.companyId),
|
||||
eq(connectionGrantDelegations.grantId, grantId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return removed;
|
||||
});
|
||||
if (!delegation) throw notFound("Connection grant delegation not found");
|
||||
const binding = actorBinding(actor);
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.delegation_revoked",
|
||||
outcome: "success",
|
||||
reasonCode: "delegation_revoked",
|
||||
details: { grantId, delegationId, agentId: delegation.agentId },
|
||||
});
|
||||
return delegation;
|
||||
},
|
||||
addConnectionInstallation: async (
|
||||
idOrUid: string,
|
||||
input: {
|
||||
providerTenant?: { name?: string; externalId?: string };
|
||||
credentialSecretRefs?: typeof connectionGrants.$inferInsert.credentialSecretRefs;
|
||||
isDefault?: boolean;
|
||||
},
|
||||
actor?: ActorInfo,
|
||||
) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
await assertSecretRefs(
|
||||
connection.companyId,
|
||||
input.credentialSecretRefs ?? [],
|
||||
);
|
||||
if (input.isDefault) {
|
||||
await db
|
||||
.update(connectionGrants)
|
||||
.set({ isDefault: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.kind, "organization"),
|
||||
),
|
||||
);
|
||||
}
|
||||
const binding = actorBinding(actor);
|
||||
const [grant] = await db
|
||||
.insert(connectionGrants)
|
||||
.values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
providerTenant: input.providerTenant,
|
||||
credentialSecretRefs: input.credentialSecretRefs ?? [],
|
||||
status: "active",
|
||||
isDefault: input.isDefault ?? false,
|
||||
createdByAgentId:
|
||||
binding.actorType === "agent" ? binding.actorId : null,
|
||||
createdByUserId:
|
||||
binding.actorType === "user" ? binding.actorId : null,
|
||||
})
|
||||
.returning();
|
||||
if (!grant) throw new Error("Failed to create connection installation");
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.created",
|
||||
outcome: "success",
|
||||
reasonCode: "grant_created",
|
||||
details: {
|
||||
grantId: grant.id,
|
||||
kind: grant.kind,
|
||||
isDefault: grant.isDefault,
|
||||
},
|
||||
});
|
||||
return toConnectionGrant(grant);
|
||||
},
|
||||
revokeConnectionGrant: async (
|
||||
idOrUid: string,
|
||||
grantId: string,
|
||||
actor?: ActorInfo,
|
||||
) => {
|
||||
const connection = await getConnectionRow(idOrUid);
|
||||
const binding = actorBinding(actor);
|
||||
const [currentGrant] = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, grantId),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!currentGrant) throw notFound("Connection grant not found");
|
||||
let providerRevocation = "not_applicable";
|
||||
if (
|
||||
connection.credentialSource === "vercel_connect" &&
|
||||
connection.externalCredential
|
||||
) {
|
||||
const request = vercelTokenRequest({
|
||||
credential: connection.externalCredential,
|
||||
grant: currentGrant,
|
||||
connectionId: connection.id,
|
||||
companyId: connection.companyId,
|
||||
resources: vercelConnectResourcesFor(connection),
|
||||
});
|
||||
vercelConnect?.evict(request);
|
||||
if (currentGrant.externalCredential?.subjectType === "app") {
|
||||
providerRevocation = "manage_in_vercel";
|
||||
} else if (currentGrant.externalCredential?.subjectType === "user") {
|
||||
try {
|
||||
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
|
||||
await vercelConnect.revoke(request);
|
||||
providerRevocation = "success";
|
||||
} catch {
|
||||
providerRevocation = "failed";
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
isPaperclipCloudConnectorStrategy(oauthConfig(connection).strategy)
|
||||
) {
|
||||
// Google revocation is client-wide for a user. The managed Workspace
|
||||
// profiles intentionally share one Paperclip-owned client, so revoking
|
||||
// one token here could invalidate unrelated Gmail, Drive, and Calendar
|
||||
// grants. A per-profile removal is therefore local-only. A future
|
||||
// provider-level disconnect must warn that it removes every profile.
|
||||
providerRevocation = "local_only_shared_client";
|
||||
}
|
||||
const grant = await db.transaction(async (tx) => {
|
||||
const [lockedGrant] = await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
||||
.where(and(eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id)))
|
||||
.for("update");
|
||||
if (!lockedGrant) throw notFound("Connection grant not found");
|
||||
await tx.select({ id: agents.id }).from(agents)
|
||||
.where(and(eq(agents.companyId, connection.companyId), notInArray(agents.status, ["pending_approval", "terminated"])))
|
||||
.orderBy(agents.id).for("no key update");
|
||||
const removedDelegations = await tx
|
||||
.delete(connectionGrantDelegations)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrantDelegations.companyId, connection.companyId),
|
||||
eq(connectionGrantDelegations.grantId, grantId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
const [updated] = await tx
|
||||
.update(connectionGrants)
|
||||
.set({
|
||||
status: "revoked",
|
||||
isDefault: false,
|
||||
revokedAt: new Date(),
|
||||
revokedByAgentId:
|
||||
binding.actorType === "agent" ? binding.actorId : null,
|
||||
revokedByUserId:
|
||||
binding.actorType === "user" ? binding.actorId : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, grantId),
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!updated) throw notFound("Connection grant not found");
|
||||
if (removedDelegations.length > 0) {
|
||||
await tx.insert(toolAccessAuditEvents).values(
|
||||
removedDelegations.map((delegation) => ({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.delegation_revoked",
|
||||
outcome: "success",
|
||||
reasonCode: "grant_revoked",
|
||||
details: {
|
||||
grantId,
|
||||
delegationId: delegation.id,
|
||||
agentId: delegation.agentId,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
if (!grant) throw notFound("Connection grant not found");
|
||||
await db.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_grant.revoked",
|
||||
outcome: "success",
|
||||
reasonCode: "grant_revoked",
|
||||
details: { grantId: grant.id, kind: grant.kind, providerRevocation },
|
||||
});
|
||||
return toConnectionGrant(grant);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { toConnection, toConnectionInstall } from "./projection.js";
|
||||
import { actorBinding } from "../actor.js";
|
||||
import { lockToolAssignmentAgents, lockToolConnectionRuntimeInputs } from "../assignment-locks.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { toolAccessAuditEvents, toolConnectionInstalls, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
|
||||
import type { ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, PutToolConnectionInstalls } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../../errors.js";
|
||||
import { logActivity } from "../../activity-log.js";
|
||||
|
||||
type InstallDependencies = {
|
||||
now: () => Date;
|
||||
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
|
||||
assertOptionalAgent(companyId: string, agentId: string | null | undefined, label: string): Promise<void>;
|
||||
};
|
||||
|
||||
export function createConnectionInstalls(db: Db, { now, getConnectionRow, assertOptionalAgent }: InstallDependencies) {
|
||||
async function appProfileForConnection(
|
||||
dbClient: Pick<Db, "select" | "insert" | "delete">,
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
) {
|
||||
const profileKey = `app:${connection.id}`;
|
||||
let [profile] = await dbClient
|
||||
.select()
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, connection.companyId),
|
||||
eq(toolProfiles.profileKey, profileKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!profile) {
|
||||
const [sameName] = await dbClient
|
||||
.select({ id: toolProfiles.id })
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, connection.companyId),
|
||||
eq(toolProfiles.name, connection.name),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const profileName = sameName
|
||||
? `${connection.name} (${connection.id.replace(/-/g, "").slice(0, 8)})`
|
||||
: connection.name;
|
||||
[profile] = await dbClient
|
||||
.insert(toolProfiles)
|
||||
.values({
|
||||
companyId: connection.companyId,
|
||||
profileKey,
|
||||
name: profileName,
|
||||
description: `Access profile for ${connection.name}.`,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
metadata: {
|
||||
source: "tool_connection_install",
|
||||
connectionId: connection.id,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
// Installation controls where a connection is exposed, not which actions
|
||||
// it grants. The app wizard's catalog-entry includes are the authority for
|
||||
// action selection, so remove the legacy connection-wide include that used
|
||||
// to silently turn every installed action on.
|
||||
await dbClient
|
||||
.delete(toolProfileEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfileEntries.companyId, connection.companyId),
|
||||
eq(toolProfileEntries.profileId, profile.id),
|
||||
eq(toolProfileEntries.selectorType, "connection"),
|
||||
eq(toolProfileEntries.effect, "include"),
|
||||
eq(toolProfileEntries.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function listConnectionInstalls(
|
||||
connectionId: string,
|
||||
companyId?: string,
|
||||
): Promise<ToolConnectionInstall[]> {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnectionInstalls.companyId, connection.companyId),
|
||||
eq(toolConnectionInstalls.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
asc(toolConnectionInstalls.targetType),
|
||||
asc(toolConnectionInstalls.targetId),
|
||||
);
|
||||
return rows.map(toConnectionInstall);
|
||||
}
|
||||
|
||||
async function resolveInstalledConnectionsForAgent(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): Promise<ToolConnection[]> {
|
||||
await assertOptionalAgent(
|
||||
companyId,
|
||||
agentId,
|
||||
"Tool connection install agent",
|
||||
);
|
||||
const installRows = await db
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnectionInstalls.companyId, companyId),
|
||||
sql`((${toolConnectionInstalls.targetType} = 'company' and ${toolConnectionInstalls.targetId} = ${companyId}) or (${toolConnectionInstalls.targetType} = 'agent' and ${toolConnectionInstalls.targetId} = ${agentId}))`,
|
||||
),
|
||||
);
|
||||
if (installRows.length === 0) return [];
|
||||
const connectionIds = [
|
||||
...new Set(installRows.map((install) => install.connectionId)),
|
||||
];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, companyId),
|
||||
inArray(toolConnections.id, connectionIds),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(toolConnections.name));
|
||||
return rows.map((row) => ({
|
||||
...toConnection(row),
|
||||
installs: installRows
|
||||
.filter((install) => install.connectionId === row.id)
|
||||
.map(toConnectionInstall),
|
||||
}));
|
||||
}
|
||||
return {
|
||||
resolveInstalledConnectionsForAgent,
|
||||
listConnectionInstalls,
|
||||
putConnectionInstalls: async (
|
||||
connectionId: string,
|
||||
input: PutToolConnectionInstalls,
|
||||
actor?: ActorInfo,
|
||||
): Promise<ToolConnectionInstallSnapshot> => {
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
const requested = new Map(
|
||||
input.installs.map((install) => [
|
||||
`${install.targetType}:${install.targetId}`,
|
||||
install,
|
||||
]),
|
||||
);
|
||||
for (const install of requested.values()) {
|
||||
if (install.targetType === "company") {
|
||||
if (install.targetId !== connection.companyId)
|
||||
throw unprocessable(
|
||||
"Company installs must target the connection company",
|
||||
);
|
||||
} else {
|
||||
await assertOptionalAgent(
|
||||
connection.companyId,
|
||||
install.targetId,
|
||||
"Tool connection install agent",
|
||||
);
|
||||
}
|
||||
}
|
||||
const markMcpAccessConfigured = connection.transport === "mcp_remote"
|
||||
&& connection.config.mcpAgentAccessConfigured !== true;
|
||||
const accessExtensions: Array<{
|
||||
targetType: "company" | "agent";
|
||||
targetId: string;
|
||||
profileId: string;
|
||||
}> = [];
|
||||
await db.transaction(async (tx) => {
|
||||
await lockToolConnectionRuntimeInputs(tx, connection.companyId, connection.id);
|
||||
if (markMcpAccessConfigured) {
|
||||
// Preserve an explicit zero-agent selection through OAuth. Merge the
|
||||
// marker in SQL so concurrent credential/config changes are retained.
|
||||
await tx.update(toolConnections).set({
|
||||
config: sql`${toolConnections.config} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
|
||||
transportConfig: sql`${toolConnections.transportConfig} || '{"mcpAgentAccessConfigured":true}'::jsonb`,
|
||||
updatedAt: now(),
|
||||
}).where(and(
|
||||
eq(toolConnections.companyId, connection.companyId),
|
||||
eq(toolConnections.id, connection.id),
|
||||
));
|
||||
}
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(toolConnectionInstalls)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnectionInstalls.companyId, connection.companyId),
|
||||
eq(toolConnectionInstalls.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
const existingKeys = new Set(
|
||||
existing.map(
|
||||
(install) => `${install.targetType}:${install.targetId}`,
|
||||
),
|
||||
);
|
||||
const bindings = await tx.select({ targetType: toolProfileBindings.targetType, targetId: toolProfileBindings.targetId })
|
||||
.from(toolProfileBindings).innerJoin(toolProfiles, eq(toolProfiles.id, toolProfileBindings.profileId))
|
||||
.where(and(eq(toolProfileBindings.companyId, connection.companyId), eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.profileKey, `app:${connection.id}`)));
|
||||
await lockToolAssignmentAgents(tx, connection.companyId, [...existing, ...requested.values(), ...bindings]);
|
||||
const removals = existing.filter(
|
||||
(install) =>
|
||||
!requested.has(`${install.targetType}:${install.targetId}`),
|
||||
);
|
||||
const removeIds = removals.map((install) => install.id);
|
||||
if (removeIds.length > 0) {
|
||||
await tx
|
||||
.delete(toolConnectionInstalls)
|
||||
.where(inArray(toolConnectionInstalls.id, removeIds));
|
||||
// Uninstalling must also drop the binding this path created. Installing
|
||||
// writes both an install row and a profile binding, so deleting only the
|
||||
// install row leaves a binding that no surface can see or remove. The
|
||||
// install row is the reach gate (`mintConnectionTokenForAgent` fails with
|
||||
// `installation_required`, and the heartbeat only hands over installed
|
||||
// connections), so a stale binding grants no reach on its own — but it
|
||||
// still makes `finishApp`, which rebuilds `access` from the bindings,
|
||||
// read a target the operator already removed.
|
||||
//
|
||||
// Only bindings tagged `source: "tool_connection_install"` are removed.
|
||||
// A binding the operator authored through the access model carries a
|
||||
// different source and must survive an uninstall.
|
||||
const [installProfile] = await tx
|
||||
.select({ id: toolProfiles.id })
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, connection.companyId),
|
||||
eq(toolProfiles.profileKey, `app:${connection.id}`),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (installProfile) {
|
||||
for (const install of removals) {
|
||||
await tx
|
||||
.delete(toolProfileBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfileBindings.companyId, connection.companyId),
|
||||
eq(toolProfileBindings.profileId, installProfile.id),
|
||||
eq(toolProfileBindings.targetType, install.targetType),
|
||||
eq(toolProfileBindings.targetId, install.targetId),
|
||||
sql`${toolProfileBindings.metadata}->>'source' = 'tool_connection_install'`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const additions = [...requested.entries()]
|
||||
.filter(([key]) => !existingKeys.has(key))
|
||||
.map(([, install]) => install);
|
||||
if (additions.length > 0) {
|
||||
await tx.insert(toolConnectionInstalls).values(
|
||||
additions.map((install) => ({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
targetType: install.targetType,
|
||||
targetId: install.targetId,
|
||||
createdByAgentId:
|
||||
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
||||
createdByUserId:
|
||||
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (requested.size > 0 && connection.connectionPurpose !== "ai") {
|
||||
const profile = await appProfileForConnection(tx, connection);
|
||||
for (const install of requested.values()) {
|
||||
const [binding] = await tx
|
||||
.insert(toolProfileBindings)
|
||||
.values({
|
||||
companyId: connection.companyId,
|
||||
profileId: profile.id,
|
||||
targetType: install.targetType,
|
||||
targetId: install.targetId,
|
||||
priority: 100,
|
||||
metadata: {
|
||||
source: "tool_connection_install",
|
||||
connectionId: connection.id,
|
||||
},
|
||||
createdByAgentId:
|
||||
actor?.actorType === "agent" ? (actor.actorId ?? null) : null,
|
||||
createdByUserId:
|
||||
actor?.actorType === "user" ? (actor.actorId ?? null) : null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: toolProfileBindings.id });
|
||||
if (binding)
|
||||
accessExtensions.push({
|
||||
targetType: install.targetType,
|
||||
targetId: install.targetId,
|
||||
profileId: profile.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (markMcpAccessConfigured || removeIds.length > 0 || additions.length > 0) {
|
||||
const binding = actorBinding(actor);
|
||||
await tx.insert(toolAccessAuditEvents).values({
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
actorType: binding.actorType ?? "system",
|
||||
actorId: binding.actorId,
|
||||
action: "connection_installs.changed",
|
||||
outcome: "success",
|
||||
reasonCode: "installs_changed",
|
||||
details: {
|
||||
...(markMcpAccessConfigured ? { mcpAgentAccessConfigured: true } : {}),
|
||||
added: additions.map((install) => ({
|
||||
targetType: install.targetType,
|
||||
targetId: install.targetId,
|
||||
})),
|
||||
removed: existing
|
||||
.filter((install) => removeIds.includes(install.id))
|
||||
.map((install) => ({
|
||||
targetType: install.targetType,
|
||||
targetId: install.targetId,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const extension of accessExtensions) {
|
||||
await logActivity(db, {
|
||||
companyId: connection.companyId,
|
||||
actorType: actor?.actorType ?? "system",
|
||||
actorId: actor?.actorId ?? "system",
|
||||
action: "tool_connection.install_access_extended",
|
||||
entityType: "tool_connection",
|
||||
entityId: connection.id,
|
||||
details: extension,
|
||||
});
|
||||
}
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
installs: await listConnectionInstalls(
|
||||
connection.id,
|
||||
connection.companyId,
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
import { toConnection } from "./projection.js";
|
||||
import { actorBinding } from "../actor.js";
|
||||
import { vercelConnectResourcesFor } from "../vercel-credentials.js";
|
||||
import type { ActorInfo } from "../types.js";
|
||||
import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js";
|
||||
import { removeSlackRegistration } from "../../chat-slack-registration-cleanup.js";
|
||||
import { chatEndpoints } from "@paperclipai/db";
|
||||
import { isBrowserUseConnection } from "../../browser-use-client.js";
|
||||
import { browserUseService } from "../../browser-use.js";
|
||||
import { emitConnectionUpdated } from "../../connector-telemetry.js";
|
||||
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { connectionGrants, connectionTokenIssuances, companySecretBindings, companySecrets, userSecretDefinitions, userSecretDeclarations, toolApplications, toolCatalogEntries, toolConnectionInstalls, toolConnections, toolOauthStates, toolGatewaySessions, toolMcpGateways, toolMcpGatewayTokens, toolProfileBindings, toolProfileEntries, toolProfiles, toolRuntimeSlots } from "@paperclipai/db";
|
||||
import type { ToolConnectionRemovalResult, ToolConnectionRemovalSummary } from "@paperclipai/shared";
|
||||
import { notFound } from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
import { forgetMcpHttpSessions } from "../../mcp-http.js";
|
||||
import { secretService } from "../../secrets.js";
|
||||
import { connectionSecretsUsedByOtherConsumers } from "../../connection-credentials.js";
|
||||
import { createToolRuntimeSupervisor } from "../../tool-runtime-supervisor.js";
|
||||
import { vercelConnectIntegrationStatus, vercelTokenRequest, type VercelConnectClient } from "../../vercel-connect.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value))
|
||||
return value as Record<string, unknown>;
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The key namespace `connectGalleryApp`, `reconnectGalleryApp` and
|
||||
* `createOrRotateOAuthSecret` mint for credentials a connection owns outright.
|
||||
* Nothing else writes this prefix, which is what lets removal tell a dedicated
|
||||
* app credential apart from a secret the operator manages by hand — see
|
||||
* `classifyConnectionSecrets`. Matched as a prefix on purpose: `secrets.remove`
|
||||
* suffixes `__deleted__<id>` onto the key before it deletes the row, so a
|
||||
* removal that is retried after a provider failure must still recognise it.
|
||||
*/
|
||||
const CONNECTION_OWNED_SECRET_KEY_PREFIX = "tool_app.";
|
||||
|
||||
/** Token fields a legacy row may have inlined into `config.oauth`. */
|
||||
const INLINE_OAUTH_TOKEN_FIELDS = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"client_secret",
|
||||
"clientSecret",
|
||||
];
|
||||
|
||||
/**
|
||||
* Drop inline OAuth token material from a connection config, keeping the
|
||||
* non-secret identity (client id, issuer, registration source) a later
|
||||
* reconnect reuses. Current code stores tokens as secret refs, never in the
|
||||
* config; this exists so a row written by an older build cannot keep a usable
|
||||
* token after the operator removed the app.
|
||||
*/
|
||||
function withoutInlineOAuthTokens(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const oauth = config.oauth;
|
||||
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth))
|
||||
return config;
|
||||
const next = { ...(oauth as Record<string, unknown>) };
|
||||
let changed = false;
|
||||
for (const field of INLINE_OAUTH_TOKEN_FIELDS) {
|
||||
if (field in next) {
|
||||
delete next[field];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? { ...config, oauth: next } : config;
|
||||
}
|
||||
|
||||
type RemovalDependencies = {
|
||||
options: { remoteHttpRequest?: (url: string, init: RequestInit) => Promise<Response> };
|
||||
secrets: ReturnType<typeof secretService>;
|
||||
runtimeSupervisor: Pick<ReturnType<typeof createToolRuntimeSupervisor>, "stopSlot">;
|
||||
vercelConnect: VercelConnectClient | null;
|
||||
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
|
||||
};
|
||||
|
||||
export function createConnectionRemoval(db: Db, { options, secrets, runtimeSupervisor, vercelConnect, getConnectionRow }: RemovalDependencies) {
|
||||
/**
|
||||
* Split the secrets a connection points at into the ones it owns outright and
|
||||
* the ones another consumer still depends on.
|
||||
*
|
||||
* Removing an app is a credential revocation boundary (PAP-17119), but it must
|
||||
* never destroy a secret the operator manages by hand or shares with another
|
||||
* target. Two independent tests have to agree before a secret is destroyed:
|
||||
*
|
||||
* 1. Provenance — the key sits in the `tool_app.` namespace only the
|
||||
* connect/reconnect/OAuth paths mint, or its user-secret definition does.
|
||||
* Personal definitions must also have no declarations on other targets.
|
||||
* 2. Exclusivity — nothing outside this connection references it: no
|
||||
* `company_secret_bindings` row from another target, and no other
|
||||
* connection or connection grant naming the same secret id.
|
||||
*
|
||||
* A secret failing either test is reported as retained; removal still drops
|
||||
* this connection's binding and ref, so the connection loses the credential
|
||||
* either way. Retaining a secret nobody can reach is a leak of an unused row;
|
||||
* deleting one another target still resolves is an outage, so the ambiguous
|
||||
* case fails towards retention.
|
||||
*/
|
||||
async function classifyConnectionSecrets(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
secretIds: string[],
|
||||
): Promise<{ owned: string[]; retained: string[] }> {
|
||||
const unique = [
|
||||
...new Set(
|
||||
secretIds.filter((id) => typeof id === "string" && id.length > 0),
|
||||
),
|
||||
];
|
||||
if (unique.length === 0) return { owned: [], retained: [] };
|
||||
|
||||
const secretRows = await db
|
||||
.select({
|
||||
id: companySecrets.id,
|
||||
key: companySecrets.key,
|
||||
scope: companySecrets.scope,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.companyId, connection.companyId),
|
||||
inArray(companySecrets.id, unique),
|
||||
),
|
||||
);
|
||||
const byId = new Map(secretRows.map((row) => [row.id, row]));
|
||||
|
||||
const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique);
|
||||
const foreignBindings = await db
|
||||
.select({ secretId: companySecretBindings.secretId })
|
||||
.from(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, connection.companyId),
|
||||
inArray(companySecretBindings.secretId, unique),
|
||||
sql`not (${companySecretBindings.targetType} = 'tool_connection' and ${companySecretBindings.targetId} = ${connection.id})`,
|
||||
),
|
||||
);
|
||||
for (const row of foreignBindings) referencedElsewhere.add(row.secretId);
|
||||
|
||||
// Bindings are the authority, but read the sibling refs too: a row written
|
||||
// before `syncCredentialBindings` existed — or by hand — can reference a
|
||||
// secret with no binding to prove it.
|
||||
const siblingConnections = await db
|
||||
.select({
|
||||
credentialRefs: toolConnections.credentialRefs,
|
||||
credentialSecretRefs: toolConnections.credentialSecretRefs,
|
||||
})
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, connection.companyId),
|
||||
ne(toolConnections.id, connection.id),
|
||||
),
|
||||
);
|
||||
for (const row of siblingConnections) {
|
||||
for (const ref of row.credentialRefs ?? [])
|
||||
referencedElsewhere.add(ref.secretId);
|
||||
for (const ref of row.credentialSecretRefs ?? [])
|
||||
referencedElsewhere.add(ref.secretId);
|
||||
}
|
||||
const siblingGrants = await db
|
||||
.select({ credentialSecretRefs: connectionGrants.credentialSecretRefs })
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
ne(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
for (const row of siblingGrants) {
|
||||
for (const ref of row.credentialSecretRefs ?? [])
|
||||
referencedElsewhere.add(ref.secretId);
|
||||
}
|
||||
|
||||
const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []);
|
||||
const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions)
|
||||
.where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : [];
|
||||
const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id));
|
||||
const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and(
|
||||
inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds),
|
||||
sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`,
|
||||
)) : [];
|
||||
const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId));
|
||||
const owned: string[] = [];
|
||||
const retained: string[] = [];
|
||||
for (const secretId of unique) {
|
||||
const row = byId.get(secretId);
|
||||
// No row means an earlier pass of this same removal already deleted it.
|
||||
// Hand it back as owned so a retry re-runs the (idempotent) revocation
|
||||
// instead of reporting a credential this connection never shared.
|
||||
if (!row) {
|
||||
owned.push(secretId);
|
||||
continue;
|
||||
}
|
||||
const dedicated = row.scope === "user" && row.userSecretDefinitionId
|
||||
? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId)
|
||||
: row.scope === "company" && row.userSecretDefinitionId === null
|
||||
&& row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX);
|
||||
if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId);
|
||||
else retained.push(secretId);
|
||||
}
|
||||
return { owned, retained };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an app: a credential-revoking teardown, not a status flip (PAP-17119).
|
||||
*
|
||||
* Order is the security property. Every database-side access path closes
|
||||
* first — grants, installs, the app-managed profile, gateway tokens minted
|
||||
* against it, outstanding OAuth state, the catalog, and the connection itself
|
||||
* — so the app is already undispatchable before the first call out to a secret
|
||||
* provider. Secret revocation runs last, and each secret's ref survives until
|
||||
* that secret is gone, so a provider that errors leaves the operation failed
|
||||
* closed and resumable: the credential is already unresolvable (its row is
|
||||
* marked deleted first), and retrying the same removal finishes the job.
|
||||
*
|
||||
* What stays behind is deliberate: the connection and application rows, their
|
||||
* ids, names and activity keep working so a later reconnect reuses the same
|
||||
* identity — but with no credential, no install and no profile, so
|
||||
* reconnecting has to ask for fresh authentication and rebuild access.
|
||||
*/
|
||||
async function removeConnection(
|
||||
connectionId: string,
|
||||
companyId?: string,
|
||||
actor?: ActorInfo,
|
||||
): Promise<ToolConnectionRemovalResult> {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
const [endpoint] = await db.select().from(chatEndpoints).where(and(eq(chatEndpoints.connectionId, connection.id), eq(chatEndpoints.companyId, connection.companyId), eq(chatEndpoints.provider, "slack")));
|
||||
if (!endpoint) return removeConnectionUnlocked(connectionId, companyId, actor);
|
||||
return chatCredentialMutationLease(db)(endpoint, async lease => {
|
||||
await db.transaction(async tx => {
|
||||
await lease.assertOwned(tx);
|
||||
await tx.update(chatEndpoints).set({ status: "archived", updatedAt: new Date() }).where(eq(chatEndpoints.id, endpoint.id));
|
||||
await tx.update(toolConnections).set({ status: "archived", enabled: false, updatedAt: new Date() }).where(eq(toolConnections.id, connection.id));
|
||||
});
|
||||
await removeSlackRegistration(db, endpoint.id, lease);
|
||||
return removeConnectionUnlocked(connectionId, companyId, actor);
|
||||
});
|
||||
}
|
||||
|
||||
async function removeConnectionUnlocked(connectionId: string, companyId?: string, actor?: ActorInfo): Promise<ToolConnectionRemovalResult> {
|
||||
const connection = await getConnectionRow(connectionId, companyId);
|
||||
forgetMcpHttpSessions(connection.id);
|
||||
const now = new Date();
|
||||
const binding = actorBinding(actor);
|
||||
|
||||
// Grants are read before they are revoked: a retried removal must still see
|
||||
// the credential refs of a grant an earlier pass already marked revoked.
|
||||
const grantRows = await db
|
||||
.select({
|
||||
id: connectionGrants.id,
|
||||
status: connectionGrants.status,
|
||||
kind: connectionGrants.kind,
|
||||
subjectUserId: connectionGrants.subjectUserId,
|
||||
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
||||
externalCredential: connectionGrants.externalCredential,
|
||||
})
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
const grantsToRevoke = grantRows.filter((row) => row.status !== "revoked");
|
||||
if (grantsToRevoke.length > 0) {
|
||||
await db
|
||||
.update(connectionGrants)
|
||||
.set({
|
||||
status: "revoked",
|
||||
isDefault: false,
|
||||
revokedAt: now,
|
||||
revokedByAgentId:
|
||||
binding.actorType === "agent" ? binding.actorId : null,
|
||||
revokedByUserId:
|
||||
binding.actorType === "user" ? binding.actorId : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
inArray(
|
||||
connectionGrants.id,
|
||||
grantsToRevoke.map((row) => row.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
let externalCredentialCleanup: ToolConnectionRemovalSummary["externalCredentialCleanup"] =
|
||||
null;
|
||||
if (
|
||||
connection.credentialSource === "vercel_connect" &&
|
||||
connection.externalCredential
|
||||
) {
|
||||
let attempted = 0;
|
||||
let revoked = 0;
|
||||
let failures = 0;
|
||||
let appSubjectCleanup: "not_applicable" | "manage_in_vercel" =
|
||||
"not_applicable";
|
||||
for (const grant of grantRows) {
|
||||
const request = vercelTokenRequest({
|
||||
credential: connection.externalCredential,
|
||||
grant,
|
||||
connectionId: connection.id,
|
||||
companyId: connection.companyId,
|
||||
resources: vercelConnectResourcesFor(connection),
|
||||
});
|
||||
vercelConnect?.evict(request);
|
||||
if (grant.externalCredential?.subjectType === "app") {
|
||||
appSubjectCleanup = "manage_in_vercel";
|
||||
continue;
|
||||
}
|
||||
if (grant.externalCredential?.subjectType !== "user") continue;
|
||||
attempted += 1;
|
||||
try {
|
||||
if (!vercelConnect) throw new Error("Vercel Connect unavailable");
|
||||
await vercelConnect.revoke(request);
|
||||
revoked += 1;
|
||||
} catch {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
externalCredentialCleanup = {
|
||||
provider: "vercel_connect",
|
||||
userSubjectsAttempted: attempted,
|
||||
userSubjectsRevoked: revoked,
|
||||
userSubjectFailures: failures,
|
||||
appSubjectCleanup,
|
||||
manageUrl: vercelConnectIntegrationStatus().manageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
// Bindings are how a credential reaches a runtime, so they go before the
|
||||
// provider round-trip rather than after it. They are also not needed to
|
||||
// finish the job: the refs on the connection row are what a resumed removal
|
||||
// reads to find the secrets it still owes a revocation.
|
||||
const removedSecretBindings = await db
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, connection.companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connection.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: companySecretBindings.id });
|
||||
|
||||
const removedInstalls = await db
|
||||
.delete(toolConnectionInstalls)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnectionInstalls.companyId, connection.companyId),
|
||||
eq(toolConnectionInstalls.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolConnectionInstalls.id });
|
||||
|
||||
// The app-managed profile exists only to carry this connection's action
|
||||
// selection, so it goes with the connection. Operator-authored profiles that
|
||||
// happen to mention the connection are left alone — the archived connection
|
||||
// is denied by the policy engine regardless.
|
||||
const [appProfile] = await db
|
||||
.select({ id: toolProfiles.id })
|
||||
.from(toolProfiles)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfiles.companyId, connection.companyId),
|
||||
eq(toolProfiles.profileKey, `app:${connection.id}`),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
let appProfileOutcome: ToolConnectionRemovalSummary["appProfile"] =
|
||||
"absent";
|
||||
let appProfileEntriesRemoved = 0;
|
||||
let appProfileBindingsRemoved = 0;
|
||||
let gatewayTokensRevoked = 0;
|
||||
let gatewaySessionsRevoked = 0;
|
||||
if (appProfile) {
|
||||
appProfileEntriesRemoved = await db.transaction(async tx => {
|
||||
await tx.select({ id: toolProfiles.id }).from(toolProfiles)
|
||||
.where(and(eq(toolProfiles.companyId, connection.companyId), eq(toolProfiles.id, appProfile.id)))
|
||||
.for("no key update");
|
||||
return (await tx.delete(toolProfileEntries)
|
||||
.where(and(eq(toolProfileEntries.companyId, connection.companyId), eq(toolProfileEntries.profileId, appProfile.id)))
|
||||
.returning({ id: toolProfileEntries.id })).length;
|
||||
});
|
||||
appProfileBindingsRemoved = (
|
||||
await db
|
||||
.delete(toolProfileBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(toolProfileBindings.companyId, connection.companyId),
|
||||
eq(toolProfileBindings.profileId, appProfile.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolProfileBindings.id })
|
||||
).length;
|
||||
|
||||
const gatewayRows = await db
|
||||
.select({ id: toolMcpGateways.id })
|
||||
.from(toolMcpGateways)
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, connection.companyId),
|
||||
eq(toolMcpGateways.profileId, appProfile.id),
|
||||
),
|
||||
);
|
||||
if (gatewayRows.length === 0) {
|
||||
await db.delete(toolProfiles).where(eq(toolProfiles.id, appProfile.id));
|
||||
appProfileOutcome = "deleted";
|
||||
} else {
|
||||
// `tool_mcp_gateways.profile_id` is ON DELETE RESTRICT, so a gateway
|
||||
// pointing here keeps the row alive. Archive it instead — the policy
|
||||
// engine only consults `active` profiles, and it has no entries left —
|
||||
// and revoke the tokens those gateways already handed out, which are the
|
||||
// one credential a caller could still present.
|
||||
await db
|
||||
.update(toolProfiles)
|
||||
.set({ status: "archived", defaultAction: "deny", updatedAt: now })
|
||||
.where(eq(toolProfiles.id, appProfile.id));
|
||||
appProfileOutcome = "archived";
|
||||
const revokedTokens = await db
|
||||
.update(toolMcpGatewayTokens)
|
||||
.set({ revokedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGatewayTokens.companyId, connection.companyId),
|
||||
inArray(
|
||||
toolMcpGatewayTokens.gatewayId,
|
||||
gatewayRows.map((row) => row.id),
|
||||
),
|
||||
isNull(toolMcpGatewayTokens.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolMcpGatewayTokens.id });
|
||||
gatewayTokensRevoked = revokedTokens.length;
|
||||
if (revokedTokens.length > 0) {
|
||||
gatewaySessionsRevoked = (
|
||||
await db
|
||||
.update(toolGatewaySessions)
|
||||
.set({ revokedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(toolGatewaySessions.companyId, connection.companyId),
|
||||
inArray(
|
||||
toolGatewaySessions.gatewayTokenId,
|
||||
revokedTokens.map((row) => row.id),
|
||||
),
|
||||
isNull(toolGatewaySessions.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolGatewaySessions.id })
|
||||
).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local runtime already holds the injected credential inside a live child
|
||||
// process, so archiving rows is not enough — the process itself is an access
|
||||
// path. Stopping is best effort on purpose: if the supervisor cannot be
|
||||
// reached, revoking the credential anyway (so nothing can start again) beats
|
||||
// abandoning the teardown, and the warning says which slot was left running.
|
||||
let runtimeSlotsStopped = 0;
|
||||
const runtimeSlotRows = await db
|
||||
.select({ id: toolRuntimeSlots.id, status: toolRuntimeSlots.status })
|
||||
.from(toolRuntimeSlots)
|
||||
.where(
|
||||
and(
|
||||
eq(toolRuntimeSlots.companyId, connection.companyId),
|
||||
eq(toolRuntimeSlots.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
for (const slot of runtimeSlotRows) {
|
||||
if (slot.status === "stopped") continue;
|
||||
try {
|
||||
await runtimeSupervisor.stopSlot({
|
||||
companyId: connection.companyId,
|
||||
slotId: slot.id,
|
||||
reason: "connection_removed",
|
||||
});
|
||||
runtimeSlotsStopped += 1;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
err: error,
|
||||
companyId: connection.companyId,
|
||||
connectionId: connection.id,
|
||||
slotId: slot.id,
|
||||
},
|
||||
"tool connection removal could not stop a runtime slot",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The catalog stays as history, but `removed` is the status that stops a
|
||||
// standing trust rule from auto-allowing one of these actions again.
|
||||
const removedCatalogEntries = await db
|
||||
.update(toolCatalogEntries)
|
||||
.set({ status: "removed", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(toolCatalogEntries.companyId, connection.companyId),
|
||||
eq(toolCatalogEntries.connectionId, connection.id),
|
||||
ne(toolCatalogEntries.status, "removed"),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolCatalogEntries.id });
|
||||
|
||||
// An authorization already in flight would otherwise come back and mint a
|
||||
// fresh token for an app the operator just removed.
|
||||
const discardedOAuthStates = await db
|
||||
.delete(toolOauthStates)
|
||||
.where(
|
||||
and(
|
||||
eq(toolOauthStates.companyId, connection.companyId),
|
||||
eq(toolOauthStates.connectionId, connection.id),
|
||||
),
|
||||
)
|
||||
.returning({ state: toolOauthStates.state });
|
||||
|
||||
// Token-derived material in the issuance ledger is not an access path —
|
||||
// nothing validates against it — but there is no reason to keep a hash of a
|
||||
// credential the operator asked us to revoke. Path, outcome, actor and time
|
||||
// stay, so the usage history survives.
|
||||
const clearedIssuanceHashes = await db
|
||||
.update(connectionTokenIssuances)
|
||||
.set({ tokenHash: null })
|
||||
.where(
|
||||
and(
|
||||
eq(connectionTokenIssuances.companyId, connection.companyId),
|
||||
eq(connectionTokenIssuances.connectionId, connection.id),
|
||||
sql`${connectionTokenIssuances.tokenHash} is not null`,
|
||||
),
|
||||
)
|
||||
.returning({ id: connectionTokenIssuances.id });
|
||||
|
||||
const archived = await db.transaction(async (tx) => {
|
||||
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
||||
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
|
||||
.for("update");
|
||||
const [updatedConnection] = await tx
|
||||
.update(toolConnections)
|
||||
.set({ status: "archived", enabled: false, updatedAt: now })
|
||||
.where(eq(toolConnections.id, connection.id))
|
||||
.returning();
|
||||
if (!updatedConnection) throw notFound("Tool connection not found");
|
||||
|
||||
const remainingConnections = await tx
|
||||
.select({ id: toolConnections.id })
|
||||
.from(toolConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.applicationId, updatedConnection.applicationId),
|
||||
ne(toolConnections.status, "archived"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let applicationArchived = false;
|
||||
if (remainingConnections.length === 0) {
|
||||
const [application] = await tx
|
||||
.update(toolApplications)
|
||||
.set({ status: "archived", archivedAt: now, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(toolApplications.id, updatedConnection.applicationId),
|
||||
ne(toolApplications.status, "archived"),
|
||||
),
|
||||
)
|
||||
.returning({ id: toolApplications.id });
|
||||
applicationArchived = Boolean(application);
|
||||
}
|
||||
|
||||
return { connection: updatedConnection, applicationArchived };
|
||||
});
|
||||
emitConnectionUpdated(archived.connection, connection, "archive");
|
||||
|
||||
// Hosted work needs its credential to stop. Agent/viewer access is already
|
||||
// revoked above; retain cleanup authority until provider shutdown confirms.
|
||||
if (isBrowserUseConnection(connection)) {
|
||||
await browserUseService(db, options.remoteHttpRequest).stopBeforeCredentialRemoval(connection.companyId, connection.id);
|
||||
}
|
||||
|
||||
// Only now, with every access path closed, revoke the credentials. Each
|
||||
// `secrets.remove` marks the row deleted before it calls the provider, so a
|
||||
// provider error leaves an unresolvable secret and a resumable removal
|
||||
// rather than a half-open app.
|
||||
const candidateSecretIds = [
|
||||
...Object.values(asRecord(connection.config.aggregatorDiscovery)).flatMap(value => typeof asRecord(value).secretId === "string" ? [asRecord(value).secretId as string] : []),
|
||||
...connection.credentialRefs.map((ref) => ref.secretId),
|
||||
...connection.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
...grantRows.flatMap((grant) =>
|
||||
(grant.credentialSecretRefs ?? []).map((ref) => ref.secretId),
|
||||
),
|
||||
];
|
||||
const { owned, retained } = await classifyConnectionSecrets(
|
||||
connection,
|
||||
candidateSecretIds,
|
||||
);
|
||||
let secretsRevoked = 0;
|
||||
for (const secretId of owned) {
|
||||
const removed = await secrets.remove(secretId);
|
||||
if (removed) secretsRevoked += 1;
|
||||
}
|
||||
|
||||
const credentialRefsCleared =
|
||||
connection.credentialRefs.length + connection.credentialSecretRefs.length;
|
||||
const [cleared] = await db
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
credentialRefs: [],
|
||||
credentialSecretRefs: [],
|
||||
config: withoutInlineOAuthTokens(connection.config),
|
||||
transportConfig: withoutInlineOAuthTokens(connection.transportConfig),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, connection.id))
|
||||
.returning();
|
||||
if (
|
||||
grantRows.some((grant) => (grant.credentialSecretRefs ?? []).length > 0)
|
||||
) {
|
||||
await db
|
||||
.update(connectionGrants)
|
||||
.set({ credentialSecretRefs: [], updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
connection: toConnection(cleared ?? archived.connection),
|
||||
removal: {
|
||||
secretsRevoked,
|
||||
secretsRetainedShared: retained.length,
|
||||
credentialRefsCleared,
|
||||
secretBindingsRemoved: removedSecretBindings.length,
|
||||
grantsRevoked: grantsToRevoke.length,
|
||||
installsRemoved: removedInstalls.length,
|
||||
appProfile: appProfileOutcome,
|
||||
appProfileEntriesRemoved,
|
||||
appProfileBindingsRemoved,
|
||||
catalogEntriesMarkedRemoved: removedCatalogEntries.length,
|
||||
oauthStatesDiscarded: discardedOAuthStates.length,
|
||||
tokenIssuanceHashesCleared: clearedIssuanceHashes.length,
|
||||
runtimeSlotsStopped,
|
||||
gatewayTokensRevoked,
|
||||
gatewaySessionsRevoked,
|
||||
applicationArchived: archived.applicationArchived,
|
||||
externalCredentialCleanup,
|
||||
},
|
||||
};
|
||||
}
|
||||
return removeConnection;
|
||||
}
|
||||
Reference in new issue
Block a user