mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix: close connection delegation security gaps
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
dfb38d82d8
commit
9d758eacda
4 files changed
+334
-56
No files matched your search
@@ -1,31 +1,154 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const sql = fs.readFileSync(
|
||||
path.join(import.meta.dirname, "migrations/0229_sticky_nightcrawler.sql"),
|
||||
const MIGRATION_FILE = "0229_sticky_nightcrawler.sql";
|
||||
const migrationSql = fs.readFileSync(
|
||||
path.join(import.meta.dirname, "migrations", MIGRATION_FILE),
|
||||
"utf8",
|
||||
);
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function migrationHash() {
|
||||
return createHash("sha256").update(migrationSql).digest("hex");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("connection grants phase 4 migration", () => {
|
||||
it("adds named-agent standing delegations", () => {
|
||||
expect(sql).toContain(`CREATE TABLE IF NOT EXISTS "connection_grant_delegations"`);
|
||||
expect(sql).toContain(`"connection_grant_delegations_grant_agent_uq"`);
|
||||
expect(sql).toContain(`FOREIGN KEY ("company_id","grant_id")`);
|
||||
it("adds replay-safe named-agent standing delegations", () => {
|
||||
expect(migrationSql).toContain(`CREATE TABLE IF NOT EXISTS "connection_grant_delegations"`);
|
||||
expect(migrationSql).toContain(`"connection_grant_delegations_grant_agent_uq"`);
|
||||
expect(migrationSql).toContain(`FOREIGN KEY ("company_id","grant_id")`);
|
||||
expect(migrationSql).toContain(`DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk"`);
|
||||
expect(migrationSql).toContain(`CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx"`);
|
||||
expect(migrationSql).toContain(`ON CONFLICT DO NOTHING`);
|
||||
});
|
||||
|
||||
it("can replay after schema state moves ahead of the migration journal", () => {
|
||||
expect(sql).toContain(`DROP CONSTRAINT IF EXISTS "connection_grant_delegations_company_grant_fk"`);
|
||||
expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx"`);
|
||||
expect(sql).toContain(`CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq"`);
|
||||
expect(sql).toContain(`ON CONFLICT DO NOTHING`);
|
||||
});
|
||||
|
||||
it("moves personal grant secrets to their owner's user scope", () => {
|
||||
expect(sql).toContain(`g."kind" = 'user'`);
|
||||
expect(sql).toContain(`"scope" = 'user'`);
|
||||
expect(sql).toContain(`"owner_user_id" = owner_map."owner_user_id"`);
|
||||
expect(sql).toContain(`"user_secret_definition_id" = d."id"`);
|
||||
expect(sql).toContain(`HAVING count(DISTINCT g."subject_user_id") = 1`);
|
||||
it("fails ambiguous personal-secret ownership closed with reauthorization guidance", () => {
|
||||
expect(migrationSql).toContain(`Cannot migrate personal connection credential`);
|
||||
expect(migrationSql).toContain(`Reauthorize the affected personal connection grants`);
|
||||
expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") <> 1`);
|
||||
expect(migrationSql).toContain(`other_g."kind" <> 'user'`);
|
||||
expect(migrationSql).toContain(`jsonb_array_elements(c."credential_secret_refs")`);
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("connection grants phase 4 executable migration", () => {
|
||||
it("converts one owner and rejects two-owner or mixed legacy references", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-connection-phase4-migration-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
|
||||
async function rewindMigration() {
|
||||
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash()}`;
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: [MIGRATION_FILE],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedLegacyCredential(input: {
|
||||
ownerUserIds: string[];
|
||||
includeOrganizationGrant?: boolean;
|
||||
includeConnectionReference?: boolean;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const applicationId = randomUUID();
|
||||
const connectionId = randomUUID();
|
||||
const secretId = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
||||
VALUES (${companyId}, ${`Phase 4 ${companyId}`}, ${`P${companyId.slice(0, 5)}`})
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "tool_applications" ("id", "company_id", "name", "type")
|
||||
VALUES (${applicationId}, ${companyId}, ${`App ${applicationId}`}, 'mcp_http')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "tool_connections" (
|
||||
"id", "company_id", "application_id", "name", "uid", "transport", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${connectionId}, ${companyId}, ${applicationId}, ${`Connection ${connectionId}`},
|
||||
${`connection-${connectionId}`}, 'mcp_remote',
|
||||
${sql.json(input.includeConnectionReference ? [{ secretId, version: "latest" }] : [])}
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "company_secrets" ("id", "company_id", "key", "name", "scope")
|
||||
VALUES (${secretId}, ${companyId}, ${`secret-${secretId}`}, ${`Secret ${secretId}`}, 'company')
|
||||
`;
|
||||
for (const ownerUserId of input.ownerUserIds) {
|
||||
await sql`
|
||||
INSERT INTO "connection_grants" (
|
||||
"company_id", "connection_id", "kind", "subject_user_id", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${companyId}, ${connectionId}, 'user', ${ownerUserId},
|
||||
${sql.json([{ secretId, version: "latest" }])}
|
||||
)
|
||||
`;
|
||||
}
|
||||
if (input.includeOrganizationGrant) {
|
||||
await sql`
|
||||
INSERT INTO "connection_grants" (
|
||||
"company_id", "connection_id", "kind", "credential_secret_refs"
|
||||
) VALUES (
|
||||
${companyId}, ${connectionId}, 'organization',
|
||||
${sql.json([{ secretId, version: "latest" }])}
|
||||
)
|
||||
`;
|
||||
}
|
||||
return { companyId, secretId, ownerUserId: input.ownerUserIds[0]! };
|
||||
}
|
||||
|
||||
try {
|
||||
const valid = await seedLegacyCredential({ ownerUserIds: ["alice"] });
|
||||
await rewindMigration();
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
const converted = await sql<{
|
||||
scope: string;
|
||||
owner_user_id: string | null;
|
||||
user_secret_definition_id: string | null;
|
||||
}[]>`
|
||||
SELECT "scope", "owner_user_id", "user_secret_definition_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${valid.secretId}
|
||||
`;
|
||||
expect(converted).toEqual([{
|
||||
scope: "user",
|
||||
owner_user_id: valid.ownerUserId,
|
||||
user_secret_definition_id: expect.any(String),
|
||||
}]);
|
||||
|
||||
const ambiguous = await seedLegacyCredential({ ownerUserIds: ["alice", "bob"] });
|
||||
await rewindMigration();
|
||||
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
|
||||
/legacy ownership is ambiguous/,
|
||||
);
|
||||
|
||||
await sql`DELETE FROM "company_secrets" WHERE "company_id" = ${ambiguous.companyId}`;
|
||||
await sql`DELETE FROM "companies" WHERE "id" = ${ambiguous.companyId}`;
|
||||
await seedLegacyCredential({
|
||||
ownerUserIds: ["carol"],
|
||||
includeOrganizationGrant: true,
|
||||
includeConnectionReference: true,
|
||||
});
|
||||
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
|
||||
/legacy ownership is ambiguous/,
|
||||
);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
@@ -19,6 +19,57 @@ ALTER TABLE "connection_grant_delegations" ADD CONSTRAINT "connection_grant_dele
|
||||
CREATE INDEX IF NOT EXISTS "connection_grant_delegations_company_agent_idx" ON "connection_grant_delegations" USING btree ("company_id","agent_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "connection_grant_delegations_grant_agent_uq" ON "connection_grant_delegations" USING btree ("grant_id","agent_id");
|
||||
--> statement-breakpoint
|
||||
DO $$
|
||||
DECLARE
|
||||
ambiguous_secret record;
|
||||
BEGIN
|
||||
SELECT
|
||||
s."id" AS "secret_id",
|
||||
count(DISTINCT g."subject_user_id") AS "personal_owner_count",
|
||||
count(*) FILTER (WHERE other_g."id" IS NOT NULL) AS "organization_reference_count",
|
||||
count(*) FILTER (WHERE c."id" IS NOT NULL) AS "connection_reference_count"
|
||||
INTO ambiguous_secret
|
||||
FROM "company_secrets" s
|
||||
JOIN "connection_grants" g
|
||||
ON g."company_id" = s."company_id"
|
||||
AND g."kind" = 'user'
|
||||
CROSS JOIN LATERAL jsonb_array_elements(g."credential_secret_refs") personal_ref
|
||||
LEFT JOIN "connection_grants" other_g
|
||||
ON other_g."company_id" = s."company_id"
|
||||
AND other_g."kind" <> 'user'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(other_g."credential_secret_refs") other_ref
|
||||
WHERE other_ref ->> 'secretId' = s."id"::text
|
||||
)
|
||||
LEFT JOIN "tool_connections" c
|
||||
ON c."company_id" = s."company_id"
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(c."credential_secret_refs") connection_ref
|
||||
WHERE connection_ref ->> 'secretId' = s."id"::text
|
||||
)
|
||||
WHERE s."id"::text = personal_ref ->> 'secretId'
|
||||
AND s."scope" = 'company'
|
||||
GROUP BY s."id"
|
||||
HAVING count(DISTINCT g."subject_user_id") <> 1
|
||||
OR count(*) FILTER (WHERE other_g."id" IS NOT NULL) > 0
|
||||
OR count(*) FILTER (WHERE c."id" IS NOT NULL) > 0
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
RAISE EXCEPTION 'Cannot migrate personal connection credential %: legacy ownership is ambiguous', ambiguous_secret."secret_id"
|
||||
USING ERRCODE = '23514',
|
||||
DETAIL = format(
|
||||
'personal owners=%s, organization grant references=%s, connection references=%s',
|
||||
ambiguous_secret."personal_owner_count",
|
||||
ambiguous_secret."organization_reference_count",
|
||||
ambiguous_secret."connection_reference_count"
|
||||
),
|
||||
HINT = 'Reauthorize the affected personal connection grants with one credential per user before retrying this migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "user_secret_definitions" (
|
||||
"company_id", "key", "name", "description", "provider", "managed_mode",
|
||||
"provider_config_id", "provider_metadata", "created_by_agent_id", "created_by_user_id"
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
toolRuntimeSlots,
|
||||
toolStdioCommandTemplates,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { getConnectableAppDefinition } from "@paperclipai/shared";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
@@ -879,6 +879,8 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
it("allows only the personal grant owner to create named-agent delegations and audits revocation", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
await grantBoardUser(db, company.id, "alice", [], "member");
|
||||
await grantBoardUser(db, company.id, "mallory", [], "member");
|
||||
const { connection } = await createBrokerConnection(db, company.id);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
@@ -910,6 +912,88 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("serializes delegation creation behind membership removal so reauthorization cannot revive stale consent", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
await grantBoardUser(db, company.id, "alice", [], "member");
|
||||
const { connection } = await createBrokerConnection(db, company.id);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: "alice",
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const serviceDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
||||
const service = createTestToolAccessService(serviceDb);
|
||||
const removalDb = createDb(tempDb!.connectionString, { maxConnections: 1 });
|
||||
let releaseRemoval!: () => void;
|
||||
const removalMayCommit = new Promise<void>((resolve) => {
|
||||
releaseRemoval = resolve;
|
||||
});
|
||||
let membershipLocked!: () => void;
|
||||
const membershipIsLocked = new Promise<void>((resolve) => {
|
||||
membershipLocked = resolve;
|
||||
});
|
||||
|
||||
const removal = removalDb.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
SELECT "id"
|
||||
FROM "company_memberships"
|
||||
WHERE "company_id" = ${company.id}
|
||||
AND "principal_type" = 'user'
|
||||
AND "principal_id" = 'alice'
|
||||
FOR UPDATE
|
||||
`);
|
||||
await tx.execute(sql`
|
||||
UPDATE "connection_grants"
|
||||
SET "status" = 'revoked', "revoked_at" = now(), "updated_at" = now()
|
||||
WHERE "id" = ${grant.id}
|
||||
`);
|
||||
await tx.execute(sql`
|
||||
DELETE FROM "connection_grant_delegations"
|
||||
WHERE "company_id" = ${company.id} AND "grant_id" = ${grant.id}
|
||||
`);
|
||||
membershipLocked();
|
||||
await removalMayCommit;
|
||||
await tx.execute(sql`
|
||||
UPDATE "company_memberships"
|
||||
SET "status" = 'suspended', "updated_at" = now()
|
||||
WHERE "company_id" = ${company.id}
|
||||
AND "principal_type" = 'user'
|
||||
AND "principal_id" = 'alice'
|
||||
`);
|
||||
});
|
||||
|
||||
await membershipIsLocked;
|
||||
let creationSettled = false;
|
||||
const creation = service.createConnectionGrantDelegation(connection.id, grant.id, agent.id, "alice")
|
||||
.then(
|
||||
(value) => ({ value, error: null }),
|
||||
(error: unknown) => ({ value: null, error }),
|
||||
)
|
||||
.finally(() => {
|
||||
creationSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(creationSettled).toBe(false);
|
||||
releaseRemoval();
|
||||
await removal;
|
||||
const creationResult = await creation;
|
||||
expect(creationResult.error).toEqual(expect.objectContaining({
|
||||
message: "Only an active company member can delegate their personal grant",
|
||||
}));
|
||||
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(and(
|
||||
eq(companyMemberships.companyId, company.id),
|
||||
eq(companyMemberships.principalId, "alice"),
|
||||
));
|
||||
await db.update(connectionGrants).set({ status: "active", revokedAt: null }).where(eq(connectionGrants.id, grant.id));
|
||||
expect(await db.select().from(connectionGrantDelegations).where(eq(connectionGrantDelegations.grantId, grant.id)))
|
||||
.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails autonomous token minting closed until the named agent has a standing delegation", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
||||
@@ -8219,42 +8219,62 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
ownerUserId: string,
|
||||
) => {
|
||||
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),
|
||||
eq(connectionGrants.kind, "user"),
|
||||
eq(connectionGrants.subjectUserId, ownerUserId),
|
||||
eq(connectionGrants.status, "active"),
|
||||
)).limit(1);
|
||||
if (!grant) throw forbidden("Only the active personal grant owner can create a delegation");
|
||||
const [targetAgent] = await db.select({ id: agents.id }).from(agents).where(and(
|
||||
eq(agents.id, agentId),
|
||||
eq(agents.companyId, connection.companyId),
|
||||
)).limit(1);
|
||||
if (!targetAgent) throw notFound("Agent not found");
|
||||
const [existing] = await db.select().from(connectionGrantDelegations).where(and(
|
||||
eq(connectionGrantDelegations.grantId, grant.id),
|
||||
eq(connectionGrantDelegations.agentId, agentId),
|
||||
)).limit(1);
|
||||
if (existing) return existing;
|
||||
const [delegation] = await db.insert(connectionGrantDelegations).values({
|
||||
companyId: connection.companyId,
|
||||
grantId: grant.id,
|
||||
agentId,
|
||||
createdByUserId: ownerUserId,
|
||||
}).returning();
|
||||
await db.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 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"),
|
||||
)).limit(1);
|
||||
if (!grant) throw forbidden("Only the active personal grant owner can create a delegation");
|
||||
const [targetAgent] = await tx.select({ id: agents.id }).from(agents).where(and(
|
||||
eq(agents.id, agentId),
|
||||
eq(agents.companyId, connection.companyId),
|
||||
)).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!;
|
||||
});
|
||||
return delegation!;
|
||||
},
|
||||
|
||||
revokeConnectionGrantDelegation: async (idOrUid: string, grantId: string, delegationId: string, actor?: ActorInfo) => {
|
||||
|
||||
Reference in new issue
Block a user