Authorize background project Git with explicit Connector consent (#17798)

This commit is contained in:
Nicky Leach authored and GitHub committed 2026-10-26 17:22:49 -07:00
1 parent 6874fed05c
commit baec3abd67
37 files changed
+1265 -156

No files matched your search

+52
View File
@@ -160,3 +160,55 @@ these company-scoped records when their company is deleted. Run-scoped adapters
Native warm sessions retain their token-free launchers and inactive broker until
session retirement; environment deletion and orderly controller shutdown close
idle owners first. Cleanup failures are logged and do not change the run result.
## Project authorization for background repository work
Background provisioning and workspace maintenance have no responsible task user.
They must not retain a task's identity. A signed-in project workspace writer can
select their own active, company-installed GitHub Connector grant in project
settings and explicitly authorize the project. The binding stores the exact grant
ID and consenting user, never a credential. Revocation records a project lifecycle
event so subscribed providers can reconcile the change.
`GET /api/projects/:id/git-authorization` returns the current binding and only the
viewer's eligible personal grants. `PUT` accepts `{ grantId }` or `{ grantId: null }`.
Agents cannot create this standing consent. Project workspace writers can remove it.
Core rechecks membership, Connector installation/enablement, grant status, and
repository metadata on each background use. Public repositories never require
a connection or credential. Private repositories wait for authorization before
provisioning starts, so missing consent cannot leave a failed anonymous clone.
Repository selections retain the same visibility metadata used by the UI lock.
Legacy workspaces use existing Connector catalog metadata, with anonymous
GitHub discovery only when visibility is unknown. Unknown visibility defers
provisioning; it is never assumed public. It never substitutes another grant
when the selected one fails. Replacing a grant requires explicit reauthorization.
Project consent follows the selected connection's current GitHub repository access,
including repositories added to the project later. It does not create a second
repository approval list. Project repository editors may add sources within that
connection's access; manage permitted repositories in the GitHub connection.
Background export also rechecks the consenting user's project read access and the
project's existing trust policy. Low-trust or unverified projects cannot export a
raw credential through standing consent.
Cloud owner access is rechecked through current Cloud authority and the managed
owner-elevation setting on every private background export; saved local admin
roles cannot replace that authority. An unavailable authority check cannot confer
owner access, though explicit project membership still applies.
The trusted plugin capability `projects.git-credentials` enables
`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)`. The host
binds requests to the active project lifecycle or workspace operation; data, UI,
tool, and proactive calls do not carry this authority. Repository URLs come from
stored project workspaces. Foreground preparation uses the live run's accepted
identity and standard trust checks, even when background project consent exists.
Maintenance ignores originating run/agent context and uses project consent only.
After archival, existing consent remains usable only for host-issued workspace
inspection and removal. New provisioning and new consent remain blocked.
The provider receives a transient GitHub token with its existing provider
permissions. This is not a repository-scoped or read-only token minting API.
Only grant-listed repositories can trigger background export. Plugins must never
save tokens in entities, state, configuration, operation receipts, or logs, and
must send them only to trusted execution infrastructure over HTTPS. Revocation
prevents subsequent credential acquisition; it cannot recall a token already
sent to an in-flight operation. Existing tasks and Git credential fallback rules
outside this plugin API are unchanged.
+22
View File
@@ -123,3 +123,25 @@ own policy and access review.
Gaps: Disable/remove and development reload are management workflows with no default
company agent access.
## Project Git authorization
Project workspace writers can use `PUT /api/projects/{id}/git-authorization` to
consent to their own eligible personal GitHub Connector grant. This route is
board-only; agent requests cannot establish standing consent. `GET` requires
project read access and returns no token. SDK `projects.resolveGitCredential`
requires `projects.git-credentials` and a matching host-issued project operation.
Background work uses the selected project grant. Foreground preparation uses the
active run identity and trust boundary. UI/data/tool invocations have no export
authority. No task identity is retained for maintenance.
Background export rechecks the consenting user's project read access and project
trust policy. Repository access follows the selected connection, including later
project additions; no separate project repository approval list is stored.
Archived projects allow only host-issued workspace inspection and removal using
existing consent; they cannot acquire credentials for provisioning.
Evidence: owned embedded-database grant/route fixtures in
`server/src/__tests__/project-git-authorization.test.ts`, worker invocation
fixtures in `server/src/__tests__/plugin-worker-manager.test.ts`, and capability
and scope denials in `packages/plugins/sdk/tests/host-client-factory.test.ts`.
These are automated fixture checks, not live provider qualification.
@@ -34,6 +34,14 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
`;
expect(chatForeignKey?.definition).toBeTruthy();
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
// Project consent is another later grant consumer; rewind only its known FK.
const [projectForeignKey] = await sql<{ definition: string }[]>`
SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint
WHERE conrelid = 'projects'::regclass
AND conname = 'projects_git_authorization_grant_id_connection_grants_id_fk'
`;
expect(projectForeignKey?.definition).toBeTruthy();
await sql`ALTER TABLE "projects" DROP CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk"`;
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
// AI defaults arrive in 0273/0277 and depend on the composite grant key
@@ -83,6 +91,9 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
await sql.unsafe(
`ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_connection_fk" ${chatForeignKey!.definition}`,
);
await sql.unsafe(
`ALTER TABLE "projects" ADD CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk" ${projectForeignKey!.definition}`,
);
const [connection] = await sql<{ uid: string; ownership: string; transport: string; auth_kind: string }[]>`
SELECT "uid", "ownership", "transport", "auth_kind" FROM "tool_connections" WHERE "id" = ${connectionId}
@@ -108,6 +119,7 @@ describeEmbeddedPostgres("connections v3 schema core migration", () => {
`).rejects.toMatchObject({ code: "23514" });
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
await sql`ALTER TABLE "projects" DROP CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk"`;
await sql`DROP TABLE IF EXISTS "connection_grant_members"`;
await sql`DROP TABLE "connection_grants"`;
await sql`DROP INDEX "tool_connections_company_uid_uq"`;
@@ -0,0 +1,3 @@
ALTER TABLE "projects" ADD COLUMN "git_authorization_grant_id" uuid;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "git_authorized_by_user_id" text;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_git_authorization_grant_id_connection_grants_id_fk" FOREIGN KEY ("git_authorization_grant_id") REFERENCES "public"."connection_grants"("id") ON DELETE set null ON UPDATE no action;
@@ -1,6 +1,6 @@
{
"id": "406521f8-5352-415b-805d-786854381e03",
"prevId": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7",
"id": "2a9439e3-4a2b-4a20-ab2b-2aee3db8cb03",
"prevId": "a0fcd9b8-f125-4985-8795-f567466b9aef",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -28508,6 +28508,13 @@
"primaryKey": false,
"notNull": true
},
"checkout_name": {
"name": "checkout_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"object_key": {
"name": "object_key",
"type": "text",
@@ -28601,8 +28608,8 @@
"method": "btree",
"with": {}
},
"execution_workspace_restore_points_live_generation_uniq": {
"name": "execution_workspace_restore_points_live_generation_uniq",
"execution_workspace_restore_points_live_status_uniq": {
"name": "execution_workspace_restore_points_live_status_uniq",
"columns": [
{
"expression": "execution_workspace_id",
@@ -28615,10 +28622,16 @@
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "checkout_name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'",
"where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')",
"concurrently": false,
"method": "btree",
"with": {}
@@ -47769,6 +47782,18 @@
"primaryKey": false,
"notNull": false
},
"git_authorization_grant_id": {
"name": "git_authorization_grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"git_authorized_by_user_id": {
"name": "git_authorized_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pause_reason": {
"name": "pause_reason",
"type": "text",
@@ -47907,6 +47932,19 @@
],
"onDelete": "no action",
"onUpdate": "no action"
},
"projects_git_authorization_grant_id_connection_grants_id_fk": {
"name": "projects_git_authorization_grant_id_connection_grants_id_fk",
"tableFrom": "projects",
"tableTo": "connection_grants",
"columnsFrom": [
"git_authorization_grant_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
+8 -1
View File
@@ -2640,6 +2640,13 @@
"when": 1793041667443,
"tag": "0378_environment_skill_runtime_hooks",
"breakpoints": true
},
{
"idx": 379,
"version": "7",
"when": 1793049353756,
"tag": "0379_white_giant_girl",
"breakpoints": true
}
]
}
}
+3
View File
@@ -4,6 +4,7 @@ import type { AgentEnvConfig } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { goals } from "./goals.js";
import { agents } from "./agents.js";
import { connectionGrants } from "./tool_access.js";
export const projects = pgTable(
"projects",
@@ -22,6 +23,8 @@ export const projects = pgTable(
color: text("color"),
icon: text("icon"),
env: jsonb("env").$type<AgentEnvConfig>(),
gitAuthorizationGrantId: uuid("git_authorization_grant_id").references(() => connectionGrants.id, { onDelete: "set null" }),
gitAuthorizedByUserId: text("git_authorized_by_user_id"),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
+29
View File
@@ -1444,3 +1444,32 @@ lifecycle inbox listings or the company secret catalog. Do not expose values
through plugin entities, state, data handlers, UI, or logs. Agent instance
secrets retain their existing lifecycle contract. Back up the tenant's existing
local secrets master key together with its database.
### Project Git credentials
Trusted workspace providers can declare `projects.git-credentials` and call
`ctx.projects.resolveGitCredential(projectId, workspaceId, companyId)` from the
matching host-issued project lifecycle or workspace operation. Other invocations
cannot obtain credentials through this method. The workspace must be a current
repository in that project and company; plugins cannot supply arbitrary URLs.
Background work uses a project writer's explicit consent to their own personal,
company-installed GitHub Connector grant. It does not retain task context. Core
checks the exact grant and its owner's active company membership on every use.
Foreground preparation uses the active run's identity and trust policy instead.
Public repositories return null and require no connection. Private repositories
without authorization wait before provisioning. Repository visibility comes from
the Connector catalog and retained project selections; legacy URL-only sources
use anonymous discovery when visibility is unknown. A revoked or invalid private
repository authorization fails closed. There is no fallback to another grant.
Project consent follows the connection's current repository access, including
sources added later, rather than a separate project repository approval list.
The consenting user must retain project read access, and background credential
export requires the project's existing trust policy to allow standard execution.
Archived projects keep credential access only for host-issued workspace inspection
and removal with existing consent; provisioning remains blocked.
The return value is transient raw credential material with the Connector's
existing permissions. This capability is for trusted execution infrastructure,
not agent-facing tools or UI. Never expose or persist it. See
[GitHub identity](../../../doc/execution-github-identity.md#project-authorization-for-background-repository-work).
@@ -202,6 +202,7 @@ export interface HostServices {
projects: {
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
resolveGitCredential(params: WorkerToHostMethods["projects.resolveGitCredential"][0], context: WorkerHostCallContext): Promise<string | null>;
getInstanceSecrets(params: WorkerToHostMethods["projects.getInstanceSecrets"][0]): Promise<WorkerToHostMethods["projects.getInstanceSecrets"][1]>;
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
@@ -443,6 +444,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.list": "projects.read",
"projects.get": "projects.read",
"projects.getInstanceSecrets": "projects.instance-secrets",
"projects.resolveGitCredential": "projects.git-credentials",
"projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
@@ -834,6 +836,14 @@ export function createHostClientHandlers(
"projects.list": gated("projects.list", async (params) => {
return services.projects.list(params);
}),
"projects.resolveGitCredential": gated("projects.resolveGitCredential", async (params, context) => {
const companyId = resolveRequiredCompanyId("projects.resolveGitCredential", params, context);
const scope = context?.invocationScope?.projectGit;
if (!scope || scope.projectId !== params.projectId || (scope.workspaceId && scope.workspaceId !== params.workspaceId)) {
throw new Error("Git credentials require a matching host project operation");
}
return services.projects.resolveGitCredential({ ...params, companyId }, context!);
}),
"projects.getInstanceSecrets": gated("projects.getInstanceSecrets", async (params, context) => {
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
return services.projects.getInstanceSecrets({ ...params, companyId });
+5
View File
@@ -289,6 +289,7 @@ export type PluginRpcErrorCode =
*/
export interface PluginInvocationScope {
companyId: string;
projectGit?: { projectId: string; workspaceId?: string; runId?: string; agentId?: string; operation?: "prepare" | "inspect" | "remove" };
}
/**
@@ -1796,6 +1797,10 @@ export interface WorkerToHostMethods {
params: { companyId: string; limit?: number; offset?: number },
result: Project[],
];
"projects.resolveGitCredential": [
params: { projectId: string; workspaceId: string; companyId: string },
result: string | null,
];
"projects.getInstanceSecrets": [
params: { projectId: string; companyId: string },
result: Record<string, string>,
+4
View File
@@ -1032,6 +1032,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
},
projects: {
async resolveGitCredential() {
requireCapability(manifest, capabilitySet, "projects.git-credentials");
throw new Error("Git credentials require a matching host project operation");
},
async getInstanceSecrets(projectId, companyId) {
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
requireCapability(manifest, capabilitySet, "events.subscribe");
+3
View File
@@ -875,6 +875,9 @@ export interface PluginEntitiesClient {
* @see PLUGIN_SPEC.md §7 — Project Workspaces
*/
export interface PluginProjectsClient {
/** Transient GitHub credential for a stored repository in the current host-issued
* lifecycle/workspace invocation. Requires projects.git-credentials. Never persist or log. */
resolveGitCredential(projectId: string, workspaceId: string, companyId: string): Promise<string | null>;
/** Trusted worker access to this plugin’s native project credentials.
* Requires projects.instance-secrets; never expose via UI/data handlers. */
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
@@ -720,6 +720,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
projects: {
async resolveGitCredential(projectId, workspaceId, companyId) {
return callHost("projects.resolveGitCredential", { projectId, workspaceId, companyId });
},
async list(input) {
return callHost("projects.list", {
companyId: input.companyId,
@@ -452,3 +452,25 @@ describe("project instance secrets", () => {
expect(getInstanceSecrets).toHaveBeenCalledWith(params);
});
});
describe("project Git credentials", () => {
it("requires capability, company, project and repository scope before calling credential services", async () => {
const resolveGitCredential = vi.fn(async () => "transient-token");
const services = { projects: { resolveGitCredential } } as unknown as HostServices;
const params = { companyId: "company-a", projectId: "project-a", workspaceId: "workspace-a" };
const context = { invocationScope: { companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "workspace-a", runId: "run-a", agentId: "agent-a" } } };
const denied = createHostClientHandlers({ pluginId: "fixture", capabilities: [], services });
await expect(denied["projects.resolveGitCredential"](params, context)).rejects.toBeInstanceOf(CapabilityDeniedError);
const allowed = createHostClientHandlers({ pluginId: "fixture", capabilities: ["projects.git-credentials"], services });
for (const invalid of [undefined, { invocationScope: { companyId: "other" } },
{ invocationScope: { companyId: "company-a" } },
{ invocationScope: { companyId: "company-a", projectGit: { projectId: "other" } } },
{ invocationScope: { companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "other" } } },
{ ...context, invalidInvocationScope: true }]) {
await expect(allowed["projects.resolveGitCredential"](params, invalid)).rejects.toThrow();
}
expect(resolveGitCredential).not.toHaveBeenCalled();
await expect(allowed["projects.resolveGitCredential"](params, context)).resolves.toBe("transient-token");
expect(resolveGitCredential).toHaveBeenCalledWith(params, context);
});
});
+1
View File
@@ -1325,6 +1325,7 @@ export const PLUGIN_CAPABILITIES = [
"companies.read",
"projects.read",
"projects.instance-secrets",
"projects.git-credentials",
"project.workspaces.read",
"execution.workspaces.read",
"workspaces.manage",
+1
View File
@@ -928,6 +928,7 @@ export type {
ProjectBudgetSummary,
ProjectRepository,
ProjectRepositoryOptions,
ProjectGitAuthorization,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
+1
View File
@@ -357,6 +357,7 @@ export type {
Project,
ProjectRepository,
ProjectRepositoryOptions,
ProjectGitAuthorization,
ProjectAccessMember,
ProjectAccessSubjectType,
ProjectDiscoverySummary,
+6
View File
@@ -6,6 +6,12 @@ import type {
} from "./workspace-runtime.js";
import type { AgentEnvConfig } from "./secrets.js";
export interface ProjectGitAuthorization {
grantId: string | null;
authorizedByUserId: string | null;
options: Array<{ grantId: string; connectionName: string; login: string }>;
}
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
export type ProjectWorkspaceVisibility = "default" | "advanced";
export type ProjectVisibility = "open" | "private";
@@ -98,13 +98,13 @@ rl.on("line", (line) => {
id: message.id,
result: {
ok: true,
supportedMethods: ["getData", "performAction"],
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"],
},
});
return;
}
if (method === "getData" || method === "performAction") {
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) {
sendNestedHostRequest(message, message.paperclipInvocation?.id);
return;
}
@@ -1,3 +1,4 @@
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
import express from "express";
import request from "supertest";
@@ -71,129 +72,7 @@ const support = await getEmbeddedPostgresTestSupport();
await database?.cleanup();
vi.unstubAllEnvs();
}, 60_000);
async function seed() {
const companyId = randomUUID(),
agentId = randomUUID(),
runId = randomUUID(),
issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: companyId,
issuePrefix: companyId.slice(0, 8),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Shared",
role: "engineer",
adapterType: "codex_local",
});
await db
.insert(issues)
.values({ id: issueId, companyId, title: "Identity test" });
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId },
});
await db.insert(companyMemberships).values(
["A", "B"].map((principalId) => ({
companyId,
principalType: "user",
principalId,
status: "active",
membershipRole: "member",
})),
);
await initializeRunIdentity(db, {
companyId,
runId,
responsibleUserId: "A",
cause: "instruction",
});
return { companyId, agentId, runId, issueId };
}
async function grant(
input: Awaited<ReturnType<typeof seed>>,
user: string,
dedicated = false,
) {
const applicationId = randomUUID(),
connectionId = randomUUID(),
secretId = randomUUID(),
definitionId = randomUUID(),
id = randomUUID();
await db.insert(toolApplications).values({
id: applicationId,
companyId: input.companyId,
name: applicationId,
type: "mcp_http",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: input.companyId,
applicationId,
name: connectionId,
uid: connectionId,
transport: "mcp_remote",
status: "active",
enabled: true,
credentialPolicy: dedicated ? "per_agent" : "per_user",
config: { sourceTemplateKey: "github" },
});
await db.insert(toolConnectionInstalls).values({
companyId: input.companyId,
connectionId,
targetType: "agent",
targetId: input.agentId,
});
if (!dedicated)
await db.insert(userSecretDefinitions).values({
id: definitionId,
companyId: input.companyId,
key: definitionId,
name: "Test GitHub",
});
await db.insert(companySecrets).values({
id: secretId,
companyId: input.companyId,
key: secretId,
name: `Test token ${secretId}`,
scope: dedicated ? "company" : "user",
ownerUserId: dedicated ? null : user,
userSecretDefinitionId: dedicated ? null : definitionId,
});
await db.insert(connectionGrants).values({
id,
companyId: input.companyId,
connectionId,
kind: dedicated ? "agent" : "user",
subjectUserId: dedicated ? null : user,
subjectAgentId: dedicated ? input.agentId : null,
status: "active",
credentialSecretRefs: [
{
secretId,
configPath: "oauth.access_token",
versionSelector: "latest",
},
],
providerTenant: {
github: {
userId: user,
login: user,
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["1"],
installationOwnerLogins: [user],
},
},
});
return { id, connectionId, secretId, definitionId };
}
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
const input = await seed(), selected = await grant(input, "A");
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
@@ -260,24 +139,6 @@ const support = await getEmbeddedPostgresTestSupport();
} finally { capture.flush(); await observed.$client.end(); }
});
async function switchTo(
input: Awaited<ReturnType<typeof seed>>,
user: string,
) {
const id = randomUUID();
await db.insert(issueComments).values({
id,
companyId: input.companyId,
issueId: input.issueId,
authorUserId: user,
body: "Next instruction",
});
const context = await reserveSteeredIdentity(db, {
...input,
messageId: id,
});
await acceptSteeredIdentity(db, context!);
}
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
const input = await seed();
await grant(input, "A");
@@ -0,0 +1,151 @@
import { randomUUID } from "node:crypto";
import { agents, companies, companyMemberships, companySecrets, connectionGrants, heartbeatRuns, issueComments, issues, toolApplications, toolConnectionInstalls, toolConnections, userSecretDefinitions, type Db } from "@paperclipai/db";
import { initializeRunIdentity, reserveSteeredIdentity, acceptSteeredIdentity } from "../../services/run-identity.js";
export function githubCredentialsFixture(getDb: () => Db) {
async function seed() {
const db = getDb();
const companyId = randomUUID(),
agentId = randomUUID(),
runId = randomUUID(),
issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: companyId,
issuePrefix: companyId.slice(0, 8),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Shared",
role: "engineer",
adapterType: "codex_local",
});
await db
.insert(issues)
.values({ id: issueId, companyId, title: "Identity test" });
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId },
});
await db.insert(companyMemberships).values(
["A", "B"].map((principalId) => ({
companyId,
principalType: "user",
principalId,
status: "active",
membershipRole: "member",
})),
);
await initializeRunIdentity(db, {
companyId,
runId,
responsibleUserId: "A",
cause: "instruction",
});
return { companyId, agentId, runId, issueId };
}
async function grant(
input: Awaited<ReturnType<typeof seed>>,
user: string,
dedicated = false,
) {
const db = getDb();
const applicationId = randomUUID(),
connectionId = randomUUID(),
secretId = randomUUID(),
definitionId = randomUUID(),
id = randomUUID();
await db.insert(toolApplications).values({
id: applicationId,
companyId: input.companyId,
name: applicationId,
type: "mcp_http",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId: input.companyId,
applicationId,
name: connectionId,
uid: connectionId,
transport: "mcp_remote",
status: "active",
enabled: true,
credentialPolicy: dedicated ? "per_agent" : "per_user",
config: { sourceTemplateKey: "github" },
});
await db.insert(toolConnectionInstalls).values({
companyId: input.companyId,
connectionId,
targetType: "agent",
targetId: input.agentId,
});
if (!dedicated)
await db.insert(userSecretDefinitions).values({
id: definitionId,
companyId: input.companyId,
key: definitionId,
name: "Test GitHub",
});
await db.insert(companySecrets).values({
id: secretId,
companyId: input.companyId,
key: secretId,
name: `Test token ${secretId}`,
scope: dedicated ? "company" : "user",
ownerUserId: dedicated ? null : user,
userSecretDefinitionId: dedicated ? null : definitionId,
});
await db.insert(connectionGrants).values({
id,
companyId: input.companyId,
connectionId,
kind: dedicated ? "agent" : "user",
subjectUserId: dedicated ? null : user,
subjectAgentId: dedicated ? input.agentId : null,
status: "active",
credentialSecretRefs: [
{
secretId,
configPath: "oauth.access_token",
versionSelector: "latest",
},
],
providerTenant: {
github: {
userId: user,
login: user,
installationCount: 1,
repositoryCount: 1,
repositorySelection: "selected",
installationIds: ["1"],
installationOwnerLogins: [user],
},
},
});
return { id, connectionId, secretId, definitionId };
}
async function switchTo(
input: Awaited<ReturnType<typeof seed>>,
user: string,
) {
const db = getDb();
const id = randomUUID();
await db.insert(issueComments).values({
id,
companyId: input.companyId,
issueId: input.issueId,
authorUserId: user,
body: "Next instruction",
});
const context = await reserveSteeredIdentity(db, {
...input,
messageId: id,
});
await acceptSteeredIdentity(db, context!);
}
return { seed, grant, switchTo };
}
@@ -280,6 +280,33 @@ describe("plugin-worker-manager stderr failure context", () => {
}
});
it("mints project Git scope only for project lifecycle and workspace operations", async () => {
const handle = createPluginWorkerHandle("test.plugin", {
entrypointPath: INVOCATION_SCOPE_WORKER_ENTRYPOINT, manifest: TEST_MANIFEST, config: {},
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, apiVersion: 1,
hostHandlers: { "companies.get": (async (_params, context) => context?.invocationScope) as never },
});
const common = { companyId: "company-a", params: { mode: "echo", requestedCompanyId: "company-a" } };
try {
await handle.start();
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "project", resourceId: "project-a" } as never))
.resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a" } });
await expect(handle.call("resourceLifecycle", { ...common, resourceType: "agent", resourceId: "agent-a" } as never))
.resolves.toEqual({ companyId: "company-a" });
for (const kind of ["prepare", "inspect", "remove", "finalize"]) {
const params = { ...common, projectId: "project-a", runId: "run-a", agentId: "agent-a", operation: {
kind, source: { id: "workspace-a" }, binding: { projectWorkspaceId: "workspace-a" } } };
await expect(handle.call("workspaceOperation", params as never)).resolves.toEqual({ companyId: "company-a",
...(kind === "finalize" ? {} : { projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: kind,
...(kind === "prepare" ? { runId: "run-a", agentId: "agent-a" } : {}) } }) });
}
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: null,
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a", projectGit: { projectId: "project-a", workspaceId: "workspace-a", operation: "prepare" } });
await expect(handle.call("workspaceOperation", { ...common, projectId: "project-a", runId: null, agentId: "agent-a",
operation: { kind: "prepare", source: { id: "workspace-a" } } } as never)).resolves.toEqual({ companyId: "company-a" });
} finally { await handle.stop().catch(() => undefined); }
});
it("passes performAction invocation scope to nested worker host calls", async () => {
const companiesGet = vi.fn(async (
params: { companyId: string },
@@ -0,0 +1,320 @@
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createDb, authUsers, companyMemberships, connectionGrants, executionWorkspaces, heartbeatRuns, instanceUserRoles, issues, projectAccessMembers, projects, projectWorkspaces, resourceLifecycleEvents, toolConnectionInstalls, toolConnections } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import { ghFetch } from "../services/github-fetch.js";
import { projectRoutes } from "../routes/projects.js";
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { errorHandler } from "../middleware/index.js";
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const vault = vi.hoisted(() => ({
resolveUserSecretValue: vi.fn(async (_company: string, input: { responsibleUserId: string }) => ({ value: `test-token-${input.responsibleUserId}` })),
resolveSecretValue: vi.fn(async () => "test-dedicated-token"),
}));
vi.mock("../services/secrets.js", () => ({ secretService: () => vault }));
vi.mock("../services/github-fetch.js", async importOriginal => ({
...await importOriginal<typeof import("../services/github-fetch.js")>(),
ghFetch: vi.fn(async () => new Response(null, { status: 404 })),
}));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("explicit project Git authorization", () => {
let db: ReturnType<typeof createDb>, database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-project-git-authorization-");
db = createDb(database.connectionString);
await db.insert(authUsers).values(["A", "B"].map(id => ({
id, name: id, email: `${id}@example.invalid`, createdAt: new Date(), updatedAt: new Date(),
})));
}, 30_000);
afterAll(async () => { await database?.cleanup(); }, 60_000);
async function projectAuthorizationFixture() {
const input = await seed();
const selected = await grant(input, "A");
const [project] = await db.insert(projects).values({ companyId: input.companyId, name: "Authorized project" }).returning();
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: input.companyId, projectId: project.id,
name: "Repository", repoUrl: "https://github.com/fixture/project.git" }).returning();
await db.update(issues).set({ projectId: project.id, projectWorkspaceId: workspace.id }).where(eq(issues.id, input.issueId));
await db.insert(toolConnectionInstalls).values({ companyId: input.companyId, connectionId: selected.connectionId, targetType: "company", targetId: input.companyId });
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: "2020-01-01T00:00:00Z", repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: true }] } } }).where(eq(connectionGrants.id, selected.id));
const service = projectGitAuthorizationService(db);
const request = { companyId: input.companyId, projectId: project.id, workspaceId: workspace.id };
return { input, selected, project, workspace, service, request, scope: { projectId: project.id } };
}
it("uses explicit project consent without a task and journals consent changes", async () => {
const f = await projectAuthorizationFixture();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
expect(await f.service.read(f.input.companyId, f.project.id, "B")).toMatchObject({ options: [] });
await expect(f.service.set(f.input.companyId, f.project.id, "B", f.selected.id)).rejects.toThrow("own active");
const saved = await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(saved).toMatchObject({ grantId: f.selected.id, authorizedByUserId: "A" });
expect(JSON.stringify(saved)).not.toContain("test-token");
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await db.update(issues).set({ responsibleUserId: "B" }).where(eq(issues.id, f.input.issueId));
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
expect((await db.select().from(resourceLifecycleEvents).where(eq(resourceLifecycleEvents.resourceId, f.project.id))).length).toBeGreaterThan(0);
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("Authorize this project");
});
it("rechecks the consenting user's project access before background credential export", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, f.project.id));
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("rechecks current Cloud owner elevation for consent on another user's private project", async () => {
const f = await projectAuthorizationFixture();
await db.update(projects).set({ visibility: "private", privacyOwnerUserId: "B" }).where(eq(projects.id, f.project.id));
vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "fixture-tenant-token");
vi.stubEnv("PAPERCLIP_CLOUD_API_ORIGIN", "https://cloud.example.invalid");
vi.stubEnv("PAPERCLIP_CLOUD_STACK_ID", "fixture-stack");
vi.stubEnv("PAPERCLIP_STACK_SLUG", "fixture-stack");
const managed = (enabled: boolean) => vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", JSON.stringify({
v: 1, mode: "cloud", catalogVersion: "test", features: { enableOwnerInstanceAdmin: enabled }, plugins: { autoInstall: [] },
}));
const portfolio = (role = "owner", stackSlug = "fixture-stack") => Response.json({ stacks: [{ stackSlug, role, isCurrent: true }] });
const fetch = vi.fn(async () => portfolio());
vi.stubGlobal("fetch", fetch); managed(true);
try {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", source: "cloud_tenant", userId: "A",
companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler);
expect((await request(app).put(`/api/projects/${f.project.id}/git-authorization`).send({ grantId: f.selected.id })).status).toBe(200);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
const cleanup = { ...f.scope, workspaceId: f.workspace.id, operation: "remove" as const };
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
expect(fetch).toHaveBeenCalledWith(new URL("https://cloud.example.invalid/v1/tenant/portfolio"), expect.objectContaining({
headers: expect.objectContaining({ "x-paperclip-cloud-user-id": "A", "x-paperclip-cloud-stack-id": "fixture-stack" }),
}));
// Stale local roles must never replace current Cloud authority.
await db.insert(instanceUserRoles).values({ userId: "A", role: "instance_admin" });
for (const denied of ["role", "setting", "unavailable", "stack"] as const) {
managed(denied !== "setting");
fetch.mockImplementation(async () => denied === "unavailable" ? new Response(null, { status: 503 })
: portfolio(denied === "role" ? "admin" : "owner", denied === "stack" ? "different-stack" : "fixture-stack"));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, cleanup)).rejects.toThrow("private");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
}
await db.insert(projectAccessMembers).values({ companyId: f.input.companyId, projectId: f.project.id, subjectType: "user", subjectId: "A" });
fetch.mockImplementation(async () => new Response(null, { status: 503 }));
expect(await f.service.resolve(f.request, cleanup)).toBe("test-token-A");
} finally {
await db.delete(instanceUserRoles).where(eq(instanceUserRoles.userId, "A"));
vi.unstubAllGlobals(); vi.unstubAllEnvs();
}
});
it("does not lend background consent to a low-trust project", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.input.companyId, projectIds: [f.project.id],
} } } }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the connection's current repository access for sources added after project authorization", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Added repository", repoUrl: "https://github.com/fixture/added",
metadata: { githubRepositoryUrl: "https://github.com/fixture/added", githubRepositoryPrivate: true },
}).returning();
const request = { ...f.request, workspaceId: workspace.id };
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositoryCount: 2, repositories: [...saved.providerTenant!.github!.repositories!,
{ id: "2", fullName: "fixture/added", installationId: "1", private: true }],
} } }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(request, f.scope)).toBe("test-token-A");
await db.update(connectionGrants).set({ providerTenant: saved.providerTenant }).where(eq(connectionGrants.id, f.selected.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(request, f.scope)).rejects.toThrow("does not include this repository");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("never requests credentials or requires a connection for a known public repository", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: false } }).where(eq(projectWorkspaces.id, f.workspace.id));
await db.update(connectionGrants).set({ providerTenant: null, status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
vi.mocked(ghFetch).mockClear(); vault.resolveUserSecretValue.mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(ghFetch).not.toHaveBeenCalled(); expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["inspect", "remove"] as const)("allows authorized %s after archive while denying provisioning and revoked consent", async operation => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: true } })
.where(eq(projectWorkspaces.id, f.workspace.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
const scope = { ...f.scope, workspaceId: f.workspace.id, operation };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
vault.resolveUserSecretValue.mockClear();
for (const deniedScope of [f.scope, { ...f.scope, operation }, { ...scope, operation: "prepare" as const },
{ ...scope, workspaceId: randomUUID() }, { ...scope, runId: f.input.runId, agentId: f.input.agentId }]) {
await expect(f.service.resolve(f.request, deniedScope)).rejects.toThrow();
}
await expect(f.service.set(f.input.companyId, f.project.id, "A", f.selected.id)).rejects.toThrow("Active project");
await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("authorization is unavailable");
await f.service.set(f.input.companyId, f.project.id, "A", null);
await expect(f.service.resolve(f.request, scope)).rejects.toThrow("Authorize this project");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["revoked", "disabled"])("ignores %s private catalog entries when the current repository is public", async stale => {
const f = await projectAuthorizationFixture();
if (stale === "revoked") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
else await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
const current = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, current.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, current.id));
vault.resolveUserSecretValue.mockClear(); vi.mocked(ghFetch).mockClear();
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled(); expect(ghFetch).not.toHaveBeenCalled();
});
it.each([true, false])("uses refreshed catalog visibility after a repository becomes private=%s", async isPrivate => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ metadata: { githubRepositoryUrl: "https://github.com/fixture/project", githubRepositoryPrivate: !isPrivate } }).where(eq(projectWorkspaces.id, f.workspace.id));
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, f.selected.id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: isPrivate }] } } }).where(eq(connectionGrants.id, f.selected.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe(isPrivate ? "test-token-A" : null);
});
it("uses repository access refresh time rather than unrelated grant updates for conflicting active catalogs", async () => {
const f = await projectAuthorizationFixture(), current = await grant(f.input, "A");
for (const [id, privateValue, refreshedAt] of [[f.selected.id, true, "2020-01-01T00:00:00Z"], [current.id, false, "2021-01-01T00:00:00Z"]] as const) {
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, id));
await db.update(connectionGrants).set({ providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
lastAccessRefreshAt: refreshedAt, repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: privateValue }] } } }).where(eq(connectionGrants.id, id));
}
await db.update(connectionGrants).set({ updatedAt: new Date() }).where(eq(connectionGrants.id, f.selected.id));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
});
it("never lets a legacy catalog's OAuth update outrank an observed repository refresh", async () => {
const f = await projectAuthorizationFixture(), legacy = await grant(f.input, "A");
const [saved] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, legacy.id));
await db.update(connectionGrants).set({ updatedAt: new Date(), providerTenant: { ...saved.providerTenant, github: { ...saved.providerTenant!.github!,
repositories: [{ id: "1", fullName: "fixture/project", installationId: "1", private: false }] } } }).where(eq(connectionGrants.id, legacy.id));
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
expect(await f.service.resolve(f.request, f.scope)).toBe("test-token-A");
});
it("discovers legacy URL-only public sources anonymously and defers unknown private sources", async () => {
const f = await projectAuthorizationFixture();
await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/legacy" }).where(eq(projectWorkspaces.id, f.workspace.id));
vi.mocked(ghFetch).mockResolvedValueOnce(Response.json({ full_name: "fixture/legacy", private: false }));
expect(await f.service.resolve(f.request, f.scope)).toBeNull();
const [, options] = vi.mocked(ghFetch).mock.calls.at(-1)!;
expect(new Headers(options?.headers).has("Authorization")).toBe(false);
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("visibility is unavailable");
});
it.each(["grant", "connection", "membership", "repository", "archive"])("rechecks %s and never falls back to another grant", async change => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "A");
if (change === "grant") await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, f.selected.id));
if (change === "connection") await db.update(toolConnections).set({ enabled: false }).where(eq(toolConnections.id, f.selected.connectionId));
if (change === "membership") await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.companyId, f.input.companyId));
if (change === "repository") await db.update(projectWorkspaces).set({ repoUrl: "https://github.com/fixture/other" }).where(eq(projectWorkspaces.id, f.workspace.id));
if (change === "archive") await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, f.project.id));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("uses the live foreground run rather than project consent and denies mismatched scopes", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await grant(f.input, "B");
await switchTo(f.input, "B");
expect(await f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).toBe("test-token-B");
for (const scope of [undefined, { projectId: randomUUID() }, { ...f.scope, workspaceId: randomUUID() }]) {
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
}
await expect(f.service.resolve({ ...f.request, workspaceId: randomUUID() }, f.scope)).rejects.toThrow();
await expect(f.service.resolve({ ...f.request, companyId: randomUUID() }, f.scope)).rejects.toThrow();
});
it.each(["configured", "bound"])("authorizes a %s workspace selected from another project, but not an unselected source", async selection => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
const [bound] = selection === "bound" ? await db.insert(executionWorkspaces).values({
companyId: f.input.companyId, projectId: f.project.id, projectWorkspaceId: f.workspace.id,
sourceIssueId: f.input.issueId, name: "Selected workspace", mode: "isolated_workspace", strategyType: "git_worktree", status: "idle",
}).returning() : [];
await db.update(issues).set({ projectId: taskProject.id,
projectWorkspaceId: selection === "configured" ? f.workspace.id : null,
executionWorkspaceId: bound?.id ?? null,
}).where(eq(issues.id, f.input.issueId));
const scope = { ...f.scope, workspaceId: f.workspace.id, runId: f.input.runId, agentId: f.input.agentId };
expect(await f.service.resolve(f.request, scope)).toBe("test-token-A");
const [unselected] = await db.insert(projectWorkspaces).values({ companyId: f.input.companyId,
projectId: f.project.id, name: "Unselected source", repoUrl: f.workspace.repoUrl,
}).returning();
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve({ ...f.request, workspaceId: unselected.id }, { ...scope, workspaceId: unselected.id })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.input.runId));
await expect(f.service.resolve(f.request, scope)).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it.each(["", "secret://fixture", "invalid\ntoken"])("rejects invalid transient credential material", async token => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
vault.resolveUserSecretValue.mockResolvedValueOnce({ value: token });
await expect(f.service.resolve(f.request, f.scope)).rejects.toThrow("credential unavailable");
});
it.each(["task", "source"])("checks access to the %s project before exporting foreground credentials", async privateProject => {
const f = await projectAuthorizationFixture();
const [taskProject] = await db.insert(projects).values({ companyId: f.input.companyId, name: "Task project" }).returning();
await db.update(issues).set({ projectId: taskProject.id }).where(eq(issues.id, f.input.issueId));
await db.update(projects).set({ visibility: "private" })
.where(eq(projects.id, privateProject === "task" ? taskProject.id : f.project.id));
vault.resolveUserSecretValue.mockClear();
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow();
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
} finally { vi.unstubAllEnvs(); }
});
it("never lends project consent to low-trust foreground preparation", async () => {
const f = await projectAuthorizationFixture();
await f.service.set(f.input.companyId, f.project.id, "A", f.selected.id);
await db.update(issues).set({ sourceTrust: { preset: LOW_TRUST_REVIEW_PRESET, disposition: "quarantined", sourceIssueId: f.input.issueId } })
.where(eq(issues.id, f.input.issueId));
vault.resolveUserSecretValue.mockClear();
await expect(f.service.resolve(f.request, { ...f.scope, runId: f.input.runId, agentId: f.input.agentId })).rejects.toThrow("low-trust");
expect(vault.resolveUserSecretValue).not.toHaveBeenCalled();
});
it("requires a board project writer and grant owner on the consent route", async () => {
const f = await projectAuthorizationFixture();
function app(userId: string, agent = false) {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { req.actor = agent ? { type: "agent", agentId: f.input.agentId, companyId: f.input.companyId }
: { type: "board", source: "session", userId, companyIds: [f.input.companyId], isInstanceAdmin: true }; next(); });
app.use("/api", projectRoutes(db)); app.use(errorHandler); return app;
}
const url = `/api/projects/${f.project.id}/git-authorization`;
expect((await request(app("B")).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A", true)).put(url).send({ grantId: f.selected.id })).status).toBe(403);
expect((await request(app("A")).put(url).send({ grantId: f.selected.id })).status).toBe(200);
const read = await request(app("A")).get(url);
expect(read.status).toBe(200); expect(JSON.stringify(read.body)).not.toContain("test-token");
expect((await request(app("A")).put(url).send({ grantId: null })).status).toBe(200);
});
});
@@ -33,6 +33,14 @@ const support = await getEmbeddedPostgresTestSupport();
expect(fetched?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true);
});
it("retains and refreshes repository visibility without replacing the workspace", async () => {
const svc = projectService(db);
const source = { ...repo("30"), private: true };
const created = await svc.createWithRepositories(companyId, { name: "Visibility" }, [source]);
expect(created.workspaces[0].metadata).toMatchObject({ githubRepositoryPrivate: true, githubRepositoryUrl: source.url });
const updated = await svc.replaceRepositories(created.id, [{ ...source, private: false }]);
expect(updated?.workspaces[0]).toMatchObject({ id: created.workspaces[0].id, metadata: { githubRepositoryPrivate: false, githubRepositoryUrl: source.url } });
});
it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => {
const svc = projectService(db);
const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]);
+21
View File
@@ -1430,6 +1430,8 @@ const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/project-repositories",
"PUT /api/projects/{id}/repositories",
"GET /api/projects/{id}/git-authorization",
"PUT /api/projects/{id}/git-authorization",
"DELETE /api/issues/{id}/documents/{key}",
"GET /api/companies/{companyId}/decisions",
"GET /api/cloud/stacks",
@@ -5004,6 +5006,25 @@ registry.registerPath({
},
});
const projectGitAuthorizationResponse = z.object({
grantId: z.string().uuid().nullable(),
authorizedByUserId: z.string().nullable(),
options: z.array(z.object({ grantId: z.string().uuid(), connectionName: z.string(), login: z.string() })),
});
registry.registerPath({
method: "get", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
summary: "Read background project GitHub authorization and the viewer's eligible grants",
request: { params: z.object({ id: z.string() }) },
responses: { 200: r.ok(projectGitAuthorizationResponse), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "put", path: "/api/projects/{id}/git-authorization", tags: ["projects"],
summary: "Authorize or revoke a personal GitHub grant for background project work",
description: "Board project workspace writers can select only their own active company-installed grant. No task authority or token is stored.",
request: { params: z.object({ id: z.string() }), body: jsonBody(z.object({ grantId: z.string().uuid().nullable() }).strict()) },
responses: { 200: r.ok(projectGitAuthorizationResponse), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "put",
path: "/api/projects/{id}/repositories",
+17
View File
@@ -1,3 +1,4 @@
import { projectGitAuthorizationService } from "../services/project-git-authorization.js";
import { skillAccessRequestContext } from "../services/skill-access.js";
import { createHash } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
@@ -296,6 +297,22 @@ export function projectRoutes(db: Db) {
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
});
router.get("/projects/:id/git-authorization", async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).read(project.companyId, project.id, req.actor.userId));
});
router.put("/projects/:id/git-authorization", validate(z.object({ grantId: z.string().uuid().nullable() }).strict()), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project || !(await assertProjectReadAllowed(req, res, project)) || !(await assertProjectWorkspaceWriteAllowed(req, res, project))) return;
if (!req.actor.userId) throw forbidden("A signed-in user is required");
res.json(await projectGitAuthorizationService(db).set(project.companyId, project.id, req.actor.userId, req.body.grantId));
});
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
+4
View File
@@ -363,6 +363,7 @@ export async function resolveManagedGitHubIdentitySelection(
agentId?: string | null;
allowStandingDelegation?: boolean;
excludeGrantId?: string;
explicitGrantId?: string;
},
): Promise<{
configured: boolean;
@@ -405,6 +406,7 @@ export async function resolveManagedGitHubIdentitySelection(
const grants = await db.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, companyId),
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
context.explicitGrantId ? eq(connectionGrants.id, context.explicitGrantId) : undefined,
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
));
const dedicated = context.agentId
@@ -537,6 +539,7 @@ export async function resolveManagedGitHubCredential(
responsibleUserId?: string | null;
agentId?: string | null;
allowStandingDelegation?: boolean;
explicitGrantId?: string;
},
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
@@ -631,6 +634,7 @@ export async function resolveManagedGitHubCredential(
} catch {
failure = { configured: true, identitySource: selection.identitySource, error: "GitHub credentials are temporarily unavailable" };
}
if (context.explicitGrantId) return failure;
// Retry credential acquisition, never the GitHub operation. An alternate
// authorization must still belong to this exact principal and account.
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
@@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"companies.get": ["companies.read"],
"projects.list": ["projects.read"],
"projects.get": ["projects.read"],
"projects.resolveGitCredential": ["projects.git-credentials"],
"projects.getInstanceSecrets": ["projects.instance-secrets"],
"projects.managed.get": ["projects.managed"],
"projects.managed.reconcile": ["projects.managed"],
@@ -1,3 +1,4 @@
import { projectGitAuthorizationService } from "./project-git-authorization.js";
import { projectInstanceSecretService } from "./project-instance-secrets.js";
import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js";
import { createAgentLifecycle } from "./agent-lifecycle.js";
@@ -1863,6 +1864,11 @@ export function buildHostServices(
},
projects: {
async resolveGitCredential(params, context) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
return projectGitAuthorizationService(db).resolve(params, context.invocationScope?.projectGit);
},
async getInstanceSecrets(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
+20 -1
View File
@@ -1158,7 +1158,26 @@ export function createPluginWorkerHandle(
if (!isRecord(params)) return null;
const directCompanyId = readNonEmptyString(params.companyId);
if (directCompanyId) return { companyId: directCompanyId };
if (directCompanyId) {
if (method === "resourceLifecycle" && params.resourceType === "project" && readNonEmptyString(params.resourceId)) {
return { companyId: directCompanyId, projectGit: { projectId: params.resourceId as string } };
}
if (method === "workspaceOperation" && isRecord(params.operation)) {
const operation = params.operation;
const source = operation.kind === "prepare" ? operation.source : operation.binding;
const workspaceId = isRecord(source) ? readNonEmptyString(operation.kind === "prepare" ? source.id : source.projectWorkspaceId) : null;
const projectId = readNonEmptyString(params.projectId);
if (projectId && workspaceId && (operation.kind === "prepare" || operation.kind === "inspect" || operation.kind === "remove")) {
// Only foreground preparation may use a run. Maintenance uses project consent.
const runId = operation.kind === "prepare" ? readNonEmptyString(params.runId) : null;
const agentId = operation.kind === "prepare" ? readNonEmptyString(params.agentId) : null;
if (Boolean(runId) !== Boolean(agentId)) return { companyId: directCompanyId };
return { companyId: directCompanyId, projectGit: { projectId, workspaceId, operation: operation.kind,
...(runId && agentId ? { runId, agentId } : {}) } };
}
}
return { companyId: directCompanyId };
}
if (method === "performAction" && isRecord(params.actorContext)) {
const companyId = readNonEmptyString(params.actorContext.companyId);
@@ -0,0 +1,251 @@
import { and, eq, sql } from "drizzle-orm";
import {
companyMemberships,
connectionGrants,
heartbeatRuns,
issues,
projects,
projectWorkspaces,
toolConnections,
toolConnectionInstalls,
type Db,
} from "@paperclipai/db";
import type { ProjectGitAuthorization } from "@paperclipai/shared";
import { forbidden, notFound } from "../errors.js";
import { resolveManagedGitHubCredential } from "./git-credentials.js";
import { resolveGitHubOperationCredentials } from "./github-operation-credentials.js";
import { normalizeProjectRepositoryUrl, projectRepositoryIsPrivate } from "./project-repositories.js";
import { recordProjectLifecycleEvent } from "./resource-lifecycle-events.js";
import { persistActivity, publishActivity } from "./activity-log.js";
import { secretService } from "./secrets.js";
import { assertTaskWorkspaceExecutionAccess, assertTaskWorkspaceSourceProjectAccess } from "./task-workspace-access.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isCloudManagedInstance } from "./cloud-instance.js";
import { isCurrentCloudOwnerInstanceAdmin, loadCurrentCloudOwnerAuthority } from "./cloud-tenant-authority.js";
function transientGitToken(token: string | undefined): string {
if (!token || token.length > 4096 || /[\0\r\n]/.test(token) || token.startsWith("secret://")) {
throw forbidden("GitHub credential unavailable");
}
return token;
}
/** Consent is for this exact personal grant, independent of tasks and agent delegation. */
export function projectGitAuthorizationService(db: Db) {
async function project(companyId: string, projectId: string, allowArchived = false) {
const [row] = await db
.select()
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)));
if (!row || (!allowArchived && row.archivedAt)) throw notFound("Active project not found");
return row;
}
async function eligible(companyId: string, userId: string) {
const [member] = await db
.select()
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
);
if (!member || member.membershipRole === "viewer") return [];
const rows = await db
.select({ grant: connectionGrants, connection: toolConnections })
.from(connectionGrants)
.innerJoin(
toolConnections,
and(eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId)),
)
.innerJoin(
toolConnectionInstalls,
and(
eq(toolConnectionInstalls.connectionId, toolConnections.id),
eq(toolConnectionInstalls.companyId, companyId),
eq(toolConnectionInstalls.targetType, "company"),
eq(toolConnectionInstalls.targetId, companyId),
),
)
.where(
and(
eq(connectionGrants.companyId, companyId),
eq(connectionGrants.kind, "user"),
eq(connectionGrants.subjectUserId, userId),
eq(connectionGrants.status, "active"),
eq(toolConnections.enabled, true),
eq(toolConnections.status, "active"),
),
);
return rows.filter(
({ grant, connection }) =>
(connection.config?.sourceTemplateKey === "github" ||
connection.transportConfig?.sourceTemplateKey === "github") &&
grant.providerTenant?.github?.repositoryCount &&
grant.providerTenant.github.installationCount > 0,
);
}
async function read(companyId: string, projectId: string, userId: string): Promise<ProjectGitAuthorization> {
const row = await project(companyId, projectId, true);
return {
grantId: row.gitAuthorizationGrantId,
authorizedByUserId: row.gitAuthorizedByUserId,
options: (await eligible(companyId, userId)).map(({ grant, connection }) => ({
grantId: grant.id,
connectionName: connection.name,
login: grant.providerTenant!.github!.login,
})),
};
}
return {
read,
async set(companyId: string, projectId: string, userId: string, grantId: string | null) {
const publication = await db.transaction(async (tx) => {
const [row] = await tx
.select()
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)))
.for("update");
if (!row || (row.archivedAt && grantId)) throw notFound("Active project not found");
if (grantId && !(await eligible(companyId, userId)).some(({ grant }) => grant.id === grantId)) {
throw forbidden("Select your own active company-installed GitHub authorization");
}
await tx
.update(projects)
.set({
gitAuthorizationGrantId: grantId,
gitAuthorizedByUserId: grantId ? userId : null,
updatedAt: new Date(),
})
.where(eq(projects.id, row.id));
await recordProjectLifecycleEvent(tx as unknown as Db, companyId, projectId);
const { publication } = await persistActivity(tx as unknown as Db, {
companyId,
actorType: "user",
actorId: userId,
action: grantId ? "project.git_authorized" : "project.git_authorization_revoked",
entityType: "project",
entityId: projectId,
details: { grantId },
});
return publication;
});
publishActivity(publication);
return read(companyId, projectId, userId);
},
async resolve(
input: { companyId: string; projectId: string; workspaceId: string },
scope?: {
projectId: string;
workspaceId?: string;
runId?: string;
agentId?: string;
operation?: "prepare" | "inspect" | "remove";
},
) {
if (
!scope ||
scope.projectId !== input.projectId ||
(scope.workspaceId && scope.workspaceId !== input.workspaceId)
)
throw forbidden("Git credentials require a matching host project operation");
const cleanup = scope.workspaceId === input.workspaceId && !scope.runId && !scope.agentId &&
(scope.operation === "inspect" || scope.operation === "remove");
const row = await project(input.companyId, input.projectId, cleanup);
const [workspace] = await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, input.companyId),
eq(projectWorkspaces.projectId, input.projectId),
eq(projectWorkspaces.id, input.workspaceId),
),
);
if (!workspace?.repoUrl) throw forbidden("Git credentials require a configured project repository");
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl);
const isPrivate = await projectRepositoryIsPrivate(db, input.companyId, workspace);
if (scope.runId || scope.agentId) {
if (!scope.runId || !scope.agentId) throw forbidden("Incomplete foreground Git identity");
const [run] = await db
.select({
status: heartbeatRuns.status,
issueId: heartbeatRuns.issueId,
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'issueId'`,
taskId: sql<string | null>`${heartbeatRuns.contextSnapshot}->>'taskId'`,
responsibleUserId: heartbeatRuns.responsibleUserId,
nativeIssueId: heartbeatRuns.nativeIssueId,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.id, scope.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, scope.agentId),
),
);
const issueId = run?.issueId ?? run?.nativeIssueId ?? run?.contextIssueId ?? run?.taskId;
const [issue] =
typeof issueId === "string"
? await db
.select({ executionWorkspaceId: issues.executionWorkspaceId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
: [];
if (!run || run.status !== "running" || !issue || typeof issueId !== "string")
throw forbidden("Active task run is unavailable");
await assertTaskWorkspaceExecutionAccess(db, {
type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: scope.agentId, runId: scope.runId,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
}, input.companyId, {
issueId, workspaceId: issue.executionWorkspaceId, projectWorkspaceId: workspace.id,
});
if (!isPrivate) return null;
const result = await resolveGitHubOperationCredentials(db, {
companyId: input.companyId,
runId: scope.runId,
agentId: scope.agentId,
});
if (result.status !== "available") throw forbidden(result.reason ?? "Run GitHub authorization unavailable");
return transientGitToken(result.env.PAPERCLIP_GIT_TOKEN);
}
if (!isPrivate) return null;
if (!row.gitAuthorizationGrantId)
throw forbidden("Authorize this project's GitHub connection before provisioning private repositories");
const selected =
row.gitAuthorizedByUserId &&
(await eligible(input.companyId, row.gitAuthorizedByUserId)).find(
({ grant }) => grant.id === row.gitAuthorizationGrantId,
);
if (!row.gitAuthorizedByUserId || !selected)
throw forbidden("Project GitHub authorization is unavailable; reconnect or select an authorization");
const cloud = isCloudManagedInstance();
const isInstanceAdmin = cloud && await isCurrentCloudOwnerInstanceAdmin(db, row.gitAuthorizedByUserId,
await loadCurrentCloudOwnerAuthority(row.gitAuthorizedByUserId));
await assertTaskWorkspaceSourceProjectAccess(db, {
type: "board", source: cloud ? "cloud_tenant" : "session", userId: row.gitAuthorizedByUserId, isInstanceAdmin,
}, input.companyId, row.id);
if (resolveCoreTrustPreset({ companyId: input.companyId, workspaceSourceProject: row }).kind !== "standard")
throw forbidden("GitHub credentials are not available to low-trust or unverified projects");
// Project consent follows this connection's repository access, including future project sources.
if (
!selected.grant.providerTenant?.github?.repositories?.some(
(r) => r.fullName.toLowerCase() === repo.fullName.toLowerCase(),
)
) {
throw forbidden("Project GitHub authorization does not include this repository; refresh Connector access");
}
const resolved = await resolveManagedGitHubCredential(db, secretService(db), input.companyId, {
responsibleUserId: row.gitAuthorizedByUserId,
explicitGrantId: row.gitAuthorizationGrantId,
allowStandingDelegation: false,
});
if (!resolved.credential || resolved.credential.grantId !== row.gitAuthorizationGrantId)
throw forbidden("Project GitHub credential unavailable; reconnect the selected authorization");
return transientGitToken(resolved.credential.token);
},
};
}
+42 -1
View File
@@ -1,3 +1,6 @@
import { connectionGrants, toolConnections } from "@paperclipai/db";
import { and, eq, sql } from "drizzle-orm";
import { ghFetch } from "./github-fetch.js";
import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
@@ -38,7 +41,8 @@ export function resolveProjectRepositorySelection(
const current = available.find((repo) => repo.id === id);
if (current) return current;
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [] };
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [],
...(typeof retained.metadata?.githubRepositoryPrivate === "boolean" ? { private: retained.metadata.githubRepositoryPrivate } : {}) };
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
});
}
@@ -58,3 +62,40 @@ export function normalizeProjectRepositoryUrl(value: string): { fullName: string
}
return { fullName: path.slice(1), url: `https://github.com${path}` };
}
/** Use the freshest active catalog visibility (the UI lock), then retained selections. Legacy URL-only
* sources need anonymous discovery; unknown visibility must never start a private clone. */
export async function projectRepositoryIsPrivate(
db: import("@paperclipai/db").Db,
companyId: string,
workspace: Pick<ProjectWorkspace, "repoUrl" | "metadata">,
): Promise<boolean> {
const repo = normalizeProjectRepositoryUrl(workspace.repoUrl!);
const visibility = await db.select({
refreshedAt: sql<string | null>`${connectionGrants.providerTenant}->'github'->>'lastAccessRefreshAt'`,
private: sql<boolean | null>`(
select bool_or((repository->>'private')::boolean)
from jsonb_array_elements(coalesce(${connectionGrants.providerTenant}->'github'->'repositories', '[]'::jsonb)) repository
where lower(repository->>'fullName') = ${repo.fullName.toLowerCase()}
)` }).from(connectionGrants).innerJoin(toolConnections, and(
eq(toolConnections.id, connectionGrants.connectionId), eq(toolConnections.companyId, companyId),
)).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.status, "active"),
eq(toolConnections.status, "active"), eq(toolConnections.enabled, true)));
// OAuth refreshes change updatedAt without observing repository visibility.
const current = visibility.filter(row => typeof row.private === "boolean" && Number.isFinite(Date.parse(row.refreshedAt ?? "")))
.sort((a, b) => Date.parse(b.refreshedAt!) - Date.parse(a.refreshedAt!))[0];
if (current) return current.private!;
if (workspace.metadata?.githubRepositoryUrl === repo.url && typeof workspace.metadata.githubRepositoryPrivate === "boolean") {
return workspace.metadata.githubRepositoryPrivate;
}
const legacy = visibility.filter(row => typeof row.private === "boolean");
if (legacy.length && legacy.every(row => row.private === legacy[0]!.private)) return legacy[0]!.private!;
const response = await ghFetch(`https://api.github.com/repos/${repo.fullName}`, {
headers: { Accept: "application/vnd.github+json" }, redirect: "error", signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
const result = await response.json() as { full_name?: string; private?: boolean };
if (result.full_name?.toLowerCase() === repo.fullName.toLowerCase() && result.private === false) return false;
}
throw unprocessable("Repository visibility is unavailable; retry discovery or refresh the GitHub repository selection before provisioning.");
}
+5 -5
View File
@@ -920,7 +920,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const project = await service.create(companyId, data);
for (const repo of repositories) {
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
return (await service.getById(project.id))!;
});
@@ -946,15 +946,15 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
for (const repo of repositories) {
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
if (retained) {
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) {
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url });
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName || retained.metadata?.githubRepositoryPrivate !== repo.private || retained.metadata?.githubRepositoryUrl !== repo.url) {
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url, metadata: { ...retained.metadata, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
continue;
}
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
if (legacy) {
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id } });
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
}
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);
+3
View File
@@ -2,6 +2,7 @@ import type {
Project,
ProjectAccessMember,
ProjectRepositoryOptions,
ProjectGitAuthorization,
ProjectWorkspace,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
@@ -20,6 +21,8 @@ function projectPath(id: string, companyId?: string, suffix = "") {
}
export const projectsApi = {
gitAuthorization: (id: string) => api.get<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization")),
setGitAuthorization: (id: string, grantId: string | null) => api.put<ProjectGitAuthorization>(projectPath(id, undefined, "/git-authorization"), { grantId }),
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
setRepositories: (id: string, repositoryIds: string[]) => api.put<Project>(projectPath(id, undefined, "/repositories"), { repositoryIds }),
list: (companyId: string, opts: { includeArchived?: boolean } = {}) => {
@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { describe, expect, it, vi } from "vitest";
import { projectsApi } from "@/api/projects";
import { ProjectGitAuthorization } from "./ProjectGitAuthorization";
vi.mock("@/api/projects", () => ({
projectsApi: {
gitAuthorization: vi.fn(),
setGitAuthorization: vi.fn(),
},
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("ProjectGitAuthorization", () => {
it("explains standing credential consent and revokes without retaining task context", async () => {
const saved = {
grantId: "grant-a",
authorizedByUserId: "user-a",
options: [
{
grantId: "grant-a",
connectionName: "Work GitHub",
login: "fixture-login",
},
],
};
vi.mocked(projectsApi.gitAuthorization).mockResolvedValue(saved);
vi.mocked(projectsApi.setGitAuthorization).mockResolvedValue({
...saved,
grantId: null,
authorizedByUserId: null,
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(["project-git-authorization", "company-a", "project-a"], saved);
const container = document.createElement("div"),
root = createRoot(container);
document.body.appendChild(container);
try {
await act(async () =>
root.render(
<QueryClientProvider client={client}>
<ProjectGitAuthorization project={{ id: "project-a", companyId: "company-a" } as Project} />
</QueryClientProvider>,
),
);
expect(container.textContent).toContain("existing GitHub permissions");
expect(container.textContent).toContain("Project repository editors can add repositories covered by this connection");
expect(container.textContent).toContain("Manage repository access in the GitHub connection");
expect(container.textContent).toContain("Task runs continue to use their own identity");
expect(container.textContent).toContain("fixture-login");
const authorize = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Authorize project",
)!;
expect(authorize.disabled).toBe(true);
const revoke = [...container.querySelectorAll("button")].find(
(button) => button.textContent === "Revoke authorization",
)!;
await act(async () => {
revoke.click();
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(projectsApi.setGitAuthorization).toHaveBeenCalledWith("project-a", null);
expect(client.getQueryData(["project-git-authorization", "company-a", "project-a"])).toMatchObject({
grantId: null,
});
expect(container.textContent).not.toContain("Revoke authorization");
} finally {
await act(async () => root.unmount());
container.remove();
client.clear();
}
});
});
@@ -0,0 +1,76 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { Button } from "./ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export function ProjectGitAuthorization({ project }: { project: Project }) {
const client = useQueryClient();
const key = ["project-git-authorization", project.companyId, project.id];
const authorization = useQuery({
queryKey: key,
queryFn: () => projectsApi.gitAuthorization(project.id),
});
const [selected, setSelected] = useState<string>("");
const save = useMutation({
mutationFn: (grantId: string | null) => projectsApi.setGitAuthorization(project.id, grantId),
onSuccess: (data) => {
client.setQueryData(key, data);
setSelected("");
},
});
const data = authorization.data;
return (
<section aria-label="Background GitHub authorization" className="flex flex-col gap-3 py-4">
<h3 className="text-sm font-medium">Background GitHub authorization</h3>
<p className="text-sm text-muted-foreground">
Allow this project’s workspace provider to use your GitHub Connector for repository provisioning and maintenance
until revoked. This shares the Connector credential with the provider using its existing GitHub permissions.
Project repository editors can add repositories covered by this connection. Manage repository access in the GitHub connection.
Task runs continue to use their own identity.
</p>
{data?.grantId && (
<p role="status" className="text-sm">
Authorized:{" "}
{data.options.find((option) => option.grantId === data.grantId)?.login ??
"selected grant (unavailable or owned by another member)"}
</p>
)}
{data && (
<div className="flex flex-wrap gap-2">
<Select value={selected} onValueChange={setSelected} disabled={save.isPending}>
<SelectTrigger aria-label="Your GitHub authorization">
<SelectValue placeholder="Select your GitHub account" />
</SelectTrigger>
<SelectContent>
{data.options.map((option) => (
<SelectItem key={option.grantId} value={option.grantId}>
{option.login} · {option.connectionName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={!selected || save.isPending} onClick={() => save.mutate(selected)}>
Authorize project
</Button>
{data.grantId && (
<Button variant="outline" disabled={save.isPending} onClick={() => save.mutate(null)}>
Revoke authorization
</Button>
)}
</div>
)}
{data && !data.options.length && (
<p className="text-sm text-muted-foreground">
Connect your GitHub account and install the Connector for this company to authorize it.
</p>
)}
{(authorization.error || save.error) && (
<p role="alert" className="text-sm text-destructive">
{(authorization.error || save.error)?.message}
</p>
)}
</section>
);
}
+2
View File
@@ -7,6 +7,7 @@ import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } f
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
import { ProjectGitAuthorization } from "./ProjectGitAuthorization";
import { ProjectRepositories } from "./ProjectRepositories";
import { cn, formatDate } from "../lib/utils";
import { environmentsApi } from "../api/environments";
@@ -510,6 +511,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
</div>
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<ProjectGitAuthorization key={`git-${project.id}`} project={project} />
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart