mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Filter AgentMail metadata before credential discovery, keep aggregator settings and inventory under the responsible user, and retain actual-agent audit identity. Return the committed nonsecret generation alongside human-only setup secrets so clients can reconcile a lost replacement response. Co-Authored-By: Paperclip <noreply@paperclip.ing>
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import type { ConnectionManagementContext } from "../../connection-management.js";
|
|
import type { EndpointRow, InternalSetupState } from "../runtime-types.js";
|
|
|
|
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
|
|
|
import {
|
|
randomBytes,
|
|
randomUUID,
|
|
} from "node:crypto";
|
|
import {
|
|
and,
|
|
eq,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
|
|
import {
|
|
chatEndpoints,
|
|
companySecretBindings,
|
|
toolConnections,
|
|
} from "@paperclipai/db";
|
|
import type {
|
|
ChatEndpointSetupState,
|
|
ToolCredentialSecretRef,
|
|
} from "@paperclipai/shared";
|
|
|
|
import {
|
|
conflict,
|
|
forbidden,
|
|
notFound,
|
|
unprocessable,
|
|
} from "../../../errors.js";
|
|
import { logger } from "../../../middleware/logger.js";
|
|
|
|
import {
|
|
logActivity,
|
|
} from "../../activity-log.js";
|
|
|
|
import type { EndpointRecord } from "../runtime-types.js";
|
|
|
|
type GitHubSetupSecretOptions = {
|
|
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
|
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
|
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
|
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null, onCommitted?: (tx: Db) => Promise<void>) => Promise<ToolCredentialSecretRef[]>;
|
|
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
|
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
|
invalidateRuntime: (endpointId: string) => Promise<boolean>;
|
|
redactError: (error: unknown) => string;
|
|
setupSecretActivityLogger?: typeof logActivity;
|
|
setupSecretCredentialPersistBarrier?: () => Promise<void>;
|
|
setupSecretFinalOwnershipBarrier?: () => Promise<void>;
|
|
};
|
|
|
|
export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOptions) {
|
|
const { endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError } = options;
|
|
|
|
|
|
async function generateSetupSecret(
|
|
endpointId: string,
|
|
actorUserId?: string | null,
|
|
managementContext?: ConnectionManagementContext,
|
|
) {
|
|
await managementContext?.reauthorize();
|
|
if (managementContext?.actor.type === "agent") {
|
|
throw forbidden("Webhook signing secrets require a human setup handoff");
|
|
}
|
|
const initial = await endpointRecord(endpointId);
|
|
if (!initial) throw notFound("Chat endpoint not found");
|
|
if (initial.endpoint.provider !== "github") {
|
|
throw unprocessable(
|
|
"Setup secret generation is only available for GitHub",
|
|
);
|
|
}
|
|
let committedWebhookSecretRef: ToolCredentialSecretRef | null = null;
|
|
return withCredentialMutationLease(
|
|
initial.endpoint,
|
|
async (credentialLease) => {
|
|
// A competing request may have completed while this request waited for
|
|
// the durable lease. Reload both endpoint state and credential refs only
|
|
// after ownership so each successful rotation starts from its immediate
|
|
// predecessor instead of replacing the credential set from a stale read.
|
|
const record = await endpointRecord(endpointId);
|
|
if (!record) throw notFound("Chat endpoint not found");
|
|
const endpoint = record.endpoint;
|
|
if (
|
|
![
|
|
"draft",
|
|
"verifying",
|
|
"active",
|
|
"attention",
|
|
"revoked",
|
|
"paused",
|
|
].includes(endpoint.status)
|
|
) {
|
|
throw conflict(
|
|
"The GitHub webhook secret cannot be rotated in this connection state",
|
|
{
|
|
code: "chat_endpoint_setup_secret_unavailable",
|
|
},
|
|
);
|
|
}
|
|
const webhookSecret = randomBytes(32).toString("hex");
|
|
// Rotation must fail closed if any existing credential cannot be resolved.
|
|
// Falling back to an empty object here would replace the full credential
|
|
// set with only the new webhook secret and strand an otherwise-live App.
|
|
const existing = await resolveCredentials(endpoint);
|
|
const replacedExistingSecret = record.credentialSecretRefs.some(
|
|
(ref) => ref.configPath === "credentials.webhookSecret",
|
|
);
|
|
const rotatingConfiguredApp =
|
|
Boolean(endpoint.providerAccountId || endpoint.botExternalId) ||
|
|
record.credentialSecretRefs.some((ref) =>
|
|
["credentials.appId", "credentials.privateKey"].includes(
|
|
ref.configPath,
|
|
),
|
|
);
|
|
const rotationId = randomUUID();
|
|
const auditDetails = {
|
|
endpointId: endpoint.id,
|
|
provider: endpoint.provider,
|
|
rotationId,
|
|
rotated: rotatingConfiguredApp,
|
|
replacedPrevious: replacedExistingSecret,
|
|
};
|
|
const writeSetupSecretActivity =
|
|
options.setupSecretActivityLogger ?? logActivity;
|
|
// This durable intent is the audit boundary. If it cannot be written,
|
|
// fail before changing state or refs so retry is safe. Endpoint/tool
|
|
// state moves fail-closed before ref replacement. Once refs change, only
|
|
// best-effort work remains and the plaintext must still be returned.
|
|
await db.transaction(async (tx) => {
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
await writeSetupSecretActivity(tx as unknown as Db, {
|
|
companyId: endpoint.companyId,
|
|
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
|
|
action: "chat_endpoint.setup_secret_rotation_started",
|
|
entityType: "tool_connection",
|
|
entityId: endpoint.connectionId,
|
|
details: auditDetails,
|
|
});
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
});
|
|
try {
|
|
if (rotatingConfiguredApp) {
|
|
const updatedAt = new Date();
|
|
await db.transaction(async (tx) => {
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await tx
|
|
.select({ setup: chatEndpoints.setup })
|
|
.from(chatEndpoints)
|
|
.where(eq(chatEndpoints.id, endpoint.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current) throw notFound("Chat endpoint not found");
|
|
await tx
|
|
.update(chatEndpoints)
|
|
.set({
|
|
status: "attention",
|
|
healthMessage:
|
|
"Update the GitHub webhook secret, then reconnect this App",
|
|
lastError: null,
|
|
setup: {
|
|
...current.setup,
|
|
step: "provider_setup",
|
|
webhookVerifiedAt: null,
|
|
runtimeGeneration: runtimeGeneration(current.setup) + 1,
|
|
} as InternalSetupState,
|
|
updatedAt,
|
|
})
|
|
.where(eq(chatEndpoints.id, endpoint.id));
|
|
await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "disabled",
|
|
enabled: false,
|
|
healthStatus: "degraded",
|
|
healthMessage:
|
|
"GitHub webhook secret rotation needs reconnect",
|
|
lastError: null,
|
|
healthCheckedAt: updatedAt,
|
|
updatedAt,
|
|
})
|
|
.where(eq(toolConnections.id, endpoint.connectionId));
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
});
|
|
await invalidateRuntime(endpoint.id).catch(() => undefined);
|
|
} else {
|
|
// Replacing a secret while the operator is still creating the GitHub
|
|
// App is not a live credential rotation. Keep first-time setup in its
|
|
// draft state, clear any ping for the superseded value, and also heal
|
|
// endpoints affected by the earlier pre-connect rotation trap.
|
|
const updatedAt = new Date();
|
|
await db.transaction(async (tx) => {
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
const current = await tx
|
|
.select({ setup: chatEndpoints.setup })
|
|
.from(chatEndpoints)
|
|
.where(eq(chatEndpoints.id, endpoint.id))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current) throw notFound("Chat endpoint not found");
|
|
await tx
|
|
.update(chatEndpoints)
|
|
.set({
|
|
status: "draft",
|
|
healthMessage: null,
|
|
lastError: null,
|
|
setup: {
|
|
...current.setup,
|
|
step: "provider_setup",
|
|
webhookVerifiedAt: null,
|
|
},
|
|
updatedAt,
|
|
})
|
|
.where(eq(chatEndpoints.id, endpoint.id));
|
|
await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "draft",
|
|
enabled: false,
|
|
healthStatus: "unchecked",
|
|
healthMessage: null,
|
|
lastError: null,
|
|
healthCheckedAt: null,
|
|
updatedAt,
|
|
})
|
|
.where(eq(toolConnections.id, endpoint.connectionId));
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
});
|
|
}
|
|
await options.setupSecretCredentialPersistBarrier?.();
|
|
// Make credential refs the final required mutation. Once this returns,
|
|
// only the best-effort completion audit remains, so the request cannot
|
|
// fail after making the newly generated secret authoritative.
|
|
const persistedRefs = await persistCredentials(
|
|
endpoint,
|
|
{ ...existing, webhookSecret },
|
|
credentialLease,
|
|
actorUserId,
|
|
async (tx) => {
|
|
await tx.update(chatEndpoints).set({
|
|
setup: sql`${chatEndpoints.setup} || ${JSON.stringify({ credentialRotationId: rotationId, webhookVerifiedAt: null })}::jsonb`,
|
|
}).where(eq(chatEndpoints.id, endpoint.id));
|
|
},
|
|
);
|
|
committedWebhookSecretRef =
|
|
persistedRefs.find(
|
|
(ref) => ref.configPath === "credentials.webhookSecret",
|
|
) ?? null;
|
|
if (!committedWebhookSecretRef) {
|
|
throw new Error(
|
|
"GitHub webhook secret was not present after credential persistence",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
await writeSetupSecretActivity(db, {
|
|
companyId: endpoint.companyId,
|
|
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
|
|
action: "chat_endpoint.setup_secret_rotation_failed",
|
|
entityType: "tool_connection",
|
|
entityId: endpoint.connectionId,
|
|
details: auditDetails,
|
|
}).catch((auditError) => {
|
|
logger.warn(
|
|
{
|
|
endpointId: endpoint.id,
|
|
error: redactError(auditError),
|
|
rotationId,
|
|
},
|
|
"could not audit failed GitHub setup-secret rotation",
|
|
);
|
|
});
|
|
throw error;
|
|
}
|
|
await db
|
|
.transaction(async (tx) => {
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
await writeSetupSecretActivity(tx as unknown as Db, {
|
|
companyId: endpoint.companyId,
|
|
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
|
|
action: "chat_endpoint.setup_secret_generated",
|
|
entityType: "tool_connection",
|
|
entityId: endpoint.connectionId,
|
|
details: auditDetails,
|
|
});
|
|
await managementContext?.reauthorize();
|
|
await credentialLease.assertOwned(tx);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
{
|
|
endpointId: endpoint.id,
|
|
error: redactError(error),
|
|
rotationId,
|
|
},
|
|
"GitHub setup secret rotated but completion audit failed",
|
|
);
|
|
});
|
|
return { webhookSecret, credentialRotationId: rotationId };
|
|
},
|
|
{
|
|
beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier,
|
|
recoverCommittedResultAfterLeaseLoss: async () => {
|
|
const committedRef = committedWebhookSecretRef;
|
|
if (!committedRef) return false;
|
|
const authoritative = await db
|
|
.select({ refs: toolConnections.credentialSecretRefs })
|
|
.from(toolConnections)
|
|
.innerJoin(
|
|
companySecretBindings,
|
|
and(
|
|
eq(companySecretBindings.companyId, initial.endpoint.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(
|
|
companySecretBindings.targetId,
|
|
initial.endpoint.connectionId,
|
|
),
|
|
eq(companySecretBindings.configPath, committedRef.configPath),
|
|
eq(companySecretBindings.secretId, committedRef.secretId),
|
|
eq(
|
|
companySecretBindings.versionSelector,
|
|
String(committedRef.versionSelector ?? "latest"),
|
|
),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(toolConnections.companyId, initial.endpoint.companyId),
|
|
eq(toolConnections.id, initial.endpoint.connectionId),
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
return (
|
|
authoritative?.refs.some(
|
|
(ref) =>
|
|
credentialFingerprint([ref]) ===
|
|
credentialFingerprint([committedRef]),
|
|
) === true
|
|
);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return { generateSetupSecret };
|
|
}
|