mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +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
@@ -208,3 +208,17 @@ boundaries pass. No new supervisor, background loop or runtime cache is created.
|
||||
- Separate guarded remote HTTP, descriptor risk policy, discovery, publication, default access, cached reads, health, attention and aggregator account synchronization.
|
||||
- `tool-access.ts`: 8,997 → 6,418 lines. New owners range from 39 to 492 lines. Remote options remain live; catalog flights and aggregator sync jobs retain per-service lifetime. Publication locks and transaction-scoped default access remain unchanged.
|
||||
- Before/after: 470 public service/HTTP integration cases plus 44 catalog-publication, HTTP rebinding and aggregator-sync cases pass. Server typecheck and module-boundary checks pass. All 157 previous function/method bodies match by normalized syntax comparison.
|
||||
|
||||
### Connection access and removal; final target
|
||||
|
||||
- Extract credential-reference validation, grant audiences/delegations, installations and removal. Each owner keeps its locks, audit writes and cleanup order. Removal uses the existing supervisor and secret client, retains the Slack mutation lease, and keeps access revocation with resumable secret cleanup.
|
||||
- `tool-access.ts`: 6,418 → **4,820 lines / 198,002 bytes**. The full stack started at 21,525 lines / 829,519 bytes. The four new modules have 75, 603, 350 and 658 lines.
|
||||
- Removal, grant resolution and the strengthened real HTTP OAuth journey passed before extraction (13 cases). The final combined public-path run passes 525 cases across seven suites. Full repository typecheck and build pass. Module-boundary and whitespace checks pass; all 94 previous function/method bodies match by normalized syntax comparison.
|
||||
- The earlier local full-suite attempt remains interrupted and unverified. PR descriptions retain that limitation and original CI failures alongside current-head CI outcomes.
|
||||
- The requested size target is met. App setup, finish/reconnect orchestration, local template definitions and examples remain in the coordinator; further extraction is outside this batch.
|
||||
|
||||
### Merge integration
|
||||
|
||||
- The first eight PRs landed on master. The catalog extraction was replayed after the Braintrust connector policy change in #17141. Risk classification and publication retain that policy in their extracted owners; setup retains its policy call.
|
||||
- The final entry point is 4,821 lines / 198,065 bytes. All 157 catalog and 94 connection-lifecycle method bodies match their updated bases by normalized syntax comparison.
|
||||
- The integrated stack passes 601 targeted cases across nine suites, including Braintrust lifecycle and connector-policy coverage. Server typecheck, module-boundary and whitespace checks pass. Fresh CI and reviews are required for both updated PR heads before merge.
|
||||
+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