mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix: close phase 4 connection QA gaps
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9d758eacda
commit
dea8e86bf4
12 files changed
+499
-66
No files matched your search
@@ -36,12 +36,12 @@ describe("connection grants phase 4 migration", () => {
|
||||
expect(migrationSql).toContain(`ON CONFLICT DO NOTHING`);
|
||||
});
|
||||
|
||||
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")`);
|
||||
it("remediates ambiguous personal-secret ownership without assigning an arbitrary owner", () => {
|
||||
expect(migrationSql).toContain(`phase4_ambiguous_personal_secrets`);
|
||||
expect(migrationSql).toContain(`count(DISTINCT g."subject_user_id") AS "owner_count"`);
|
||||
expect(migrationSql).toContain(`organization_grant."kind" <> 'user'`);
|
||||
expect(migrationSql).toContain(`"status" = 'needs_reauthorization'`);
|
||||
expect(migrationSql).toContain(`"health_status" = 'missing_secret'`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () =>
|
||||
)
|
||||
`;
|
||||
}
|
||||
return { companyId, secretId, ownerUserId: input.ownerUserIds[0]! };
|
||||
return { companyId, connectionId, secretId, ownerUserId: input.ownerUserIds[0]! };
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -133,20 +133,56 @@ describeEmbeddedPostgres("connection grants phase 4 executable migration", () =>
|
||||
|
||||
const ambiguous = await seedLegacyCredential({ ownerUserIds: ["alice", "bob"] });
|
||||
await rewindMigration();
|
||||
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
|
||||
/legacy ownership is ambiguous/,
|
||||
);
|
||||
await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined();
|
||||
expect(await sql<{
|
||||
status: string;
|
||||
is_default: boolean;
|
||||
credential_secret_refs: unknown[];
|
||||
}[]>`
|
||||
SELECT "status", "is_default", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${ambiguous.companyId}
|
||||
`).toEqual([
|
||||
{ status: "needs_reauthorization", is_default: false, credential_secret_refs: [] },
|
||||
{ status: "needs_reauthorization", is_default: false, credential_secret_refs: [] },
|
||||
]);
|
||||
expect(await sql<{ scope: string; owner_user_id: string | null }[]>`
|
||||
SELECT "scope", "owner_user_id"
|
||||
FROM "company_secrets"
|
||||
WHERE "id" = ${ambiguous.secretId}
|
||||
`).toEqual([{ scope: "company", owner_user_id: null }]);
|
||||
|
||||
await sql`DELETE FROM "company_secrets" WHERE "company_id" = ${ambiguous.companyId}`;
|
||||
await sql`DELETE FROM "companies" WHERE "id" = ${ambiguous.companyId}`;
|
||||
await seedLegacyCredential({
|
||||
const mixed = await seedLegacyCredential({
|
||||
ownerUserIds: ["carol"],
|
||||
includeOrganizationGrant: true,
|
||||
includeConnectionReference: true,
|
||||
});
|
||||
await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow(
|
||||
/legacy ownership is ambiguous/,
|
||||
);
|
||||
await rewindMigration();
|
||||
await expect(applyPendingMigrations(database.connectionString)).resolves.toBeUndefined();
|
||||
expect(await sql<{
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
health_status: string;
|
||||
credential_secret_refs: unknown[];
|
||||
}[]>`
|
||||
SELECT "status", "enabled", "health_status", "credential_secret_refs"
|
||||
FROM "tool_connections"
|
||||
WHERE "id" = ${mixed.connectionId}
|
||||
`).toEqual([{
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
health_status: "missing_secret",
|
||||
credential_secret_refs: [],
|
||||
}]);
|
||||
expect(await sql<{ status: string; credential_secret_refs: unknown[] }[]>`
|
||||
SELECT "status", "credential_secret_refs"
|
||||
FROM "connection_grants"
|
||||
WHERE "company_id" = ${mixed.companyId}
|
||||
ORDER BY "kind"
|
||||
`).toEqual([
|
||||
{ status: "needs_reauthorization", credential_secret_refs: [] },
|
||||
{ status: "needs_reauthorization", credential_secret_refs: [] },
|
||||
]);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
@@ -19,56 +19,104 @@ 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
|
||||
-- A legacy company-scoped credential can only become user-scoped when exactly
|
||||
-- one personal owner references it and no organization grant or connection
|
||||
-- also references it. Ambiguous rows fail closed in-place: remove every live
|
||||
-- reference, require reauthorization, and disable affected connections. This
|
||||
-- keeps the instance bootable while ensuring the credential is never assigned
|
||||
-- to an arbitrary user.
|
||||
DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets";
|
||||
--> statement-breakpoint
|
||||
CREATE TEMP TABLE "phase4_ambiguous_personal_secrets" ON COMMIT DROP AS
|
||||
WITH personal_secret_owners AS (
|
||||
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
|
||||
s."company_id",
|
||||
count(DISTINCT g."subject_user_id") AS "owner_count"
|
||||
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 (
|
||||
WHERE s."scope" = 'company'
|
||||
AND s."id"::text = personal_ref ->> 'secretId'
|
||||
GROUP BY s."id", s."company_id"
|
||||
)
|
||||
SELECT owners."secret_id", owners."company_id"
|
||||
FROM personal_secret_owners owners
|
||||
WHERE owners."owner_count" <> 1
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(other_g."credential_secret_refs") other_ref
|
||||
WHERE other_ref ->> 'secretId' = s."id"::text
|
||||
FROM "connection_grants" organization_grant
|
||||
CROSS JOIN LATERAL jsonb_array_elements(organization_grant."credential_secret_refs") organization_ref
|
||||
WHERE organization_grant."company_id" = owners."company_id"
|
||||
AND organization_grant."kind" <> 'user'
|
||||
AND organization_ref ->> 'secretId' = owners."secret_id"::text
|
||||
)
|
||||
LEFT JOIN "tool_connections" c
|
||||
ON c."company_id" = s."company_id"
|
||||
AND EXISTS (
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(c."credential_secret_refs") connection_ref
|
||||
WHERE connection_ref ->> 'secretId' = s."id"::text
|
||||
FROM "tool_connections" connection
|
||||
CROSS JOIN LATERAL jsonb_array_elements(connection."credential_secret_refs") connection_ref
|
||||
WHERE connection."company_id" = owners."company_id"
|
||||
AND connection_ref ->> 'secretId' = owners."secret_id"::text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH ambiguous_secrets AS (
|
||||
SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets"
|
||||
)
|
||||
UPDATE "connection_grants" grant_row
|
||||
SET
|
||||
"credential_secret_refs" = COALESCE((
|
||||
SELECT jsonb_agg(ref)
|
||||
FROM jsonb_array_elements(grant_row."credential_secret_refs") ref
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ambiguous_secrets ambiguous
|
||||
WHERE ambiguous."company_id" = grant_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_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 $$;
|
||||
), '[]'::jsonb),
|
||||
"status" = 'needs_reauthorization',
|
||||
"is_default" = false,
|
||||
"updated_at" = now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(grant_row."credential_secret_refs") ref
|
||||
JOIN ambiguous_secrets ambiguous
|
||||
ON ambiguous."company_id" = grant_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_id"::text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
WITH ambiguous_secrets AS (
|
||||
SELECT "secret_id", "company_id" FROM "phase4_ambiguous_personal_secrets"
|
||||
)
|
||||
UPDATE "tool_connections" connection_row
|
||||
SET
|
||||
"credential_secret_refs" = COALESCE((
|
||||
SELECT jsonb_agg(ref)
|
||||
FROM jsonb_array_elements(connection_row."credential_secret_refs") ref
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM ambiguous_secrets ambiguous
|
||||
WHERE ambiguous."company_id" = connection_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_id"::text
|
||||
)
|
||||
), '[]'::jsonb),
|
||||
"status" = 'draft',
|
||||
"enabled" = false,
|
||||
"health_status" = 'missing_secret',
|
||||
"health_message" = 'Legacy personal credential ownership was ambiguous. Reauthorize this connection.',
|
||||
"last_error" = 'oauth_reauthorization_required',
|
||||
"updated_at" = now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(connection_row."credential_secret_refs") ref
|
||||
JOIN ambiguous_secrets ambiguous
|
||||
ON ambiguous."company_id" = connection_row."company_id"
|
||||
AND ref ->> 'secretId' = ambiguous."secret_id"::text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS "phase4_ambiguous_personal_secrets";
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "user_secret_definitions" (
|
||||
"company_id", "key", "name", "description", "provider", "managed_mode",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
@@ -478,6 +478,47 @@ describeEmbeddedPostgres("access service", () => {
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
});
|
||||
|
||||
it("destroys every owned personal secret on suspension, including unreferenced secrets", async () => {
|
||||
const { company } = await createCompanyWithOwner(db);
|
||||
const member = await db.insert(companyMemberships).values({
|
||||
companyId: company.id,
|
||||
principalType: "user",
|
||||
principalId: `member-${randomUUID()}`,
|
||||
status: "active",
|
||||
membershipRole: "member",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const definition = await db.insert(userSecretDefinitions).values({
|
||||
companyId: company.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Personal OAuth token",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const ownedSecrets = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Orphaned personal credential",
|
||||
}).returning();
|
||||
const otherUserSecret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: "another-user",
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: "Another user's credential",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
|
||||
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
||||
|
||||
expect(await db.select().from(companySecrets).where(inArray(
|
||||
companySecrets.id,
|
||||
ownedSecrets.map((secret) => secret.id),
|
||||
))).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, otherUserSecret.id)))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows owner and admin role-default grants to manage environments", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
||||
@@ -912,6 +912,67 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("enforces delegation owner and manager permissions through the HTTP routes", 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");
|
||||
await grantBoardUser(db, company.id, "manager", ["tools:manage_connections"], "operator");
|
||||
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 nonOwner = await request(createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "member", "mallory"),
|
||||
))
|
||||
.post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`)
|
||||
.send({ agentId: agent.id });
|
||||
expect(nonOwner.status).toBe(403);
|
||||
|
||||
const created = await request(createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "member", "alice"),
|
||||
))
|
||||
.post(`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations`)
|
||||
.send({ agentId: agent.id });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toMatchObject({ grantId: grant.id, agentId: agent.id });
|
||||
|
||||
const unrelatedRevoke = await request(createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "member", "mallory"),
|
||||
)).delete(
|
||||
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
|
||||
);
|
||||
expect(unrelatedRevoke.status).toBe(403);
|
||||
|
||||
const managerRevoke = await request(createRouteApp(
|
||||
db,
|
||||
boardSessionActor(company.id, "operator", "manager"),
|
||||
)).delete(
|
||||
`/api/tool-connections/${connection.id}/grants/${grant.id}/delegations/${created.body.id}`,
|
||||
);
|
||||
expect(managerRevoke.status).toBe(200);
|
||||
expect(await db.select().from(connectionGrantDelegations).where(eq(
|
||||
connectionGrantDelegations.id,
|
||||
created.body.id,
|
||||
))).toHaveLength(0);
|
||||
expect(await db.select().from(toolAccessAuditEvents).where(eq(
|
||||
toolAccessAuditEvents.connectionId,
|
||||
connection.id,
|
||||
))).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ action: "connection_grant.delegated", actorId: "alice" }),
|
||||
expect.objectContaining({ action: "connection_grant.delegation_revoked", actorId: "manager" }),
|
||||
]));
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1038,6 +1099,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(inactiveOwner.status).toBe(403);
|
||||
expect(inactiveOwner.body).toMatchObject({
|
||||
code: "grant_owner_membership_inactive",
|
||||
remediation: { action: "restore_membership_or_reconnect" },
|
||||
});
|
||||
expect(inactiveOwner.body.error).toContain("not an active company member");
|
||||
});
|
||||
@@ -3714,7 +3776,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
issueId: issue.id,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
title: "Connect your account",
|
||||
title: "Connect your Slack to continue",
|
||||
});
|
||||
expect(interaction.payload).toMatchObject({ target: { href: started.authorizationUrl } });
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export function errorHandler(
|
||||
"connection_not_installed",
|
||||
"subject_not_permitted",
|
||||
"standing_delegation_required",
|
||||
"grant_owner_membership_inactive",
|
||||
]).has(typeof details?.code === "string" ? details.code : "");
|
||||
recordResponsibleUserDenialFromHttpError(req, details);
|
||||
if (err.status >= 500) {
|
||||
|
||||
@@ -50,16 +50,17 @@ export function accessService(db: Db) {
|
||||
eq(connectionGrants.subjectUserId, userId),
|
||||
));
|
||||
const grantIds = ownedGrants.map((grant) => grant.id);
|
||||
const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) =>
|
||||
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
))];
|
||||
const ownedSecrets = referencedSecretIds.length === 0 ? [] : await tx.select({
|
||||
// Membership removal is an ownership boundary, not merely a grant cleanup.
|
||||
// Destroy every personal secret owned by the departing user, including
|
||||
// orphaned rows that are not referenced by a current grant. Restricting the
|
||||
// lookup to grant refs leaves recoverable credentials on disk and lets them
|
||||
// reappear if the same identity is later reactivated.
|
||||
const ownedSecrets = await tx.select({
|
||||
id: companySecrets.id,
|
||||
}).from(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "user"),
|
||||
eq(companySecrets.ownerUserId, userId),
|
||||
inArray(companySecrets.id, referencedSecretIds),
|
||||
));
|
||||
const secretIds = ownedSecrets.map((secret) => secret.id);
|
||||
const removedDelegations = grantIds.length === 0 ? [] : await tx
|
||||
|
||||
@@ -2353,7 +2353,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
key: `connection:${input.connection.uid}:delegation:${input.ownerUserId}:${input.agentId}`,
|
||||
revisionId: input.connection.updatedAt.toISOString(),
|
||||
label: `Delegate ${input.connection.name}`,
|
||||
href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup`,
|
||||
href: `/${company?.issuePrefix ?? ""}/apps/${input.connection.id}/setup#personal-identity`,
|
||||
},
|
||||
};
|
||||
const [existing] = await db.select({ id: issueThreadInteractions.id }).from(issueThreadInteractions).where(and(
|
||||
|
||||
@@ -53,6 +53,8 @@ import type {
|
||||
UpdateToolMcpGateway,
|
||||
CreateToolTrustRuleFromActionRequest,
|
||||
ToolRedactedValueSummary,
|
||||
ConnectionGrant,
|
||||
ConnectionGrantDelegation,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -75,6 +77,10 @@ export type ToolPoliciesResponse = { policies: ToolPolicy[] };
|
||||
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
|
||||
export type ToolGalleryResponse = { apps: AppDefinition[] };
|
||||
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
|
||||
export type ConnectionGrantsResponse = {
|
||||
connection: { id: string; uid: string };
|
||||
grants: ConnectionGrant[];
|
||||
};
|
||||
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
|
||||
expiresAt?: string | Date | null;
|
||||
};
|
||||
@@ -306,6 +312,21 @@ export const toolsApi = {
|
||||
`/tool-connections/${connectionId}/installs`,
|
||||
{ installs },
|
||||
),
|
||||
// --- Identity grants ---
|
||||
listConnectionGrants: (connectionId: string) =>
|
||||
api.get<ConnectionGrantsResponse>(`/tool-connections/${connectionId}/grants`),
|
||||
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
|
||||
api.post<ConnectionGrantDelegation>(
|
||||
`/tool-connections/${connectionId}/grants/${grantId}/delegations`,
|
||||
{ agentId },
|
||||
),
|
||||
revokeConnectionGrantDelegation: (
|
||||
connectionId: string,
|
||||
grantId: string,
|
||||
delegationId: string,
|
||||
) => api.delete<ConnectionGrantDelegation>(
|
||||
`/tool-connections/${connectionId}/grants/${grantId}/delegations/${delegationId}`,
|
||||
),
|
||||
createConnection: (companyId: string, input: CreateToolConnectionInput) =>
|
||||
api.post<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
|
||||
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
|
||||
|
||||
@@ -29,6 +29,8 @@ export const queryKeys = {
|
||||
connection: (connectionId: string) => ["tools", "connection", connectionId] as const,
|
||||
connectionInstalls: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "installs"] as const,
|
||||
connectionGrants: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "grants"] as const,
|
||||
catalog: (connectionId: string) => ["tools", "connection", connectionId, "catalog"] as const,
|
||||
connectionActivity: (connectionId: string) =>
|
||||
["tools", "connection", connectionId, "activity"] as const,
|
||||
|
||||
@@ -21,6 +21,9 @@ const finishAppMock = vi.hoisted(() => vi.fn());
|
||||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
|
||||
const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
|
||||
const revokeConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
@@ -50,6 +53,14 @@ vi.mock("@/api/tools", () => ({
|
||||
archiveConnection: vi.fn(),
|
||||
refreshCatalog: (connectionId: string) => refreshCatalogMock(connectionId),
|
||||
startOAuth: (connectionId: string) => startOAuthMock(connectionId),
|
||||
listConnectionGrants: (connectionId: string) => listConnectionGrantsMock(connectionId),
|
||||
createConnectionGrantDelegation: (connectionId: string, grantId: string, agentId: string) =>
|
||||
createConnectionGrantDelegationMock(connectionId, grantId, agentId),
|
||||
revokeConnectionGrantDelegation: (
|
||||
connectionId: string,
|
||||
grantId: string,
|
||||
delegationId: string,
|
||||
) => revokeConnectionGrantDelegationMock(connectionId, grantId, delegationId),
|
||||
reconnectConnection: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -265,6 +276,16 @@ describe("AppDetail", () => {
|
||||
authorizationUrl: "https://example.test/oauth",
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [],
|
||||
});
|
||||
createConnectionGrantDelegationMock.mockResolvedValue({
|
||||
id: "delegation-1",
|
||||
grantId: "grant-user",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
revokeConnectionGrantDelegationMock.mockResolvedValue({});
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
getSessionMock.mockResolvedValue({
|
||||
user: { id: "user-1", name: "Dotta", image: null },
|
||||
@@ -993,4 +1014,60 @@ describe("AppDetail", () => {
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
expect(body).not.toContain(authorizationUrl);
|
||||
});
|
||||
|
||||
it("lets the personal grant owner delegate autonomous access to a named agent", async () => {
|
||||
getConnectionMock.mockResolvedValue(connection({ credentialPolicy: "per_user" }));
|
||||
listConnectionGrantsMock.mockResolvedValue({
|
||||
connection: { id: "conn-1", uid: "conn-1" },
|
||||
grants: [{
|
||||
id: "grant-user",
|
||||
companyId: "company-1",
|
||||
connectionId: "conn-1",
|
||||
kind: "user",
|
||||
subjectUserId: "user-1",
|
||||
providerTenant: null,
|
||||
credentialSecretRefs: [],
|
||||
status: "active",
|
||||
isDefault: false,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
revokedAt: null,
|
||||
revokedByAgentId: null,
|
||||
revokedByUserId: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
delegations: [],
|
||||
}],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
const trigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Allow autonomous access");
|
||||
expect(trigger).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const agentCheckbox = document.body.querySelector<HTMLElement>('[aria-label="Allow Coder"]');
|
||||
expect(agentCheckbox).toBeTruthy();
|
||||
await act(async () => {
|
||||
agentCheckbox?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Save")
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createConnectionGrantDelegationMock).toHaveBeenCalledWith(
|
||||
"conn-1",
|
||||
"grant-user",
|
||||
"agent-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,11 @@ export function AppDetail() {
|
||||
queryFn: () => toolsApi.getConnection(connectionId),
|
||||
enabled: !!connectionId && !!activeTab,
|
||||
});
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionGrants(connectionId),
|
||||
queryFn: () => toolsApi.listConnectionGrants(connectionId),
|
||||
enabled: !!connectionId && activeTab === "setup",
|
||||
});
|
||||
const installsQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionInstalls(connectionId),
|
||||
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
|
||||
@@ -104,7 +109,9 @@ export function AppDetail() {
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && (activeTab === "permissions" || activeTab === "activity"),
|
||||
enabled: !!selectedCompanyId && (
|
||||
activeTab === "setup" || activeTab === "permissions" || activeTab === "activity"
|
||||
),
|
||||
});
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(connectionId),
|
||||
@@ -120,7 +127,7 @@ export function AppDetail() {
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
enabled: activeTab === "activity",
|
||||
enabled: activeTab === "setup" || activeTab === "activity",
|
||||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
@@ -299,6 +306,53 @@ export function AppDetail() {
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidateGrants = () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
};
|
||||
|
||||
const replaceDelegations = useMutation({
|
||||
mutationFn: async ({
|
||||
grantId,
|
||||
currentDelegations,
|
||||
agentIds,
|
||||
}: {
|
||||
grantId: string;
|
||||
currentDelegations: Array<{ id: string; agentId: string }>;
|
||||
agentIds: string[];
|
||||
}) => {
|
||||
const desired = new Set(agentIds);
|
||||
const existing = new Map(currentDelegations.map((delegation) => [delegation.agentId, delegation]));
|
||||
await Promise.all([
|
||||
...currentDelegations
|
||||
.filter((delegation) => !desired.has(delegation.agentId))
|
||||
.map((delegation) => toolsApi.revokeConnectionGrantDelegation(
|
||||
connectionId,
|
||||
grantId,
|
||||
delegation.id,
|
||||
)),
|
||||
...agentIds
|
||||
.filter((agentId) => !existing.has(agentId))
|
||||
.map((agentId) => toolsApi.createConnectionGrantDelegation(connectionId, grantId, agentId)),
|
||||
]);
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Autonomous access saved",
|
||||
body: "Only the agents you selected can use your identity in autonomous runs.",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
invalidateGrants();
|
||||
pushToast({
|
||||
title: "Couldn't save autonomous access",
|
||||
body: error instanceof Error ? error.message : "Please try again.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const removeApp = useMutation({
|
||||
mutationFn: () => toolsApi.archiveConnection(connectionId),
|
||||
onSuccess: () => {
|
||||
@@ -472,6 +526,18 @@ export function AppDetail() {
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
oauthStartDisabled={startOAuth.isPending}
|
||||
onStartOAuth={() => startOAuth.mutate()}
|
||||
personalGrant={(grantsQuery.data?.grants ?? []).find((grant) =>
|
||||
grant.kind === "user" && grant.subjectUserId === sessionQuery.data?.user?.id
|
||||
)}
|
||||
agents={agents}
|
||||
delegationLoading={grantsQuery.isLoading || sessionQuery.isLoading || agentsQuery.isLoading}
|
||||
delegationError={grantsQuery.isError || sessionQuery.isError || agentsQuery.isError}
|
||||
delegationPending={replaceDelegations.isPending}
|
||||
onReplaceDelegations={(grant, agentIds) => replaceDelegations.mutate({
|
||||
grantId: grant.id,
|
||||
currentDelegations: grant.delegations ?? [],
|
||||
agentIds,
|
||||
})}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import type { ConnectionGrant, ToolCatalogEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { AgentMultiSelect, type AgentMultiSelectOption } from "@/components/AgentMultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
@@ -16,6 +17,12 @@ export function SetupPanel({
|
||||
configUpdateDisabled,
|
||||
onStartOAuth,
|
||||
oauthStartDisabled,
|
||||
personalGrant,
|
||||
agents = [],
|
||||
delegationLoading = false,
|
||||
delegationError = false,
|
||||
delegationPending = false,
|
||||
onReplaceDelegations,
|
||||
}: Pick<
|
||||
AppDetailSectionProps,
|
||||
"connection" | "galleryEntry"
|
||||
@@ -26,6 +33,12 @@ export function SetupPanel({
|
||||
configUpdateDisabled: boolean;
|
||||
onStartOAuth: () => void;
|
||||
oauthStartDisabled: boolean;
|
||||
personalGrant?: ConnectionGrant;
|
||||
agents?: AgentMultiSelectOption[];
|
||||
delegationLoading?: boolean;
|
||||
delegationError?: boolean;
|
||||
delegationPending?: boolean;
|
||||
onReplaceDelegations?: (grant: ConnectionGrant, agentIds: string[]) => void;
|
||||
}) {
|
||||
const description = galleryEntry?.description ?? null;
|
||||
const oauth = connection.config?.oauth;
|
||||
@@ -58,11 +71,76 @@ export function SetupPanel({
|
||||
onStart={onStartOAuth}
|
||||
/>
|
||||
)}
|
||||
{connection.credentialPolicy === "per_user" && onReplaceDelegations && (
|
||||
<PersonalDelegationSection
|
||||
grant={personalGrant}
|
||||
agents={agents}
|
||||
loading={delegationLoading}
|
||||
error={delegationError}
|
||||
pending={delegationPending}
|
||||
onReplace={onReplaceDelegations}
|
||||
/>
|
||||
)}
|
||||
<AppLifecycleSection connection={connection} disabled={appToggleDisabled} onToggle={onToggleApp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalDelegationSection({
|
||||
grant,
|
||||
agents,
|
||||
loading,
|
||||
error,
|
||||
pending,
|
||||
onReplace,
|
||||
}: {
|
||||
grant?: ConnectionGrant;
|
||||
agents: AgentMultiSelectOption[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
pending: boolean;
|
||||
onReplace: (grant: ConnectionGrant, agentIds: string[]) => void;
|
||||
}) {
|
||||
if (loading) {
|
||||
return <p id="personal-identity" className="text-sm text-muted-foreground">Loading autonomous access…</p>;
|
||||
}
|
||||
if (error) {
|
||||
return <p id="personal-identity" className="text-sm text-destructive">Couldn’t load autonomous access.</p>;
|
||||
}
|
||||
if (!grant || grant.status !== "active") return null;
|
||||
|
||||
const delegatedAgentIds = new Set((grant.delegations ?? []).map((delegation) => delegation.agentId));
|
||||
const delegationCount = delegatedAgentIds.size;
|
||||
|
||||
return (
|
||||
<section id="personal-identity" className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-bold text-foreground">Your identity</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Select the named agents that may use your identity in autonomous runs.
|
||||
</p>
|
||||
</div>
|
||||
<AgentMultiSelect
|
||||
agents={agents}
|
||||
selectedAgentIds={delegatedAgentIds}
|
||||
pending={pending}
|
||||
disabled={pending}
|
||||
triggerFullWidth={false}
|
||||
triggerSize="sm"
|
||||
triggerLabel={delegationCount === 0
|
||||
? "Allow autonomous access"
|
||||
: `${delegationCount} ${delegationCount === 1 ? "agent" : "agents"} allowed`}
|
||||
headerContent={(
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only selected agents can use this personal identity without you present.
|
||||
</p>
|
||||
)}
|
||||
onSave={(next) => onReplace(grant, [...next])}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PostHogConfigurationSection({ connection }: { connection: ToolConnection }) {
|
||||
const raw = connection.config?.methodConfig;
|
||||
const config = raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
|
||||
Reference in new issue
Block a user