mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
perf(connections): scope profile rule and binding reads (#17589)
Read current company/agent profile bindings and rules matching the exact catalog rows being evaluated. Preserve all five selector kinds, null application matches, exclusions, and the existing access evaluator without a cross-call cache. Verified with full build and typecheck, 652 affected tests across ten suites, all 18 strengthened effective-assignment tests, exclusion mutation and byte counterfactuals, complete exact-head CI, and Greptile 5/5 Medium with no unresolved comments. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b7dc594bfa
commit
bd77ffe78d
3 files changed
+163
-32
No files matched your search
@@ -135,6 +135,13 @@ authority once. Different task bindings must both pass. The batch is discarded
|
||||
after that delivery; later events still recheck grants, membership, task
|
||||
existence, and company boundaries.
|
||||
|
||||
Connection-assignment checks select profile bindings for the current company or
|
||||
agent and read rules that can match the selected connection's catalog rows.
|
||||
All five selector kinds, null application matching, exclusions, and profile
|
||||
precedence retain their existing evaluator. Rule filtering uses the catalog
|
||||
values already read by that check, so a later catalog change cannot drop an
|
||||
exclusion from the decision being evaluated. There is no cross-call cache.
|
||||
|
||||
Filesystem preparation uses current task/run and selected-configuration checks.
|
||||
Historical root-reader authorization has been retired; no retained-reader batch is
|
||||
loaded at admission or publication. Workspace-record disclosure still uses task
|
||||
|
||||
@@ -169,6 +169,122 @@ describeDatabase("effective tool assignments", () => {
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("omits unrelated rules and bindings while retaining current connection decisions", async () => {
|
||||
const f = await fixture();
|
||||
const [otherApplication] = await db.insert(toolApplications).values({ companyId: f.company.id, name: "Other app", type: "mcp_http" }).returning();
|
||||
const [otherConnection] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: otherApplication.id,
|
||||
name: "Other connection", uid: randomUUID(), transport: "mcp_remote" }).returning();
|
||||
const [otherTool] = await db.insert(toolCatalogEntries).values({ companyId: f.company.id, applicationId: otherApplication.id,
|
||||
connectionId: otherConnection.id, name: "other", toolName: "other", versionHash: "other", riskLevel: "destructive" }).returning();
|
||||
await db.insert(toolProfileEntries).values(Array.from({ length: 200 }, (_, index) => [
|
||||
{ selectorType: "application" as const, applicationId: otherApplication.id },
|
||||
{ selectorType: "connection" as const, connectionId: otherConnection.id },
|
||||
{ selectorType: "catalog_entry" as const, catalogEntryId: otherTool.id },
|
||||
{ selectorType: "tool_name" as const, toolName: `unrelated_${index}` },
|
||||
{ selectorType: "risk_level" as const, riskLevel: "destructive" as const },
|
||||
].map(entry => ({ companyId: f.company.id, profileId: f.profile.id, effect: index % 2 ? "include" as const : "exclude" as const, ...entry }))).flat());
|
||||
await db.insert(toolProfileBindings).values(Array.from({ length: 200 }, () => ({ companyId: f.company.id,
|
||||
profileId: f.profile.id, targetType: "agent" as const, targetId: randomUUID() })));
|
||||
const [include, exclude] = await db.insert(toolProfileEntries).values([
|
||||
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "connection", effect: "include", connectionId: f.connection.id },
|
||||
{ companyId: f.company.id, profileId: f.profile.id, selectorType: "catalog_entry", effect: "exclude", catalogEntryId: f.write.id },
|
||||
]).returning();
|
||||
const service = toolAccessService(db), before = await compare(f);
|
||||
const savedRules = await db.select().from(toolProfileEntries);
|
||||
const events: Array<Record<string, unknown>> = [], now = Date.now();
|
||||
const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event));
|
||||
installDatabaseResponseCapture(db, { toolProfileEntries, toolProfileBindings }, capture);
|
||||
try {
|
||||
const scoped = await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id]);
|
||||
capture.flush();
|
||||
const rules = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1" && event.table === "tool_profile_entries");
|
||||
const bindings = events.filter(event => event.event === "paperclip.db_response_capture.entry.v1" && event.table === "tool_profile_bindings");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(Number(rules[0].responseValueBytes)).toBeLessThan(4096);
|
||||
expect(rules[0]).toMatchObject({ calls: 1, rows: 2 });
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0]).toMatchObject({ calls: 1, rows: 1 });
|
||||
expect(scoped).toEqual({ allowedConnectionIds: [f.connection.id], installedConnections: before.full.installedConnections });
|
||||
expect(await db.select().from(toolProfileEntries)).toEqual(savedRules);
|
||||
await db.update(toolProfileEntries).set({ effect: "exclude" }).where(eq(toolProfileEntries.id, include.id));
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, include.id));
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
expect((await db.select().from(toolProfileEntries).where(eq(toolProfileEntries.id, exclude.id)))[0]).toEqual(exclude);
|
||||
} finally { capture.flush(); }
|
||||
});
|
||||
|
||||
it("preserves null application matches and exact special-character tool names", async () => {
|
||||
const f = await fixture(), toolName = "quoted' path\\tool_🙂";
|
||||
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
||||
await db.update(toolCatalogEntries).set({ applicationId: null, toolName }).where(eq(toolCatalogEntries.id, f.read.id));
|
||||
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
||||
selectorType: "application", applicationId: null, effect: "exclude" }).returning();
|
||||
const service = toolAccessService(db);
|
||||
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
await db.update(toolProfileEntries).set({ selectorType: "tool_name", toolName }).where(eq(toolProfileEntries.id, rule.id));
|
||||
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
await db.update(toolProfileEntries).set({ toolName: `${toolName}different` }).where(eq(toolProfileEntries.id, rule.id));
|
||||
expect((await compare(f)).assignments.allowedTools.map(tool => tool.id)).toEqual([f.read.id]);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
||||
});
|
||||
|
||||
it.each(["application", "connection", "catalog_entry", "tool_name", "risk_level"] as const)("denies the connection when a %s exclusion blocks its only active tool", async selectorType => {
|
||||
const f = await fixture();
|
||||
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
||||
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
const [rule] = await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id, selectorType, effect: "exclude",
|
||||
...(selectorType === "application" ? { applicationId: f.application.id } : {}),
|
||||
...(selectorType === "connection" ? { connectionId: f.connection.id } : {}),
|
||||
...(selectorType === "catalog_entry" ? { catalogEntryId: f.read.id } : {}),
|
||||
...(selectorType === "tool_name" ? { toolName: f.read.toolName } : {}),
|
||||
...(selectorType === "risk_level" ? { riskLevel: "read" as const } : {}),
|
||||
}).returning();
|
||||
const service = toolAccessService(db);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
expect((await compare(f)).assignments.allowedTools).toEqual([]);
|
||||
await db.delete(toolProfileEntries).where(eq(toolProfileEntries.id, rule.id));
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
||||
});
|
||||
|
||||
it("filters rules against the catalog rows being evaluated when the catalog changes during the read", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(toolProfiles).set({ defaultAction: "allow" }).where(eq(toolProfiles.id, f.profile.id));
|
||||
await db.update(toolCatalogEntries).set({ status: "removed" }).where(eq(toolCatalogEntries.id, f.write.id));
|
||||
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profile.id,
|
||||
selectorType: "tool_name", toolName: f.read.toolName, effect: "exclude" });
|
||||
let changed = false;
|
||||
const changingDb = new Proxy(db, { get(target, key) {
|
||||
if (key !== "select") return Reflect.get(target, key);
|
||||
return (...fields: Parameters<Db["select"]>) => {
|
||||
const builder = target.select(...fields), from = builder.from.bind(builder);
|
||||
builder.from = ((...args: Parameters<typeof from>) => {
|
||||
const selection = from(...args);
|
||||
if (args[0] === toolCatalogEntries) {
|
||||
const execute = selection.execute.bind(selection);
|
||||
selection.execute = async () => {
|
||||
const rows = await execute();
|
||||
if (!changed) {
|
||||
changed = true;
|
||||
await db.update(toolCatalogEntries).set({ toolName: "changed-after-read" }).where(eq(toolCatalogEntries.id, f.read.id));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
}
|
||||
return selection;
|
||||
}) as typeof builder.from;
|
||||
return builder;
|
||||
};
|
||||
} });
|
||||
const service = toolAccessService(changingDb);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([]);
|
||||
expect(changed).toBe(true);
|
||||
expect((await service.getConnectionAssignmentsForAgent(f.company.id, f.agent.id, [f.connection.id])).allowedConnectionIds).toEqual([f.connection.id]);
|
||||
});
|
||||
|
||||
it("keeps other companies' catalog rows out of scoped assignments", async () => {
|
||||
const f = await fixture();
|
||||
const other = await fixture();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, or, sql, type AnyColumn } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, toolCatalogEntries, toolProfileBindings, toolProfileEntries, toolProfiles } from "@paperclipai/db";
|
||||
import type { ToolCatalogEntry, ToolConnection, ToolProfileEffectiveSummary, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
@@ -10,6 +10,12 @@ type ProfileAssignmentDependencies = {
|
||||
toCatalogEntry(row: typeof toolCatalogEntries.$inferSelect): ToolCatalogEntry;
|
||||
};
|
||||
|
||||
function catalogValuesContain(column: AnyColumn, values: Array<string | null>) {
|
||||
const distinct = [...new Set(values)];
|
||||
return sql`(${JSON.stringify(distinct.filter(value => value !== null))}::jsonb ? ${column}::text
|
||||
or (${distinct.includes(null)} and ${column} is null))`;
|
||||
}
|
||||
|
||||
function resolveCatalogAccess(
|
||||
profiles: Array<Pick<typeof toolProfiles.$inferSelect, "id" | "defaultAction">>,
|
||||
entries: Array<ProfileEntryMatch & { profileId: string; effect: string }>,
|
||||
@@ -69,20 +75,17 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
|
||||
agentId,
|
||||
"Tool profile effective agent",
|
||||
);
|
||||
const allBindings = await db
|
||||
const matchingBindings = await db
|
||||
.select()
|
||||
.from(toolProfileBindings)
|
||||
.where(eq(toolProfileBindings.companyId, companyId))
|
||||
.where(and(eq(toolProfileBindings.companyId, companyId), or(
|
||||
and(eq(toolProfileBindings.targetType, "company"), eq(toolProfileBindings.targetId, companyId)),
|
||||
and(eq(toolProfileBindings.targetType, "agent"), eq(toolProfileBindings.targetId, agentId)),
|
||||
)))
|
||||
.orderBy(
|
||||
asc(toolProfileBindings.priority),
|
||||
asc(toolProfileBindings.createdAt),
|
||||
);
|
||||
const matchingBindings = allBindings.filter(
|
||||
(binding) =>
|
||||
(binding.targetType === "company" &&
|
||||
binding.targetId === companyId) ||
|
||||
(binding.targetType === "agent" && binding.targetId === agentId),
|
||||
);
|
||||
const candidateProfileIds = profileIdsInBindingOrder(matchingBindings);
|
||||
if (candidateProfileIds.length === 0) return { bindings: [], activeProfiles: [] };
|
||||
const candidateProfiles = await db
|
||||
@@ -194,29 +197,34 @@ export function createToolProfileResolver(db: Db, { assertOptionalAgent, resolve
|
||||
if (activeProfiles.length === 0 || connectionIds.length === 0) {
|
||||
return { allowedConnectionIds: [], installedConnections: await resolveInstalledConnectionsForAgent(companyId, agentId) };
|
||||
}
|
||||
const [entries, catalog] = await Promise.all([
|
||||
db.select({
|
||||
profileId: toolProfileEntries.profileId,
|
||||
effect: toolProfileEntries.effect,
|
||||
selectorType: toolProfileEntries.selectorType,
|
||||
applicationId: toolProfileEntries.applicationId,
|
||||
connectionId: toolProfileEntries.connectionId,
|
||||
catalogEntryId: toolProfileEntries.catalogEntryId,
|
||||
toolName: toolProfileEntries.toolName,
|
||||
riskLevel: toolProfileEntries.riskLevel,
|
||||
}).from(toolProfileEntries).where(and(
|
||||
eq(toolProfileEntries.companyId, companyId), inArray(toolProfileEntries.profileId, activeProfiles.map(profile => profile.id)),
|
||||
)),
|
||||
db.select({
|
||||
id: toolCatalogEntries.id,
|
||||
applicationId: toolCatalogEntries.applicationId,
|
||||
connectionId: toolCatalogEntries.connectionId,
|
||||
toolName: toolCatalogEntries.toolName,
|
||||
riskLevel: toolCatalogEntries.riskLevel,
|
||||
}).from(toolCatalogEntries).where(and(
|
||||
eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.status, "active"), inArray(toolCatalogEntries.connectionId, connectionIds),
|
||||
)),
|
||||
]);
|
||||
const catalog = await db.select({
|
||||
id: toolCatalogEntries.id,
|
||||
applicationId: toolCatalogEntries.applicationId,
|
||||
connectionId: toolCatalogEntries.connectionId,
|
||||
toolName: toolCatalogEntries.toolName,
|
||||
riskLevel: toolCatalogEntries.riskLevel,
|
||||
}).from(toolCatalogEntries).where(and(
|
||||
eq(toolCatalogEntries.companyId, companyId), eq(toolCatalogEntries.status, "active"), inArray(toolCatalogEntries.connectionId, connectionIds),
|
||||
));
|
||||
const entries = catalog.length === 0 ? [] : await db.select({
|
||||
profileId: toolProfileEntries.profileId,
|
||||
effect: toolProfileEntries.effect,
|
||||
selectorType: toolProfileEntries.selectorType,
|
||||
applicationId: toolProfileEntries.applicationId,
|
||||
connectionId: toolProfileEntries.connectionId,
|
||||
catalogEntryId: toolProfileEntries.catalogEntryId,
|
||||
toolName: toolProfileEntries.toolName,
|
||||
riskLevel: toolProfileEntries.riskLevel,
|
||||
}).from(toolProfileEntries).where(and(
|
||||
eq(toolProfileEntries.companyId, companyId), inArray(toolProfileEntries.profileId, activeProfiles.map(profile => profile.id)),
|
||||
or(
|
||||
and(eq(toolProfileEntries.selectorType, "application"), catalogValuesContain(toolProfileEntries.applicationId, catalog.map(entry => entry.applicationId))),
|
||||
and(eq(toolProfileEntries.selectorType, "connection"), catalogValuesContain(toolProfileEntries.connectionId, catalog.map(entry => entry.connectionId))),
|
||||
and(eq(toolProfileEntries.selectorType, "catalog_entry"), catalogValuesContain(toolProfileEntries.catalogEntryId, catalog.map(entry => entry.id))),
|
||||
and(eq(toolProfileEntries.selectorType, "tool_name"), catalogValuesContain(toolProfileEntries.toolName, catalog.map(entry => entry.toolName))),
|
||||
and(eq(toolProfileEntries.selectorType, "risk_level"), catalogValuesContain(toolProfileEntries.riskLevel, catalog.map(entry => entry.riskLevel))),
|
||||
),
|
||||
));
|
||||
const { allowedCatalogIds } = resolveCatalogAccess(activeProfiles, entries, catalog);
|
||||
return {
|
||||
allowedConnectionIds: [...new Set(catalog.filter(entry => allowedCatalogIds.has(entry.id)).map(entry => entry.connectionId))],
|
||||
|
||||
Reference in new issue
Block a user