mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Authorize background project Git with explicit Connector consent (#17798)
This commit is contained in:
1 parent
6874fed05c
commit
baec3abd67
37 files changed
+1265
-156
No files matched your search
@@ -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.
|
||||
@@ -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;
|
||||
+43
-5
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>>(),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -928,6 +928,7 @@ export type {
|
||||
ProjectBudgetSummary,
|
||||
ProjectRepository,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
ProjectGoalRef,
|
||||
|
||||
@@ -357,6 +357,7 @@ export type {
|
||||
Project,
|
||||
ProjectRepository,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectGitAuthorization,
|
||||
ProjectAccessMember,
|
||||
ProjectAccessSubjectType,
|
||||
ProjectDiscoverySummary,
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user