mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
fix: close suspended credential access paths
Sweep delegated personal access when a membership is suspended and prune every surviving reference before destroying the user's secrets. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a156e06d01
commit
5ae50e9a71
2 files changed
+133
-10
No files matched your search
@@ -289,6 +289,9 @@ describeEmbeddedPostgres("access service", () => {
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: `OAuth ${randomUUID()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.update(toolConnections).set({
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).where(eq(toolConnections.id, connection.id));
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
@@ -307,6 +310,7 @@ describeEmbeddedPostgres("access service", () => {
|
||||
connectionId: connection.id,
|
||||
kind: "organization",
|
||||
isDefault: true,
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantMembers).values({
|
||||
companyId: company.id,
|
||||
@@ -323,11 +327,84 @@ describeEmbeddedPostgres("access service", () => {
|
||||
expect(await db.select().from(connectionGrantMembers)).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked" })]);
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, organizationGrant.id)))
|
||||
.toEqual([expect.objectContaining({ credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))
|
||||
.toEqual([expect.objectContaining({ credentialSecretRefs: [] })]);
|
||||
expect(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.reasonCode, "membership_removed")))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("revokes delegated personal connection access when membership is suspended", 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 agent = await db.insert(agents).values({
|
||||
companyId: company.id,
|
||||
name: "Delegated agent",
|
||||
role: "worker",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const application = await db.insert(toolApplications).values({
|
||||
companyId: company.id,
|
||||
applicationKey: `app-${randomUUID()}`,
|
||||
name: "Personal mail",
|
||||
type: "mcp",
|
||||
status: "active",
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const connection = await db.insert(toolConnections).values({
|
||||
companyId: company.id,
|
||||
applicationId: application.id,
|
||||
name: "Mail",
|
||||
uid: `mail-${randomUUID()}`,
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
credentialPolicy: "per_user",
|
||||
}).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 secret = await db.insert(companySecrets).values({
|
||||
companyId: company.id,
|
||||
scope: "user",
|
||||
ownerUserId: member.principalId,
|
||||
userSecretDefinitionId: definition.id,
|
||||
key: `oauth-${randomUUID()}`,
|
||||
name: `OAuth ${randomUUID()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const grant = await db.insert(connectionGrants).values({
|
||||
companyId: company.id,
|
||||
connectionId: connection.id,
|
||||
kind: "user",
|
||||
subjectUserId: member.principalId,
|
||||
credentialSecretRefs: [{ secretId: secret.id, configPath: "oauth.access_token" }],
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
await db.insert(connectionGrantDelegations).values({
|
||||
companyId: company.id,
|
||||
grantId: grant.id,
|
||||
agentId: agent.id,
|
||||
createdByUserId: member.principalId,
|
||||
});
|
||||
|
||||
await accessService(db).updateMember(company.id, member.id, { status: "suspended" });
|
||||
|
||||
expect(await db.select().from(connectionGrantDelegations)).toHaveLength(0);
|
||||
expect(await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id))).toHaveLength(0);
|
||||
expect(await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)))
|
||||
.toEqual([expect.objectContaining({ status: "revoked", credentialSecretRefs: [] })]);
|
||||
});
|
||||
|
||||
it("allows owner and admin role-default grants to manage environments", async () => {
|
||||
const { company, owner } = await createCompanyWithOwner(db);
|
||||
const access = accessService(db);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
toolAccessAuditEvents,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
@@ -49,9 +50,18 @@ export function accessService(db: Db) {
|
||||
eq(connectionGrants.subjectUserId, userId),
|
||||
));
|
||||
const grantIds = ownedGrants.map((grant) => grant.id);
|
||||
const secretIds = [...new Set(ownedGrants.flatMap((grant) =>
|
||||
const referencedSecretIds = [...new Set(ownedGrants.flatMap((grant) =>
|
||||
grant.credentialSecretRefs.map((ref) => ref.secretId),
|
||||
))];
|
||||
const ownedSecrets = referencedSecretIds.length === 0 ? [] : 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
|
||||
.delete(connectionGrantDelegations)
|
||||
.where(and(
|
||||
@@ -71,14 +81,41 @@ export function accessService(db: Db) {
|
||||
eq(connectionGrants.companyId, companyId),
|
||||
inArray(connectionGrants.id, grantIds),
|
||||
));
|
||||
if (secretIds.length > 0) {
|
||||
await tx.delete(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.scope, "user"),
|
||||
eq(companySecrets.ownerUserId, userId),
|
||||
inArray(companySecrets.id, secretIds),
|
||||
));
|
||||
}
|
||||
if (secretIds.length > 0) {
|
||||
const removedSecretIds = new Set(secretIds);
|
||||
const [grantRefs, connectionRefs] = await Promise.all([
|
||||
tx.select({
|
||||
id: connectionGrants.id,
|
||||
credentialSecretRefs: connectionGrants.credentialSecretRefs,
|
||||
}).from(connectionGrants).where(eq(connectionGrants.companyId, companyId)),
|
||||
tx.select({
|
||||
id: toolConnections.id,
|
||||
credentialSecretRefs: toolConnections.credentialSecretRefs,
|
||||
}).from(toolConnections).where(eq(toolConnections.companyId, companyId)),
|
||||
]);
|
||||
for (const grant of grantRefs) {
|
||||
const credentialSecretRefs = grant.credentialSecretRefs.filter(
|
||||
(ref) => !removedSecretIds.has(ref.secretId),
|
||||
);
|
||||
if (credentialSecretRefs.length !== grant.credentialSecretRefs.length) {
|
||||
await tx.update(connectionGrants).set({ credentialSecretRefs, updatedAt: now })
|
||||
.where(eq(connectionGrants.id, grant.id));
|
||||
}
|
||||
}
|
||||
for (const connection of connectionRefs) {
|
||||
const credentialSecretRefs = connection.credentialSecretRefs.filter(
|
||||
(ref) => !removedSecretIds.has(ref.secretId),
|
||||
);
|
||||
if (credentialSecretRefs.length !== connection.credentialSecretRefs.length) {
|
||||
await tx.update(toolConnections).set({ credentialSecretRefs, updatedAt: now })
|
||||
.where(eq(toolConnections.id, connection.id));
|
||||
}
|
||||
}
|
||||
await tx.delete(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
inArray(companySecrets.id, secretIds),
|
||||
));
|
||||
}
|
||||
await tx.delete(connectionGrantMembers).where(and(
|
||||
eq(connectionGrantMembers.companyId, companyId),
|
||||
@@ -851,12 +888,21 @@ export function accessService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status !== "suspended" &&
|
||||
nextStatus === "suspended"
|
||||
) {
|
||||
await sweepMemberConnectionAccess(tx, companyId, existing.principalId, now);
|
||||
}
|
||||
|
||||
return tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: new Date(),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
|
||||
Reference in new issue
Block a user