perf(apps): scope connection profile configuration reads (#18687)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Apps Permissions page reads saved access profiles for one
connection.
> - The page fetched every company profile, rule, and binding.
> - It used only the app profile and eligible task-granted profiles for
that connection.
> - This pull request adds a saved configuration read owned by the
profile service.
> - The route keeps current connection and per-profile management
checks.
> - The benefit is fewer database rows, less Node work, and a smaller
response for the same displayed configuration.

## Linked Issues or Issue Description

**Subsystem affected**

Server profile configuration reads and the Apps Permissions and Review
data caller.

**Problem or motivation**

One connection page loaded and serialized unrelated company profiles. In
the local fixture, this produced a 32.35 MB response with 76,001 rule
rows.

**Proposed solution**

Select the displayed connection's app profile and exact eligible
connection-intent profiles before loading their entries and bindings.
Return an explicit configuration DTO with complete selected arrays. Keep
summary and pending-review work on the existing full API.

**Alternatives considered**

Filtering the full response in the browser retains the database and Node
cost. Truncating the full API would break other clients. Filtering
entries to the connection would lose selectors and bindings needed by
saves.

**Roadmap alignment**

This improves the existing governed Apps capability. It does not add a
new access policy. Related merged work: #18673 and #18681. Related
harness cleanup: #18683. Public PR searches found no duplicate
connection configuration projection.

## What Changed

- Add `ToolProfileConfiguration` and `GET
/api/tool-connections/{connectionId}/profile-configuration`.
- Select `app:<connectionId>` at any status and exact active, correctly
bound `connection_intent` profiles. Run the existing per-profile
visibility filter before configuration queries.
- Preserve complete ordered entries and bindings. Keep all save
behavior, empty-binding install fallback, and the existing full profile
API.
- Switch AppDetail and AgentConnectionAccess to the configuration DTO.
Use a distinct cache key below the existing profiles prefix so mutation
and reconnect invalidations still refresh it.
- Add real PostgreSQL differential and authenticated HTTP tests, UI
caller coverage, a reproducible benchmark, and access-map documentation.

## Verification

Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, after both #18673
and #18681. Final head: `b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`.
Current implementation and reproduction details are in [the performance
note](https://github.com/paperclipai/paperclip/blob/b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22/doc/performance/2026-11-02-connection-profile-configuration.md).

- AppDetail: 79 tests passed. Existing access/action saves, install
fallback, grant removal, errors, and refresh cases use the new caller.
- Real PostgreSQL differential: passed. Full ordered selected
configuration equals the old full-list selection. Cases cover extra
bindings, sibling selectors, wrong source/key/status/binding, null and
non-string agent metadata, fresh mutations, empty app bindings,
visibility filtering, and company scope.
- Production HTTP authentication: API key and run JWT cases passed.
Hidden personal accounts omit spanning profiles. Private connection
reads fail. Restored ownership restores visibility. Revoked management
grants deny fresh calls. Final UID canonicalization and foreign-company
404 assertions passed on the current head.
- Map structural check, token gate, and `git diff --check`: passed.
- Required local `pnpm -r typecheck` and `pnpm build`: passed on the
current head. `pnpm test:run` was started once. Its first chat shard
passed (337 tests, 151.87 seconds); the second shard was intentionally
interrupted after the complete final-head CI suite passed. This partial
local attempt is not a full local test pass. The same stable-runner
general and serialized partitions and Runner lanes passed in [final-head
CI](https://github.com/paperclipai/paperclip/actions/runs/41538805231),
including aggregate `ci / verify`. No local assertion failures were
observed before interruption.
- Final-head focused acceptance: four files, 85 tests passed. Full token
gates, maps, and module boundaries passed.
- Actual browser acceptance passed on the current checkout with isolated
real PostgreSQL and a local trusted board. An action saved as Off
persisted after reload. Both app agent bindings stayed intact. Removing
an agent-only task grant persisted after reload. Requests used only the
narrow profile endpoint. No browser exceptions occurred. No responses
were mocked. No provider calls or hosted changes were made. The helper
stopped the owned runtime and database after the check.
- Final exact-head checks: 54 success, one clean neutral Paperclip
Review, three skipped, zero pending or failures. Legacy Snyk status:
success. No inline review comments.
- Greptile reviewed exact head
`b40c1ed7c0833d403e20ee8a5ff9d09f1bad6d22`: 5/5, no findings, completed
successful check.

Five alternating unprofiled pairs ran with real PostgreSQL on
macOS/arm64, Node v24.22.2, and forced GC. The fixture has 210 profiles
(200 heavy profiles plus ten eligibility cases), 76,001 rules, 1,900
active tools, 695 removed tools, and 12 agents. The selected
configuration has two profiles and 1,391 rules. Each pair asserts
complete ordered selected DTO equality.

| Measurement | Full list | Configuration read |
| --- | --- | --- |
| Service wall ms, median [min, max] | 200.927 [193.319, 213.390] |
6.414 [5.508, 6.973] |
| Node CPU ms, median [min, max] | 262.751 [246.727, 271.573] | 27.525
[22.576, 29.308] |
| Select queries / returned rows | 7 / 78,139 | 3 / 1,397 |
| Actual response JSON bytes | 32,353,534 | 592,233 |
| Serialization wall ms, median [min, max] | 90.714 [88.715, 99.304] |
1.644 [1.637, 1.745] |
| Serialization CPU ms, median [min, max] | 90.644 [88.569, 96.778] |
1.654 [1.637, 1.807] |

Reproduce: `node --expose-gc --import
./server/node_modules/tsx/dist/loader.mjs
server/scripts/benchmark-connection-profile-configuration.ts 5`.

The benchmark measures the service and actual response JSON separately.
It excludes HTTP and authorization work through an identity visibility
callback. Node CPU does not include PostgreSQL CPU. No hosted or
foreground-under-load improvement is claimed.

Preserved failures: offline install lacked a locked tarball; frozen
online install passed. Sandbox localhost and tsx IPC restrictions
blocked initial DB/typecheck attempts; permitted retries use the same
checks. The first HTTP suite needed the unbuilt plugin SDK; its owning
build helper fixed setup. Shared typecheck caught a missing barrel
export, now fixed. A broad typecheck inherited the host's unpinned
nested pnpm executable; the owned stalled process was stopped and
restarted with pinned pnpm 9. The complete current-head CI suite and
aggregate verify passed. The separate Paperclip agent review published
5/5 with no findings at this head and completed with a clean neutral
conclusion. An added foreign-company assertion expected 403; the
existing guard correctly returned 404. Only the test expectation
changed. The first real browser attempt saved and reloaded the action
correctly, but grant deletion returned 422: the stress fixture had a
company binding, which makes that profile the company default. The UI
showed the existing deletion error. The second browser seed used the
production agent-only intent shape and passed removal and reload. This
does not change the stress fixture or product deletion gates. Initial
external browser script attempts also needed an ESM extension and the
correct dependency path. Logs, first-failure screenshots, traces, and
result files are retained in the local evidence directory.

## Risks

- SQL selection must stay aligned with connection-intent eligibility.
Differential fixtures cover the exact current predicate.
- A selected connection can still have many rules. This change preserves
all selected configuration.
- Per-profile authorization still reads current selected resources and
can add work. Connection visibility does not bypass that check.
- No migration, default-allow, effective-access, policy-review,
provider, or deployment behavior changes. The old endpoint remains
available. Rollback restores the old AppDetail caller and removes the
additive endpoint.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, with code execution and repository tools.
The host does not expose the context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-02 16:55:56 -06:00
1 parent 00fea0da52
commit 554a770c6f
19 files changed
+415 -17

No files matched your search

@@ -0,0 +1,81 @@
# Connection Permissions configuration read
Baseline: `3f8ebfff8aa9febd0733c6f65b07485dcadfd0e2`, which includes #18673
and #18681. This change removes unrelated profile reads from AppDetail. The
profile service owns selection and projection. The route retains the connection
management guard and the full-list route's per-profile visibility checks. The
legacy full profile endpoint remains unchanged.
The new configuration DTO contains saved profile fields and complete ordered
entries and bindings. It has no summary or pending-review counts. Selection
matches the old UI: `app:<connectionId>` at any status, plus active
`connection_intent` profiles with exact metadata/key and a matching agent binding.
This is a presentation view, not effective access. Complete bindings preserve
save behavior and the empty-binding install fallback.
## Local experiment
Run with Node 24, pnpm 9 and installed dependencies:
```sh
node --expose-gc --import ./server/node_modules/tsx/dist/loader.mjs \
server/scripts/benchmark-connection-profile-configuration.ts 5
```
The benchmark uses the existing real PostgreSQL profile fixture: 200 heavy
profiles, 76,000 rules, 1,900 active tools, 695 removed tools, and 12 agents. It
adds ten eligibility cases and one cross-connection rule. The selected connection
has two profiles and 1,391 rules. Each sample asserts equality of the complete
ordered selected configuration against the old full-list selection. Selected
rules are not truncated, even when they refer to another connection.
Five alternating unprofiled pairs ran in a coordinated quiet window on
macOS/arm64, Node v24.22.2, with forced GC before samples. Times below are
median [minimum, maximum] milliseconds. CPU measures Node process CPU, not
PostgreSQL CPU. The benchmark uses an identity visibility callback and excludes
HTTP, middleware, and authorization work. It separately serializes the actual
old full response and new configuration response.
| Measurement | Full list | Connection configuration |
| --- | --- | --- |
| Service wall | 200.927 [193.319, 213.390] | 6.414 [5.508, 6.973] |
| Node CPU | 262.751 [246.727, 271.573] | 27.525 [22.576, 29.308] |
| Select queries | 7 | 3 |
| Returned database rows | 78,139 | 1,397 |
| Actual response JSON bytes | 32,353,534 | 592,233 |
| JSON serialization wall | 90.714 [88.715, 99.304] | 1.644 [1.637, 1.745] |
| JSON serialization CPU | 90.644 [88.569, 96.778] | 1.654 [1.637, 1.807] |
This establishes local service work and payload reduction. It does not establish
hosted latency, foreground responsiveness under load, or live provider behavior.
Cost still grows with the selected profiles' complete configuration. Current
agent authorization can separately read selected profile entries and bindings;
those reads are excluded from this benchmark and remain in the request path.
## Correctness and acceptance
- The PostgreSQL differential test covers exact eligibility, null/non-string
agent metadata, inactive intent profiles, wrong key/source/bindings, sibling
and unmanaged profiles, complete binding ordering, fresh entry changes, empty
app bindings, denied profile selection, and company isolation.
- The assembled HTTP integration fixture exercises standard agent keys and run
JWTs. It checks private account visibility, missing connections, and fresh
permission revocation. A profile spanning a hidden account is omitted while
the displayed connection stays readable.
- AppDetail's component suite covers saved access and action toggles, complete
access restatement, install fallback, removing grants, errors, and refresh.
The configuration key is a distinct child of the existing profiles prefix,
so existing mutation and reconnect invalidations still refresh it.
Preserved setup failures: the offline install lacked a locked package tarball;
a frozen online install passed. The first database attempt failed on sandbox
localhost binding restrictions; the escalated real PostgreSQL run passed. The
first HTTP suite could not resolve the unbuilt plugin SDK; the owning
`ensure-build-deps` command built it and the retry passed. A missing shared
barrel export failed the first shared typecheck and was fixed. The first broad
typecheck inherited an unpinned nested pnpm executable; its stalled owned
processes were stopped and the command was restarted with a pinned pnpm wrapper.
The added foreign-company HTTP assertion first expected 403. The existing guard
returned 404 to hide the resource, as designed. The test expectation was corrected;
production authorization did not change.
+9
View File
@@ -83,6 +83,15 @@ Evidence: `verified` — [authenticated integration fixtures](../../server/src/_
Verification: `passed` — deterministic local fixture; no live provider qualification.
Gaps: Provider fixtures do not qualify live provider setup or availability.
### `get-tool-connections-profile-configuration`
API: `present` — `GET /api/tool-connections/{connectionId}/profile-configuration`; JSON REST. Returns saved app and eligible connection-intent profiles, including complete ordered entries and bindings. Omits summary and pending-review counts. The company-wide full profile list stays available.
Agent access: `available` — agent REST and default native `search_api` / `call_api`.
Policy: `tools:manage_profiles`; the displayed connection and every selected profile use current management-resource authorization. Profiles that span a hidden personal account are omitted. Connection visibility alone does not grant profile visibility.
Evidence: `verified` — [authenticated integration fixture](../../server/src/__tests__/connection-management.integration.test.ts) covers standard agent keys and run JWTs, private accounts, missing resources, and revoked authority on local PostgreSQL.
Verification: `passed` — deterministic local fixture. [Configuration differential test](../../server/src/__tests__/connection-profile-configuration.test.ts) compares complete selected entries and bindings with the full list, including mutations and empty bindings.
Gaps: These fixtures do not qualify live providers or hosted performance.
### `get-tool-connections-installs`
API: `present` — `GET /api/tool-connections/{connectionId}/installs`; JSON REST.
+2
View File
@@ -49,6 +49,8 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep
### `permissions`
The page loads saved profiles for the displayed connection through the configuration read. It preserves complete app bindings for saves and the install fallback when bindings are empty. It does not request profile summaries or pending-review counts.
Automated: [app detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests.
[Installation permission regressions](../../server/src/__tests__/tool-connection-installs.test.ts)
+1
View File
@@ -1526,6 +1526,7 @@ export type {
ToolProfileSummary,
ToolProfileStatus,
ToolProfileWithDetails,
ToolProfileConfiguration,
ToolRateLimitCounter,
ToolRateLimitRule,
ToolRateLimitWindowKind,
+1
View File
@@ -600,6 +600,7 @@ export type {
ToolProfileSummary,
ToolProfileStatus,
ToolProfileWithDetails,
ToolProfileConfiguration,
ToolRateLimitCounter,
ToolRateLimitRule,
ToolRateLimitWindowKind,
+6
View File
@@ -785,6 +785,12 @@ export interface ToolProfileSummary {
isCompanyDefault: boolean;
}
/** Saved configuration only; no effective-access summary or pending-review calculation. */
export interface ToolProfileConfiguration extends Omit<ToolProfile, "newToolsPendingCount"> {
entries: ToolProfileEntry[];
bindings: ToolProfileBinding[];
}
export interface ToolProfileWithDetails extends ToolProfile {
entries: ToolProfileEntry[];
bindings: ToolProfileBinding[];
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { createDb, type Db } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js";
import { createToolProfileService } from "../src/services/tool-access/profiles/service.js";
import { createConnectionProfileConfigurationFixture, legacyConnectionConfiguration } from "../src/__tests__/helpers/connection-profile-configuration-fixture.js";
const repeat = Number(process.argv[2] ?? 5);
if (!Number.isInteger(repeat) || repeat < 1) throw new Error("Positive repeat count required");
const database = await startEmbeddedPostgresTestDatabase("connection-profile-config-benchmark-");
const db = createDb(database.connectionString, { maxConnections: 1 });
let queries = 0, rows = 0;
const measuredDb = 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), execute = selection.execute.bind(selection);
selection.execute = async () => { const result = await execute(); queries++; rows += result.length; return result; };
return selection;
}) as typeof builder.from;
return builder;
};
} });
try {
const f = await createConnectionProfileConfigurationFixture(db);
const service = createToolProfileService(measuredDb, () => new Date("2026-02-01T00:00:00Z"));
const invoke = async (label: "before" | "after") => {
if (label === "before") {
const profiles = await service.listProfiles(f.company.id);
return { profiles, selected: legacyConnectionConfiguration(profiles, f.connection.id) };
}
const profiles = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async rows => rows);
return { profiles, selected: profiles };
};
const baseline = await invoke("before"), treatment = await invoke("after");
assert.deepStrictEqual(treatment.selected, baseline.selected);
console.log(JSON.stringify({ node: process.version, platform: `${process.platform}/${process.arch}`, fixture: {
profiles: baseline.profiles.length, rules: baseline.profiles.reduce((n, p) => n + p.entries.length, 0),
activeTools: 1900, removedTools: 695, agents: 12, selectedProfiles: treatment.profiles.length,
selectedRules: treatment.profiles.reduce((n, p) => n + p.entries.length, 0),
}, equality: "full ordered selected configuration", scope: "real PostgreSQL service + actual response JSON; identity filter, excludes HTTP/auth" }));
for (let round = 0; round < repeat; round++) {
for (const label of round % 2 ? ["after", "before"] as const : ["before", "after"] as const) {
global.gc?.(); queries = 0; rows = 0;
const start = performance.now(), cpu = process.cpuUsage();
const result = await invoke(label);
const serviceMs = performance.now() - start, used = process.cpuUsage(cpu);
const serializeStart = performance.now(), serializeCpu = process.cpuUsage();
const json = JSON.stringify({ profiles: result.profiles });
const serializationMs = performance.now() - serializeStart, serializeUsed = process.cpuUsage(serializeCpu);
assert.deepStrictEqual(result.selected, baseline.selected);
console.log(JSON.stringify({ round, label, serviceMs, cpuMs: (used.user + used.system) / 1000, queries, rows,
jsonBytes: Buffer.byteLength(json), serializationMs, serializationCpuMs: (serializeUsed.user + serializeUsed.system) / 1000,
forcedGc: Boolean(global.gc) }));
}
}
} finally { await db.$client.end({ timeout: 1 }); await database.cleanup(); }
@@ -6,7 +6,7 @@ import { join } from "node:path";
import { createServer } from "node:http";
import { and, eq, sql } from "drizzle-orm";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, aiConnectionPools, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
import { createDb, authUsers, activityLog, agentApiKeys, companies, companySecrets, companySecretVersions, chatConversations, chatDeliveries, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, toolPolicies, toolRateLimitCounters, connectionGrants, connectionGrantMembers, connectionGrantDelegations, toolAccessAuditEvents, toolOauthStates, issueThreadInteractions, connectionIntentDeliveries, agents, companyMemberships, heartbeatRuns, issues, principalPermissionGrants, projects, toolApplications, toolConnections, toolConnectionInstalls, aiConnectionPools, toolMcpGateways, toolMcpGatewayTokens, toolActionRequests, toolCatalogEntries, toolInvocations, toolCallEvents, toolProfileEntries, toolProfiles, toolProfileBindings, plugins, adapterAuthSessions, environments, routines, chatGitHubReviews } from "@paperclipai/db";
import { findSatisfiedToolConnection, satisfiedConnectionIntentService } from "../services/satisfied-connection-intents.js";
import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js";
import { aiConnectionService } from "../services/ai-connections.js";
@@ -408,6 +408,53 @@ describe("connection management with production default agents", () => {
for (const id of profileIds) expect(await f.request("GET", `/tool-profiles/${id}/new-tools?connectionId=${first.body.id}`)).toMatchObject({ status: 200 });
});
it("keeps connection profile configuration scoped and rechecks each profile against current personal access", async () => {
const f = await fixture();
const create = async (name: string) => {
const response = await f.request("POST", `/companies/${f.companyId}/tools/connections`, {
name, transport: "mcp_remote", authKind: "none", config: { url: "https://1.1.1.1/mcp" },
});
expect(response).toMatchObject({ status: 201 });
return response.body;
};
const owned = await create("Configuration owned"), privateConnection = await create("Configuration private");
const [app, intent] = await server.db.insert(toolProfiles).values([
{ companyId: f.companyId, profileKey: `app:${owned.id}`, name: "App" },
{ companyId: f.companyId, profileKey: `connection-intent:${owned.id}:${f.agentId}`, name: "Intent",
metadata: { source: "connection_intent", connectionId: owned.id, agentId: f.agentId } },
]).returning();
await server.db.insert(toolProfileBindings).values({ companyId: f.companyId, profileId: intent.id, targetType: "agent", targetId: f.agentId });
await server.db.insert(toolProfileEntries).values([
{ companyId: f.companyId, profileId: app.id, selectorType: "connection", connectionId: privateConnection.id },
{ companyId: f.companyId, profileId: intent.id, selectorType: "connection", connectionId: owned.id },
]);
const path = `/tool-connections/${owned.id}/profile-configuration`;
const uidResult = await f.request("GET", `/tool-connections/${encodeURIComponent(owned.uid)}/profile-configuration`);
expect(uidResult).toMatchObject({ status: 200 });
expect(uidResult.body.profiles.map((p: { id: string }) => p.id).sort()).toEqual([app.id, intent.id].sort());
const foreign = await fixture();
expect(await foreign.request("GET", `/tool-connections/${owned.id}/profile-configuration`)).toMatchObject({ status: 404 });
for (const token of [f.key.token, f.jwt]) {
const result = await f.request("GET", path, undefined, token);
expect(result).toMatchObject({ status: 200 });
expect(result.body.profiles.map((p: { id: string }) => p.id).sort()).toEqual([app.id, intent.id].sort());
expect(result.body.profiles.every((p: object) => !("summary" in p) && !("newToolsPendingCount" in p))).toBe(true);
}
// Seeing the displayed connection must not expose a profile spanning a
// second account after that account becomes private to another person.
await server.db.update(toolConnections).set({ credentialPolicy: "per_user", createdByUserId: randomUUID() }).where(eq(toolConnections.id, privateConnection.id));
for (const token of [f.key.token, f.jwt]) {
expect(await f.request("GET", path, undefined, token)).toMatchObject({ status: 200, body: { profiles: [expect.objectContaining({ id: intent.id })] } });
expect(await f.request("GET", `/tool-connections/${privateConnection.id}/profile-configuration`, undefined, token)).toMatchObject({ status: 403 });
expect(await f.request("GET", `/tool-connections/${randomUUID()}/profile-configuration`, undefined, token)).toMatchObject({ status: 404 });
}
await server.db.update(toolConnections).set({ createdByUserId: f.userId }).where(eq(toolConnections.id, privateConnection.id));
expect((await f.request("GET", path)).body.profiles).toHaveLength(2);
await server.db.delete(principalPermissionGrants).where(and(eq(principalPermissionGrants.principalId, f.agentId), eq(principalPermissionGrants.permissionKey, "tools:manage_profiles")));
expect(await f.request("GET", path)).toMatchObject({ status: 403 });
expect(await f.request("GET", path, undefined, f.jwt)).toMatchObject({ status: 403 });
});
it("rechecks authority after remote endpoint validation before committing a connection update", async () => {
const f = await fixture();
const created = await f.request("POST", `/companies/${f.companyId}/tools/connections`, { name: "Before validation", transport: "mcp_remote", authKind: "none", transportConfig: { url: "https://1.1.1.1/mcp" } });
@@ -0,0 +1,44 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { createDb, toolProfileBindings, toolProfileEntries } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { createConnectionProfileConfigurationFixture, legacyConnectionConfiguration } from "./helpers/connection-profile-configuration-fixture.js";
import { createToolProfileService } from "../services/tool-access/profiles/service.js";
const support = await getEmbeddedPostgresTestSupport();
const describeDatabase = support.supported ? describe : describe.skip;
describeDatabase("connection Permissions profile configuration", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("connection-profile-configuration-"); db = createDb(database.connectionString); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); });
it("matches the old selected view with full ordered entries and bindings, before and after changes", async () => {
const f = await createConnectionProfileConfigurationFixture(db, { profiles: 8, tools: 12, rules: 4 });
const service = createToolProfileService(db, () => new Date("2026-02-01T00:00:00Z"));
const visible = async <T>(rows: T[]) => rows;
const compare = async () => {
const expected = legacyConnectionConfiguration(await service.listProfiles(f.company.id), f.connection.id);
const actual = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, visible);
expect(actual).toEqual(expected);
expect(actual.map(p => p.id).sort()).toEqual([...f.selectedIds].sort());
expect(actual.every(p => !("summary" in p) && !("newToolsPendingCount" in p))).toBe(true);
return actual;
};
let result = await compare();
expect(result.find(p => p.id === f.selectedIds[0])!.bindings.map(b => b.priority)).toEqual([1, 2]);
expect(result.find(p => p.id === f.selectedIds[1])!.bindings).toHaveLength(2);
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.selectedIds[1], selectorType: "tool_name", toolName: "new selection" });
await compare();
// An unbound app profile must remain present for the install fallback.
await db.delete(toolProfileBindings).where(eq(toolProfileBindings.profileId, f.selectedIds[0]));
result = await compare();
expect(result.find(p => p.id === f.selectedIds[0])!.bindings).toEqual([]);
const seen: string[][] = [];
const filtered = await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async rows => {
seen.push(rows.map(p => p.id)); return rows.filter(p => p.id !== f.selectedIds[1]);
});
expect(seen[0].sort()).toEqual([...f.selectedIds].sort());
expect(filtered.map(p => p.id)).toEqual([f.selectedIds[0]]);
expect(await service.listConnectionProfileConfiguration(f.company.id, f.connection.id, async () => [])).toEqual([]);
const foreign = await createConnectionProfileConfigurationFixture(db, { profiles: 2, tools: 3, rules: 2 });
expect(await service.listConnectionProfileConfiguration(foreign.company.id, f.connection.id, visible)).toEqual([]);
});
});
@@ -0,0 +1,51 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { agents, toolConnections, toolProfileBindings, toolProfileEntries, toolProfiles, type Db } from "@paperclipai/db";
import type { ToolProfileConfiguration, ToolProfileWithDetails } from "@paperclipai/shared";
import { createProfileProjectionFixture } from "./tool-profile-projection-fixture.js";
export async function createConnectionProfileConfigurationFixture(db: Db, options: Parameters<typeof createProfileProjectionFixture>[1] = {}) {
const f = await createProfileProjectionFixture(db, options);
const companyAgents = await db.select().from(agents).where(eq(agents.companyId, f.company.id));
const agentId = companyAgents[0]!.id;
const [sibling] = await db.insert(toolConnections).values({ companyId: f.company.id, applicationId: f.application.id,
name: "Sibling account", uid: randomUUID(), transport: "mcp_remote", status: "active" }).returning();
await db.update(toolProfiles).set({ profileKey: `app:${f.connection.id}`, status: "disabled" }).where(eq(toolProfiles.id, f.profiles[0]!.id));
const metadata = { source: "connection_intent", connectionId: f.connection.id, agentId };
await db.update(toolProfiles).set({ profileKey: `connection-intent:${f.connection.id}:${agentId}`, metadata }).where(eq(toolProfiles.id, f.profiles[1]!.id));
const rejected = await db.insert(toolProfiles).values([
{ profileKey: `app:${sibling.id}`, name: "Sibling app" },
{ profileKey: `connection-intent:${sibling.id}:${agentId}`, name: "Sibling intent", metadata: { ...metadata, connectionId: sibling.id } },
{ profileKey: `connection-intent:${f.connection.id}:wrong-key`, name: "Wrong key", metadata },
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[1]!.id}`, name: "Wrong binding", metadata: { ...metadata, agentId: companyAgents[1]!.id } },
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[2]!.id}`, name: "Inactive intent", status: "disabled" as const, metadata: { ...metadata, agentId: companyAgents[2]!.id } },
{ profileKey: `connection-intent:${f.connection.id}:123`, name: "Non-string agent", metadata: { ...metadata, agentId: 123 } },
{ profileKey: `connection-intent:${f.connection.id}:null`, name: "Null agent", metadata: { ...metadata, agentId: null } },
{ profileKey: "unmanaged", name: "Unmanaged", metadata },
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[3]!.id}`, name: "Wrong source", metadata: { ...metadata, agentId: companyAgents[3]!.id, source: "other" } },
{ profileKey: `connection-intent:${f.connection.id}:${companyAgents[4]!.id}`, name: "No binding", metadata: { ...metadata, agentId: companyAgents[4]!.id } },
].map(profile => ({ companyId: f.company.id, ...profile }))).returning();
const bindingAt = new Date("2026-01-01T00:00:00Z");
await db.insert(toolProfileBindings).values([
{ profileId: f.profiles[0]!.id, targetType: "agent" as const, targetId: agentId, priority: 2 },
{ profileId: f.profiles[0]!.id, targetType: "agent" as const, targetId: companyAgents[1]!.id, priority: 1 },
{ profileId: f.profiles[1]!.id, targetType: "agent" as const, targetId: agentId, priority: 0 },
{ profileId: f.profiles[1]!.id, targetType: "company" as const, targetId: f.company.id, priority: 3 },
...rejected.filter(p => p.name !== "No binding").map(p => ({ profileId: p.id, targetType: "agent" as const, targetId: agentId, priority: 0 })),
].map(binding => ({ companyId: f.company.id, ...binding, createdAt: bindingAt })));
// Selected profiles can carry selectors outside this account. Keep all rows.
await db.insert(toolProfileEntries).values({ companyId: f.company.id, profileId: f.profiles[0]!.id,
selectorType: "connection", connectionId: sibling.id, effect: "exclude" });
return { ...f, sibling, agentId, selectedIds: [f.profiles[0]!.id, f.profiles[1]!.id], rejected };
}
/** The old AppDetail/AgentConnectionAccess selection, with only unused derived fields removed. */
export function legacyConnectionConfiguration(profiles: ToolProfileWithDetails[], connectionId: string): ToolProfileConfiguration[] {
return profiles.filter(profile => {
const agentId = profile.metadata?.agentId;
return profile.profileKey === `app:${connectionId}` || (profile.status === "active"
&& profile.metadata?.source === "connection_intent" && profile.metadata.connectionId === connectionId
&& typeof agentId === "string" && profile.profileKey === `connection-intent:${connectionId}:${agentId}`
&& profile.bindings.some(binding => binding.targetType === "agent" && binding.targetId === agentId));
}).map(({ summary: _summary, newToolsPendingCount: _pending, ...configuration }) => configuration);
}
+9
View File
@@ -12068,6 +12068,15 @@ registerCurrentRoute({
summary: "Handle a managed Vercel Connect OAuth callback",
});
registerCurrentRoute({
"x-paperclip-transport": "rest",
"x-paperclip-connection-management": "connections:profiles",
method: "get",
path: "/api/tool-connections/{connectionId}/profile-configuration",
tags: ["tool-access"],
summary: "Read connection Permissions profile configuration without summaries or review counts",
});
registerCurrentRoute({
"x-paperclip-transport": "rest",
"x-paperclip-connection-management": "connections:profiles",
+9
View File
@@ -2327,6 +2327,15 @@ function connectorEnrollmentPrincipal(req: Request): string {
res.json({ profiles: await filterConnectionManagementRows(db, req, "connections:profiles", await svc.listProfiles(companyId), row => ({ profileId: row.id })) });
});
router.get("/tool-connections/:connectionId/profile-configuration", managementGuard("connections:profiles", "GET /tool-connections/:connectionId/profile-configuration", { personal: true }), async (req, res) => {
assertBoardOrAgent(req);
const existing = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found");
if (!existing) return;
const profiles = await svc.listConnectionProfileConfiguration(existing.companyId, existing.id,
rows => filterConnectionManagementRows(db, req, "connections:profiles", rows, row => ({ profileId: row.id })));
res.json({ profiles });
});
router.get("/tool-profiles/:profileId/new-tools", managementGuard("connections:profiles", "GET /tool-profiles/:profileId/new-tools", { personal: true }), async (req, res) => {
assertBoardOrAgent(req);
const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found");
@@ -0,0 +1,64 @@
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm";
import { toolProfileBindings, toolProfileEntries, toolProfiles, type Db } from "@paperclipai/db";
import type { ToolProfileConfiguration } from "@paperclipai/shared";
import { toProfile, toProfileBinding, toProfileEntry } from "./projection.js";
type ProfileRow = typeof toolProfiles.$inferSelect;
/** Saved profiles shown by connection Permissions; this is not effective access. */
export function createConnectionProfileConfigurationReader(db: Db) {
return async (
companyId: string,
connectionId: string,
visibleProfiles: (profiles: ProfileRow[]) => Promise<ProfileRow[]>,
): Promise<ToolProfileConfiguration[]> => {
const candidates = await db.select().from(toolProfiles).where(and(
eq(toolProfiles.companyId, companyId),
or(
eq(toolProfiles.profileKey, `app:${connectionId}`),
and(
eq(toolProfiles.status, "active"),
sql`${toolProfiles.metadata}->>'source' = 'connection_intent'`,
sql`${toolProfiles.metadata}->>'connectionId' = ${connectionId}`,
sql`jsonb_typeof(${toolProfiles.metadata}->'agentId') = 'string'`,
sql`${toolProfiles.profileKey} = 'connection-intent:' || ${connectionId} || ':' || (${toolProfiles.metadata}->>'agentId')`,
sql`exists (select 1 from ${toolProfileBindings} where
${toolProfileBindings.companyId} = ${toolProfiles.companyId}
and ${toolProfileBindings.profileId} = ${toolProfiles.id}
and ${toolProfileBindings.targetType} = 'agent'
and ${toolProfileBindings.targetId} = ${toolProfiles.metadata}->>'agentId')`,
),
),
)).orderBy(desc(toolProfiles.updatedAt));
// Preserve the full-list route's per-profile current-authority checks before
// loading configuration. Connection access alone does not grant profile access.
const profiles = await visibleProfiles(candidates);
if (profiles.length === 0) return [];
const ids = profiles.map(profile => profile.id);
const [entries, bindings] = await Promise.all([
db.select().from(toolProfileEntries).where(and(
eq(toolProfileEntries.companyId, companyId), inArray(toolProfileEntries.profileId, ids),
)).orderBy(asc(toolProfileEntries.createdAt), asc(toolProfileEntries.id)),
db.select().from(toolProfileBindings).where(and(
eq(toolProfileBindings.companyId, companyId), inArray(toolProfileBindings.profileId, ids),
)).orderBy(asc(toolProfileBindings.priority), asc(toolProfileBindings.createdAt)),
]);
const entriesByProfile = new Map<string, ToolProfileConfiguration["entries"]>();
const bindingsByProfile = new Map<string, ToolProfileConfiguration["bindings"]>();
for (const entry of entries) {
const group = entriesByProfile.get(entry.profileId) ?? [];
group.push(toProfileEntry(entry));
entriesByProfile.set(entry.profileId, group);
}
for (const binding of bindings) {
const group = bindingsByProfile.get(binding.profileId) ?? [];
group.push(toProfileBinding(binding));
bindingsByProfile.set(binding.profileId, group);
}
return profiles.map(profile => ({
...toProfile(profile),
entries: entriesByProfile.get(profile.id) ?? [],
bindings: bindingsByProfile.get(profile.id) ?? [],
}));
};
}
@@ -1,4 +1,5 @@
import type { ConnectionManagementContext } from "../../connection-management.js";
import { createConnectionProfileConfigurationReader } from "./connection-configuration.js";
import { connectionActorUserId } from "../actor.js";
import { assertProfileEntryInputs } from "../../tool-profile-entry-validation.js";
import { randomUUID } from "node:crypto";
@@ -360,6 +361,8 @@ export function createToolProfileService(db: Db, now: () => Date) {
}
}
return {
listConnectionProfileConfiguration: createConnectionProfileConfigurationReader(db),
listProfiles: async (
companyId: string,
): Promise<ToolProfileWithDetails[]> => {
+3
View File
@@ -34,6 +34,7 @@ import type {
ToolProfileEntrySelectorType,
ToolProfileStatus,
ToolProfileWithDetails,
ToolProfileConfiguration,
ToolProfileNewToolReviewDecision,
ToolProfileNewToolsReview,
ToolProfileNewToolsReviewResult,
@@ -480,6 +481,8 @@ export const toolsApi = {
),
// --- Profiles ---
listConnectionProfileConfiguration: (connectionId: string) =>
api.get<{ profiles: ToolProfileConfiguration[] }>(`/tool-connections/${connectionId}/profile-configuration`),
listProfiles: (companyId: string) =>
api.get<ToolProfilesResponse>(`/companies/${companyId}/tools/profiles`),
getProfileNewTools: (profileId: string) =>
+2
View File
@@ -88,6 +88,8 @@ export const queryKeys = {
actionRequests: (companyId: string, status: string) =>
["tools", companyId, "action-requests", status] as const,
gateways: (companyId: string) => ["tools", "gateways", companyId] as const,
connectionProfileConfiguration: (companyId: string, connectionId: string) =>
["tools", companyId, "profiles", "connection-configuration", connectionId] as const,
profiles: (companyId: string) => ["tools", companyId, "profiles"] as const,
profileNewTools: (profileId: string) =>
["tools", "profiles", profileId, "new-tools"] as const,
+17 -8
View File
@@ -19,7 +19,7 @@ const listApplicationsMock = vi.hoisted(() => vi.fn());
const listGalleryMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listCatalogMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
const listConnectionProfileConfigurationMock = vi.hoisted(() => vi.fn());
const listPoliciesMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
@@ -60,7 +60,7 @@ vi.mock("@/api/tools", () => ({
listGallery: (companyId: string) => listGalleryMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
listCatalog: (connectionId: string) => listCatalogMock(connectionId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
listConnectionProfileConfiguration: (connectionId: string) => listConnectionProfileConfigurationMock(connectionId),
deleteProfile: (profileId: string) => deleteProfileMock(profileId),
listPolicies: (companyId: string) => listPoliciesMock(companyId),
listConnectionActivity: (connectionId: string, limit: number) =>
@@ -409,7 +409,7 @@ describe("AppDetail", () => {
}),
],
});
listProfilesMock.mockResolvedValue({
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [
{
profileKey: "app:conn-1",
@@ -1034,6 +1034,15 @@ describe("AppDetail", () => {
});
});
it("fetches the connection configuration under a distinct child of the profiles cache", async () => {
await renderAppDetail();
expect(listConnectionProfileConfigurationMock).toHaveBeenCalledWith("conn-1");
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1").slice(0, 3))
.toEqual(queryKeys.tools.profiles("company-1"));
expect(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-1"))
.not.toEqual(queryKeys.tools.connectionProfileConfiguration("company-1", "conn-2"));
});
it("shows and removes task-granted access without changing the connection's default permissions", async () => {
const grant = {
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
@@ -1041,7 +1050,7 @@ describe("AppDetail", () => {
bindings: [{ targetType: "agent", targetId: "agent-1" }],
entries: [{ effect: "include", catalogEntryId: "catalog-write" }],
};
listProfilesMock.mockResolvedValue({ profiles: [grant] });
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [grant] });
listPoliciesMock.mockResolvedValue({ policies: [{
enabled: true, policyType: "require_approval",
selectors: { catalogEntryId: "catalog-write" },
@@ -1051,7 +1060,7 @@ describe("AppDetail", () => {
const section = Array.from(container.querySelectorAll("section")).find(section => section.textContent?.includes("Additional agent access"))!;
expect(section.textContent).toContain("Write issue");
expect(section.textContent).toContain("Ask first");
listProfilesMock.mockResolvedValue({ profiles: [] });
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [] });
await act(async () => section.querySelector("button")!.click());
await flushReact();
expect(deleteProfileMock).toHaveBeenCalledWith("task-grant");
@@ -1061,7 +1070,7 @@ describe("AppDetail", () => {
});
it("keeps the task grant visible when removal fails", async () => {
listProfilesMock.mockResolvedValue({ profiles: [{
listConnectionProfileConfigurationMock.mockResolvedValue({ profiles: [{
id: "task-grant", status: "active", profileKey: "connection-intent:conn-1:agent-1",
metadata: { source: "connection_intent", connectionId: "conn-1", agentId: "agent-1" },
bindings: [{ targetType: "agent", targetId: "agent-1" }],
@@ -1078,7 +1087,7 @@ describe("AppDetail", () => {
it("persists agent access independently from always-installed agents", async () => {
mockParams.tab = "permissions";
listProfilesMock.mockResolvedValue({
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [{
profileKey: "app:conn-1",
entries: [
@@ -1144,7 +1153,7 @@ describe("AppDetail", () => {
it("uses the setup-style agent access question without install terminology", async () => {
mockParams.tab = "permissions";
listProfilesMock.mockResolvedValue({
listConnectionProfileConfigurationMock.mockResolvedValue({
profiles: [{
profileKey: "app:conn-1",
entries: [
+6 -6
View File
@@ -15,7 +15,7 @@ import { Check, Loader2, Pencil } from "lucide-react";
import type {
ToolConnection,
ToolPolicy,
ToolProfileWithDetails,
ToolProfileConfiguration,
} from "@paperclipai/shared";
import {
connectionDisplaySecondaryHint,
@@ -133,9 +133,9 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio
enabled: !!connectionId && needsCatalog,
});
const profilesQuery = useQuery({
queryKey: queryKeys.tools.profiles(selectedCompanyId ?? "__none__"),
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
enabled: !!selectedCompanyId && (activeTab === "review" || activeTab === "permissions"),
queryKey: queryKeys.tools.connectionProfileConfiguration(selectedCompanyId ?? "__none__", connectionId),
queryFn: () => toolsApi.listConnectionProfileConfiguration(connectionId),
enabled: !!selectedCompanyId && !!connectionId && (activeTab === "review" || activeTab === "permissions"),
});
const policiesQuery = useQuery({
queryKey: queryKeys.tools.policies(selectedCompanyId ?? "__none__"),
@@ -923,7 +923,7 @@ function StatusBadge({ status }: { status: StatusInfo }) {
);
}
function enabledCatalogIds(profile: ToolProfileWithDetails | undefined): Set<string> {
function enabledCatalogIds(profile: ToolProfileConfiguration | undefined): Set<string> {
const ids = new Set<string>();
for (const entry of profile?.entries ?? []) {
if (entry.effect === "include" && entry.catalogEntryId) ids.add(entry.catalogEntryId);
@@ -959,7 +959,7 @@ function askFirstCatalogIds(policies: ToolPolicy[], connectionId: string): Set<s
* one that agrees with what the tab displays.
*/
function accessFrom(
profile: ToolProfileWithDetails | undefined,
profile: ToolProfileConfiguration | undefined,
install: InstallState,
): AccessDraft {
const bindings = profile?.bindings ?? [];
@@ -1,11 +1,11 @@
import { useState } from "react";
import type { Agent, ToolCatalogEntry, ToolPolicy, ToolProfileWithDetails } from "@paperclipai/shared";
import type { Agent, ToolCatalogEntry, ToolPolicy, ToolProfileConfiguration } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
export function AgentConnectionAccess({ connectionId, profiles, policies, catalog, agents, canManage, onRemove }: {
connectionId: string;
profiles: ToolProfileWithDetails[];
profiles: ToolProfileConfiguration[];
policies: ToolPolicy[];
catalog: ToolCatalogEntry[];
agents: Agent[];