Files
PaperClipAI/server/src/__tests__/tool-oauth-legacy-backfill.test.ts
T
DottaandPaperclip 7e00f67138 feat(connections): add v3 schema core (#9958)
## Thinking Path

> - Paperclip is the open source control plane people use to manage
AI-agent companies and their governed access to external systems.
> - Connected Apps build on the existing Apps and MCP gateway substrate
so companies can configure reusable, auditable integrations.
> - The current connection record does not yet have a stable public
address, explicit ownership/auth method fields, or subject-specific
credential grants.
> - Without that schema core, later OAuth, per-user authorization, token
brokering, triggers, and connector-service phases cannot enforce tenant
and subject boundaries consistently.
> - This pull request adds the forward-compatible Connections v3 schema
core while preserving the existing connection lifecycle and directly
migrating the remote MCP transport name.
> - The benefit is a company-scoped, least-privilege foundation for
one-click integrations without bypassing Paperclip secrets, profiles,
rules, or audit controls.

## Linked Issues or Issue Description

No matching public issue was found.

**Problem**

Paperclip's current app connections need a durable identity and
authorization substrate before Connected Apps can safely support
multiple setup methods, per-user credentials, provider tenants, and
managed connector services. The existing schema only models a single
connection-level credential set and uses legacy transport terminology.

**Proposed solution**

Add a stable company-scoped connection UID, explicit
ownership/auth/transport fields, a subject-aware `connection_grants`
table, and multi-key credential annotations. Backfill existing
connections and workspace grants in a reversible migration, then update
shared/server/UI contracts to the new `mcp_remote` transport name.

**Related work**

- Related foundation: #9534
- Roadmap: Connected Apps (one-click integrations)

## What Changed

- Added company-scoped connection `uid`, `ownership`, `authKind`, and
canonical transport fields across database, shared contracts,
validators, services, and UI fixtures.
- Added `connection_grants` with workspace/user subject rules, provider
tenant metadata, credential secret refs, revocation state, company
scoping, and uniqueness constraints.
- Added migration `0182_connections_v3_schema_core` to backfill stable
UIDs, rename `remote_http` to `mcp_remote`, infer auth kinds, create
default workspace grants, and support rollback coverage.
- Added multi-key credential annotations and updated gateway/access
services without changing the existing lifecycle behavior.
- Updated the connection glossary, connector playbook, and security
threat model for the new identity, grant, and relay boundaries.
- Added explicit test UIDs to direct database fixtures so the new
non-null invariant is exercised across affected server suites.

## Verification

- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts
server/src/__tests__/tool-gateway-service.test.ts
server/src/__tests__/tool-gateway.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts
server/src/__tests__/tool-oauth-legacy-backfill.test.ts
server/src/__tests__/tool-access-policy-service.test.ts
server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts
packages/db/src/connections-v3-schema-core-migration.test.ts
packages/shared/src/validators/tool-access.test.ts --config
vitest.config.ts` — 9 files, 218 tests passed.
- Latest-head GitHub Actions: build, typecheck, general/serialized
suites, backup/worktree restore coverage, both e2e shards, canary,
policy, and security scans pass.
- Greptile: 5/5 with zero unresolved threads.
- `pnpm check:token-gates` remains red only on five pre-existing `#9627`
color literals outside this change.

## Risks

- **Migration risk:** UID backfill and default-grant creation touch
every existing connection. The migration uses company-scoped uniqueness,
deterministic legacy UIDs with ID suffixes, and seeded up/rollback
coverage.
- **Authorization risk:** Grant rows carry credential references.
Constraints enforce workspace-vs-user subject shape, company/connection
lookup indexes, one default grant per connection, and one user grant per
connection/subject. Security review is requested specifically for this
design.
- **Compatibility risk:** `remote_http` is renamed directly to
`mcp_remote`; all repository call sites and fixtures are updated in the
same change.
- **Future-phase risk:** Subject-bound token issuance, triggers, and
connector-service relay verification remain fail-closed requirements
documented for later phases; this PR does not expose those capabilities.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex CLI coding agent. The runtime did not expose an exact
underlying model ID or context-window size; capabilities used include
repository inspection, code editing, shell execution, test execution,
Git/GitHub CLI operations, and structured reasoning.

## 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>
2026-07-21 15:16:26 -05:00

328 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
companies,
companySecretBindings,
companySecretProviderConfigs,
companySecrets,
companySecretVersions,
createDb,
secretAccessEvents,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { backfillLegacyToolOAuthTokens } from "../services/tool-oauth-legacy-backfill.js";
import { secretService } from "../services/secrets.js";
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function createCompany(db: ReturnType<typeof createDb>) {
return db
.insert(companies)
.values({
name: `OAuth Legacy ${randomUUID()}`,
issuePrefix: `OL${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
describeEmbeddedPostgres("tool OAuth legacy backfill", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-oauth-backfill-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
vi.restoreAllMocks();
await db.delete(secretAccessEvents);
await db.delete(companySecretBindings);
await db.delete(companySecrets);
await db.delete(companySecretProviderConfigs);
await db.delete(toolConnections);
await db.delete(toolApplications);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("moves legacy raw OAuth tokens into secret refs and removes JSONB token keys idempotently", async () => {
const company = await createCompany(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `legacy-oauth-${randomUUID()}`,
name: `Legacy OAuth ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: `Legacy OAuth Connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: {
url: "https://legacy.example.test/mcp",
oauth: {
provider: "legacy",
tokenUrl: "https://legacy.example.test/oauth/token",
access_token: "legacy-access-token",
refresh_token: "legacy-refresh-token",
expiresAt: "2099-01-01T00:00:00.000Z",
},
},
transportConfig: {
url: "https://legacy.example.test/mcp",
oauth: {
access_token: "legacy-access-token",
refresh_token: "legacy-refresh-token",
},
},
credentialSecretRefs: [],
credentialRefs: [],
}).returning();
const first = await backfillLegacyToolOAuthTokens(db);
expect(first).toMatchObject({
scannedConnections: 1,
migratedConnections: 1,
sanitizedConnections: 1,
createdSecrets: 2,
rotatedSecrets: 0,
accessTokensBackfilled: 1,
refreshTokensBackfilled: 1,
});
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection!.id));
expect(JSON.stringify(updated!.config)).not.toContain("legacy-access-token");
expect(JSON.stringify(updated!.config)).not.toContain("legacy-refresh-token");
expect(JSON.stringify(updated!.config)).not.toContain("access_token");
expect(JSON.stringify(updated!.config)).not.toContain("refresh_token");
expect(JSON.stringify(updated!.transportConfig)).not.toContain("access_token");
expect(updated!.credentialSecretRefs).toEqual(expect.arrayContaining([
expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token" }),
expect.objectContaining({ configPath: "oauth.refresh_token", label: "OAuth refresh token" }),
]));
expect(updated!.credentialRefs).toEqual([
expect.objectContaining({ name: "oauth.access_token", key: "Authorization", prefix: "Bearer " }),
]);
const secretRows = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
expect(secretRows.map((secret) => secret.key).sort()).toEqual([
`tool-connection/${connection!.id}/oauth/access-token`,
`tool-connection/${connection!.id}/oauth/refresh-token`,
].sort());
await expect(db.select().from(companySecretVersions)).resolves.toHaveLength(2);
const secrets = secretService(db);
const accessRef = updated!.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token")!;
const refreshRef = updated!.credentialSecretRefs.find((ref) => ref.configPath === "oauth.refresh_token")!;
await expect(secrets.resolveSecretValue(company.id, accessRef.secretId, "latest", {
consumerType: "tool_connection",
consumerId: connection!.id,
configPath: "oauth.access_token",
actorType: "system",
})).resolves.toBe("legacy-access-token");
await expect(secrets.resolveSecretValue(company.id, refreshRef.secretId, "latest", {
consumerType: "tool_connection",
consumerId: connection!.id,
configPath: "oauth.refresh_token",
actorType: "system",
})).resolves.toBe("legacy-refresh-token");
const accessEvents = await db.select().from(secretAccessEvents);
expect(accessEvents).toEqual(expect.arrayContaining([
expect.objectContaining({
consumerType: "tool_connection",
consumerId: connection!.id,
configPath: "oauth.access_token",
outcome: "success",
}),
expect.objectContaining({
consumerType: "tool_connection",
consumerId: connection!.id,
configPath: "oauth.refresh_token",
outcome: "success",
}),
]));
const second = await backfillLegacyToolOAuthTokens(db);
expect(second).toMatchObject({
scannedConnections: 0,
migratedConnections: 0,
sanitizedConnections: 0,
createdSecrets: 0,
rotatedSecrets: 0,
});
await expect(db.select().from(companySecretVersions)).resolves.toHaveLength(2);
});
it("preserves an existing deterministic OAuth secret provider when rotating legacy material", async () => {
const company = await createCompany(db);
const [application] = await db.insert(toolApplications).values({
companyId: company.id,
applicationKey: `legacy-oauth-aws-${randomUUID()}`,
name: `Legacy OAuth AWS ${randomUUID()}`,
type: "mcp_http",
status: "active",
}).returning();
const [connection] = await db.insert(toolConnections).values({
companyId: company.id,
applicationId: application!.id,
name: `Legacy OAuth AWS Connection ${randomUUID()}`,
uid: `test/${randomUUID()}`,
transport: "mcp_remote",
status: "active",
enabled: true,
config: {
url: "https://legacy-aws.example.test/mcp",
oauth: {
provider: "legacy",
access_token: "legacy-access-token",
},
},
transportConfig: {},
credentialSecretRefs: [],
credentialRefs: [],
}).returning();
const externalRef =
`arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/oauth/${connection!.id}`;
const createVersionSpy = vi.spyOn(awsSecretsManagerProvider, "createVersion").mockResolvedValue({
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-2",
source: "managed",
},
valueSha256: "value-sha-2",
fingerprintSha256: "fingerprint-sha-2",
externalRef,
providerVersionRef: "aws-version-2",
});
const secrets = secretService(db);
const awsVault = await secrets.createProviderConfig(company.id, {
provider: "aws_secrets_manager",
displayName: "AWS OAuth vault",
config: { region: "us-east-1", namespace: "oauth-test", secretNamePrefix: "paperclip" },
});
const resolveSpy = vi.spyOn(awsSecretsManagerProvider, "resolveVersion").mockImplementation(async (input) => {
expect(input.material).toMatchObject({
scheme: "aws_secrets_manager_v1",
versionId: "aws-version-2",
});
expect(input.providerVersionRef).toBe("aws-version-2");
expect(input.providerConfig).toEqual(expect.objectContaining({
id: awsVault.id,
provider: "aws_secrets_manager",
}));
return "legacy-access-token";
});
const deterministicKey = `tool-connection/${connection!.id}/oauth/access-token`;
const [existingSecret] = await db.insert(companySecrets).values({
companyId: company.id,
key: deterministicKey,
name: `Existing OAuth access ${randomUUID()}`,
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
status: "active",
managedMode: "paperclip_managed",
externalRef,
latestVersion: 1,
createdByUserId: "test",
lastRotatedAt: new Date(),
}).returning();
await db.insert(companySecretVersions).values({
secretId: existingSecret!.id,
version: 1,
material: {
scheme: "aws_secrets_manager_v1",
secretId: externalRef,
versionId: "aws-version-1",
source: "managed",
},
valueSha256: "value-sha-1",
fingerprintSha256: "fingerprint-sha-1",
providerVersionRef: "aws-version-1",
status: "current",
createdByUserId: "test",
});
const result = await backfillLegacyToolOAuthTokens(db);
expect(result).toMatchObject({
scannedConnections: 1,
migratedConnections: 1,
sanitizedConnections: 1,
createdSecrets: 0,
rotatedSecrets: 1,
accessTokensBackfilled: 1,
refreshTokensBackfilled: 0,
});
expect(createVersionSpy).toHaveBeenCalledWith(expect.objectContaining({
providerConfig: expect.objectContaining({ id: awsVault.id, provider: "aws_secrets_manager" }),
context: expect.objectContaining({
companyId: company.id,
secretKey: deterministicKey,
version: 2,
}),
}));
const [updatedConnection] = await db
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connection!.id));
expect(JSON.stringify(updatedConnection!.config)).not.toContain("legacy-access-token");
expect(JSON.stringify(updatedConnection!.config)).not.toContain("access_token");
const accessRef = updatedConnection!.credentialSecretRefs.find((ref) => ref.configPath === "oauth.access_token")!;
expect(accessRef.secretId).toBe(existingSecret.id);
const [updatedSecret] = await db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, existingSecret.id));
expect(updatedSecret).toMatchObject({
provider: "aws_secrets_manager",
providerConfigId: awsVault.id,
latestVersion: 2,
externalRef,
});
const versions = await db
.select()
.from(companySecretVersions)
.where(eq(companySecretVersions.secretId, existingSecret.id));
expect(versions).toEqual(expect.arrayContaining([
expect.objectContaining({ version: 1, status: "previous" }),
expect.objectContaining({
version: 2,
status: "current",
material: expect.objectContaining({
scheme: "aws_secrets_manager_v1",
versionId: "aws-version-2",
}),
providerVersionRef: "aws-version-2",
}),
]));
await expect(secrets.resolveSecretValue(company.id, accessRef.secretId, "latest", {
consumerType: "tool_connection",
consumerId: connection!.id,
configPath: "oauth.access_token",
actorType: "system",
})).resolves.toBe("legacy-access-token");
expect(resolveSpy).toHaveBeenCalled();
});
});