mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Authorize background project Git with explicit Connector consent (#17798)
This commit is contained in:
1 parent
6874fed05c
commit
baec3abd67
37 files changed
+1264
-155
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
|
Native warm sessions retain their token-free launchers and inactive broker until
|
||||||
session retirement; environment deletion and orderly controller shutdown close
|
session retirement; environment deletion and orderly controller shutdown close
|
||||||
idle owners first. Cleanup failures are logged and do not change the run result.
|
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
|
Gaps: Disable/remove and development reload are management workflows with no default
|
||||||
company agent access.
|
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();
|
expect(chatForeignKey?.definition).toBeTruthy();
|
||||||
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
|
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()}`;
|
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
|
||||||
// AI defaults arrive in 0273/0277 and depend on the composite grant key
|
// 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(
|
await sql.unsafe(
|
||||||
`ALTER TABLE "chat_endpoints" ADD CONSTRAINT "chat_endpoints_company_connection_fk" ${chatForeignKey!.definition}`,
|
`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 }[]>`
|
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}
|
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" });
|
`).rejects.toMatchObject({ code: "23514" });
|
||||||
|
|
||||||
await sql`ALTER TABLE "chat_endpoints" DROP CONSTRAINT "chat_endpoints_company_connection_fk"`;
|
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 IF EXISTS "connection_grant_members"`;
|
||||||
await sql`DROP TABLE "connection_grants"`;
|
await sql`DROP TABLE "connection_grants"`;
|
||||||
await sql`DROP INDEX "tool_connections_company_uid_uq"`;
|
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",
|
"id": "2a9439e3-4a2b-4a20-ab2b-2aee3db8cb03",
|
||||||
"prevId": "9c1b4cac-52f1-4c6e-89b3-c9a7816016c7",
|
"prevId": "a0fcd9b8-f125-4985-8795-f567466b9aef",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
"tables": {
|
"tables": {
|
||||||
@@ -28508,6 +28508,13 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
"checkout_name": {
|
||||||
|
"name": "checkout_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
"object_key": {
|
"object_key": {
|
||||||
"name": "object_key",
|
"name": "object_key",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -28601,8 +28608,8 @@
|
|||||||
"method": "btree",
|
"method": "btree",
|
||||||
"with": {}
|
"with": {}
|
||||||
},
|
},
|
||||||
"execution_workspace_restore_points_live_generation_uniq": {
|
"execution_workspace_restore_points_live_status_uniq": {
|
||||||
"name": "execution_workspace_restore_points_live_generation_uniq",
|
"name": "execution_workspace_restore_points_live_status_uniq",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "execution_workspace_id",
|
"expression": "execution_workspace_id",
|
||||||
@@ -28615,10 +28622,16 @@
|
|||||||
"isExpression": false,
|
"isExpression": false,
|
||||||
"asc": true,
|
"asc": true,
|
||||||
"nulls": "last"
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "checkout_name",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"isUnique": true,
|
"isUnique": true,
|
||||||
"where": "\"execution_workspace_restore_points\".\"status\" <> 'deleted'",
|
"where": "\"execution_workspace_restore_points\".\"status\" in ('pending', 'active', 'expiring')",
|
||||||
"concurrently": false,
|
"concurrently": false,
|
||||||
"method": "btree",
|
"method": "btree",
|
||||||
"with": {}
|
"with": {}
|
||||||
@@ -47769,6 +47782,18 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": 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": {
|
"pause_reason": {
|
||||||
"name": "pause_reason",
|
"name": "pause_reason",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -47907,6 +47932,19 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "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": {},
|
"compositePrimaryKeys": {},
|
||||||
+7
@@ -2640,6 +2640,13 @@
|
|||||||
"when": 1793041667443,
|
"when": 1793041667443,
|
||||||
"tag": "0378_environment_skill_runtime_hooks",
|
"tag": "0378_environment_skill_runtime_hooks",
|
||||||
"breakpoints": true
|
"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 { companies } from "./companies.js";
|
||||||
import { goals } from "./goals.js";
|
import { goals } from "./goals.js";
|
||||||
import { agents } from "./agents.js";
|
import { agents } from "./agents.js";
|
||||||
|
import { connectionGrants } from "./tool_access.js";
|
||||||
|
|
||||||
export const projects = pgTable(
|
export const projects = pgTable(
|
||||||
"projects",
|
"projects",
|
||||||
@@ -22,6 +23,8 @@ export const projects = pgTable(
|
|||||||
color: text("color"),
|
color: text("color"),
|
||||||
icon: text("icon"),
|
icon: text("icon"),
|
||||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
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"),
|
pauseReason: text("pause_reason"),
|
||||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||||
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
|
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
|
through plugin entities, state, data handlers, UI, or logs. Agent instance
|
||||||
secrets retain their existing lifecycle contract. Back up the tenant's existing
|
secrets retain their existing lifecycle contract. Back up the tenant's existing
|
||||||
local secrets master key together with its database.
|
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: {
|
projects: {
|
||||||
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||||
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][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]>;
|
getInstanceSecrets(params: WorkerToHostMethods["projects.getInstanceSecrets"][0]): Promise<WorkerToHostMethods["projects.getInstanceSecrets"][1]>;
|
||||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][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.list": "projects.read",
|
||||||
"projects.get": "projects.read",
|
"projects.get": "projects.read",
|
||||||
"projects.getInstanceSecrets": "projects.instance-secrets",
|
"projects.getInstanceSecrets": "projects.instance-secrets",
|
||||||
|
"projects.resolveGitCredential": "projects.git-credentials",
|
||||||
"projects.listWorkspaces": "project.workspaces.read",
|
"projects.listWorkspaces": "project.workspaces.read",
|
||||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||||
@@ -834,6 +836,14 @@ export function createHostClientHandlers(
|
|||||||
"projects.list": gated("projects.list", async (params) => {
|
"projects.list": gated("projects.list", async (params) => {
|
||||||
return services.projects.list(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) => {
|
"projects.getInstanceSecrets": gated("projects.getInstanceSecrets", async (params, context) => {
|
||||||
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
|
const companyId = resolveRequiredCompanyId("projects.getInstanceSecrets", params, context);
|
||||||
return services.projects.getInstanceSecrets({ ...params, companyId });
|
return services.projects.getInstanceSecrets({ ...params, companyId });
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export type PluginRpcErrorCode =
|
|||||||
*/
|
*/
|
||||||
export interface PluginInvocationScope {
|
export interface PluginInvocationScope {
|
||||||
companyId: string;
|
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 },
|
params: { companyId: string; limit?: number; offset?: number },
|
||||||
result: Project[],
|
result: Project[],
|
||||||
];
|
];
|
||||||
|
"projects.resolveGitCredential": [
|
||||||
|
params: { projectId: string; workspaceId: string; companyId: string },
|
||||||
|
result: string | null,
|
||||||
|
];
|
||||||
"projects.getInstanceSecrets": [
|
"projects.getInstanceSecrets": [
|
||||||
params: { projectId: string; companyId: string },
|
params: { projectId: string; companyId: string },
|
||||||
result: Record<string, string>,
|
result: Record<string, string>,
|
||||||
|
|||||||
@@ -1032,6 +1032,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
|
async resolveGitCredential() {
|
||||||
|
requireCapability(manifest, capabilitySet, "projects.git-credentials");
|
||||||
|
throw new Error("Git credentials require a matching host project operation");
|
||||||
|
},
|
||||||
async getInstanceSecrets(projectId, companyId) {
|
async getInstanceSecrets(projectId, companyId) {
|
||||||
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
|
requireCapability(manifest, capabilitySet, "projects.instance-secrets");
|
||||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||||
|
|||||||
@@ -875,6 +875,9 @@ export interface PluginEntitiesClient {
|
|||||||
* @see PLUGIN_SPEC.md §7 — Project Workspaces
|
* @see PLUGIN_SPEC.md §7 — Project Workspaces
|
||||||
*/
|
*/
|
||||||
export interface PluginProjectsClient {
|
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.
|
/** Trusted worker access to this plugin’s native project credentials.
|
||||||
* Requires projects.instance-secrets; never expose via UI/data handlers. */
|
* Requires projects.instance-secrets; never expose via UI/data handlers. */
|
||||||
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
|
getInstanceSecrets(projectId: string, companyId: string): Promise<Record<string, string>>;
|
||||||
|
|||||||
@@ -720,6 +720,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||||||
},
|
},
|
||||||
|
|
||||||
projects: {
|
projects: {
|
||||||
|
async resolveGitCredential(projectId, workspaceId, companyId) {
|
||||||
|
return callHost("projects.resolveGitCredential", { projectId, workspaceId, companyId });
|
||||||
|
},
|
||||||
async list(input) {
|
async list(input) {
|
||||||
return callHost("projects.list", {
|
return callHost("projects.list", {
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
|
|||||||
@@ -452,3 +452,25 @@ describe("project instance secrets", () => {
|
|||||||
expect(getInstanceSecrets).toHaveBeenCalledWith(params);
|
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",
|
"companies.read",
|
||||||
"projects.read",
|
"projects.read",
|
||||||
"projects.instance-secrets",
|
"projects.instance-secrets",
|
||||||
|
"projects.git-credentials",
|
||||||
"project.workspaces.read",
|
"project.workspaces.read",
|
||||||
"execution.workspaces.read",
|
"execution.workspaces.read",
|
||||||
"workspaces.manage",
|
"workspaces.manage",
|
||||||
|
|||||||
@@ -928,6 +928,7 @@ export type {
|
|||||||
ProjectBudgetSummary,
|
ProjectBudgetSummary,
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
ProjectRepositoryOptions,
|
ProjectRepositoryOptions,
|
||||||
|
ProjectGitAuthorization,
|
||||||
ProjectCodebase,
|
ProjectCodebase,
|
||||||
ProjectCodebaseOrigin,
|
ProjectCodebaseOrigin,
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ export type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
ProjectRepositoryOptions,
|
ProjectRepositoryOptions,
|
||||||
|
ProjectGitAuthorization,
|
||||||
ProjectAccessMember,
|
ProjectAccessMember,
|
||||||
ProjectAccessSubjectType,
|
ProjectAccessSubjectType,
|
||||||
ProjectDiscoverySummary,
|
ProjectDiscoverySummary,
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import type {
|
|||||||
} from "./workspace-runtime.js";
|
} from "./workspace-runtime.js";
|
||||||
import type { AgentEnvConfig } from "./secrets.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 ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
|
||||||
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
export type ProjectWorkspaceVisibility = "default" | "advanced";
|
||||||
export type ProjectVisibility = "open" | "private";
|
export type ProjectVisibility = "open" | "private";
|
||||||
|
|||||||
@@ -98,13 +98,13 @@ rl.on("line", (line) => {
|
|||||||
id: message.id,
|
id: message.id,
|
||||||
result: {
|
result: {
|
||||||
ok: true,
|
ok: true,
|
||||||
supportedMethods: ["getData", "performAction"],
|
supportedMethods: ["getData", "performAction", "resourceLifecycle", "workspaceOperation"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === "getData" || method === "performAction") {
|
if (["getData", "performAction", "resourceLifecycle", "workspaceOperation"].includes(method)) {
|
||||||
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
sendNestedHostRequest(message, message.paperclipInvocation?.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { githubCredentialsFixture } from "./helpers/github-credentials-fixture.js";
|
||||||
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
|
import { createNativeGitHubAccess } from "../services/native-runtime/native-github-access.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
@@ -71,129 +72,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
|||||||
await database?.cleanup();
|
await database?.cleanup();
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
async function seed() {
|
const { seed, grant, switchTo } = githubCredentialsFixture(() => db);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
it.each(["config", "transportConfig"] as const)("reads only current GitHub identity connections via %s", async location => {
|
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 input = await seed(), selected = await grant(input, "A");
|
||||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, selected.connectionId));
|
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(); }
|
} 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 () => {
|
it("resolves A → B → A without retaining tokens, and records only redacted diagnostics", async () => {
|
||||||
const input = await seed();
|
const input = await seed();
|
||||||
await grant(input, "A");
|
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 () => {
|
it("passes performAction invocation scope to nested worker host calls", async () => {
|
||||||
const companiesGet = vi.fn(async (
|
const companiesGet = vi.fn(async (
|
||||||
params: { companyId: string },
|
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.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
|
||||||
expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true);
|
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 () => {
|
it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => {
|
||||||
const svc = projectService(db);
|
const svc = projectService(db);
|
||||||
const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]);
|
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",
|
"GET /api/companies/{companyId}/project-repositories",
|
||||||
"PUT /api/projects/{id}/repositories",
|
"PUT /api/projects/{id}/repositories",
|
||||||
|
"GET /api/projects/{id}/git-authorization",
|
||||||
|
"PUT /api/projects/{id}/git-authorization",
|
||||||
"DELETE /api/issues/{id}/documents/{key}",
|
"DELETE /api/issues/{id}/documents/{key}",
|
||||||
"GET /api/companies/{companyId}/decisions",
|
"GET /api/companies/{companyId}/decisions",
|
||||||
"GET /api/cloud/stacks",
|
"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({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/api/projects/{id}/repositories",
|
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 { skillAccessRequestContext } from "../services/skill-access.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
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));
|
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) => {
|
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
|
||||||
assertBoard(req);
|
assertBoard(req);
|
||||||
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
|
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;
|
agentId?: string | null;
|
||||||
allowStandingDelegation?: boolean;
|
allowStandingDelegation?: boolean;
|
||||||
excludeGrantId?: string;
|
excludeGrantId?: string;
|
||||||
|
explicitGrantId?: string;
|
||||||
},
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -405,6 +406,7 @@ export async function resolveManagedGitHubIdentitySelection(
|
|||||||
const grants = await db.select().from(connectionGrants).where(and(
|
const grants = await db.select().from(connectionGrants).where(and(
|
||||||
eq(connectionGrants.companyId, companyId),
|
eq(connectionGrants.companyId, companyId),
|
||||||
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
|
inArray(connectionGrants.connectionId, [...eligibleConnectionIds]),
|
||||||
|
context.explicitGrantId ? eq(connectionGrants.id, context.explicitGrantId) : undefined,
|
||||||
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
|
or(eq(connectionGrants.kind, "agent"), eq(connectionGrants.kind, "user")),
|
||||||
));
|
));
|
||||||
const dedicated = context.agentId
|
const dedicated = context.agentId
|
||||||
@@ -537,6 +539,7 @@ export async function resolveManagedGitHubCredential(
|
|||||||
responsibleUserId?: string | null;
|
responsibleUserId?: string | null;
|
||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
allowStandingDelegation?: boolean;
|
allowStandingDelegation?: boolean;
|
||||||
|
explicitGrantId?: string;
|
||||||
},
|
},
|
||||||
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
|
): Promise<{ configured: boolean; identitySource?: "personal" | "dedicated"; credential?: GitCredential; error?: string }> {
|
||||||
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
|
const selection = await resolveManagedGitHubIdentitySelection(db, companyId, context);
|
||||||
@@ -631,6 +634,7 @@ export async function resolveManagedGitHubCredential(
|
|||||||
} catch {
|
} catch {
|
||||||
failure = { configured: true, identitySource: selection.identitySource, error: "GitHub credentials are temporarily unavailable" };
|
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
|
// Retry credential acquisition, never the GitHub operation. An alternate
|
||||||
// authorization must still belong to this exact principal and account.
|
// authorization must still belong to this exact principal and account.
|
||||||
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
|
const alternate = await resolveManagedGitHubIdentitySelection(db, companyId, {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||||||
"companies.get": ["companies.read"],
|
"companies.get": ["companies.read"],
|
||||||
"projects.list": ["projects.read"],
|
"projects.list": ["projects.read"],
|
||||||
"projects.get": ["projects.read"],
|
"projects.get": ["projects.read"],
|
||||||
|
"projects.resolveGitCredential": ["projects.git-credentials"],
|
||||||
"projects.getInstanceSecrets": ["projects.instance-secrets"],
|
"projects.getInstanceSecrets": ["projects.instance-secrets"],
|
||||||
"projects.managed.get": ["projects.managed"],
|
"projects.managed.get": ["projects.managed"],
|
||||||
"projects.managed.reconcile": ["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 { projectInstanceSecretService } from "./project-instance-secrets.js";
|
||||||
import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js";
|
import { pluginAgentRuntimeState, pluginAgentRuntimeIdentity } from "./plugin-agent-runtime-state.js";
|
||||||
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
import { createAgentLifecycle } from "./agent-lifecycle.js";
|
||||||
@@ -1863,6 +1864,11 @@ export function buildHostServices(
|
|||||||
},
|
},
|
||||||
|
|
||||||
projects: {
|
projects: {
|
||||||
|
async resolveGitCredential(params, context) {
|
||||||
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
return projectGitAuthorizationService(db).resolve(params, context.invocationScope?.projectGit);
|
||||||
|
},
|
||||||
async getInstanceSecrets(params) {
|
async getInstanceSecrets(params) {
|
||||||
const companyId = ensureCompanyId(params.companyId);
|
const companyId = ensureCompanyId(params.companyId);
|
||||||
await ensurePluginAvailableForCompany(companyId);
|
await ensurePluginAvailableForCompany(companyId);
|
||||||
|
|||||||
@@ -1158,7 +1158,26 @@ export function createPluginWorkerHandle(
|
|||||||
if (!isRecord(params)) return null;
|
if (!isRecord(params)) return null;
|
||||||
|
|
||||||
const directCompanyId = readNonEmptyString(params.companyId);
|
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)) {
|
if (method === "performAction" && isRecord(params.actorContext)) {
|
||||||
const companyId = readNonEmptyString(params.actorContext.companyId);
|
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 type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
|
||||||
import { unprocessable } from "../errors.js";
|
import { unprocessable } from "../errors.js";
|
||||||
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
|
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
|
||||||
@@ -38,7 +41,8 @@ export function resolveProjectRepositorySelection(
|
|||||||
const current = available.find((repo) => repo.id === id);
|
const current = available.find((repo) => repo.id === id);
|
||||||
if (current) return current;
|
if (current) return current;
|
||||||
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
|
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.");
|
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}` };
|
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 service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
|
||||||
const project = await service.create(companyId, data);
|
const project = await service.create(companyId, data);
|
||||||
for (const repo of repositories) {
|
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))!;
|
return (await service.getById(project.id))!;
|
||||||
});
|
});
|
||||||
@@ -946,15 +946,15 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
|
|||||||
for (const repo of repositories) {
|
for (const repo of repositories) {
|
||||||
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
|
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
|
||||||
if (retained) {
|
if (retained) {
|
||||||
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) {
|
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 });
|
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url, metadata: { ...retained.metadata, githubRepositoryUrl: repo.url, githubRepositoryPrivate: repo.private } });
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
|
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.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 } });
|
} 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 tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
|
||||||
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);
|
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, projectId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectAccessMember,
|
ProjectAccessMember,
|
||||||
ProjectRepositoryOptions,
|
ProjectRepositoryOptions,
|
||||||
|
ProjectGitAuthorization,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
WorkspaceOperation,
|
WorkspaceOperation,
|
||||||
WorkspaceRuntimeControlTarget,
|
WorkspaceRuntimeControlTarget,
|
||||||
@@ -20,6 +21,8 @@ function projectPath(id: string, companyId?: string, suffix = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const projectsApi = {
|
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`),
|
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
|
||||||
setRepositories: (id: string, repositoryIds: string[]) => api.put<Project>(projectPath(id, undefined, "/repositories"), { repositoryIds }),
|
setRepositories: (id: string, repositoryIds: string[]) => api.put<Project>(projectPath(id, undefined, "/repositories"), { repositoryIds }),
|
||||||
list: (companyId: string, opts: { includeArchived?: boolean } = {}) => {
|
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 { Link } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
|
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
|
||||||
|
import { ProjectGitAuthorization } from "./ProjectGitAuthorization";
|
||||||
import { ProjectRepositories } from "./ProjectRepositories";
|
import { ProjectRepositories } from "./ProjectRepositories";
|
||||||
import { cn, formatDate } from "../lib/utils";
|
import { cn, formatDate } from "../lib/utils";
|
||||||
import { environmentsApi } from "../api/environments";
|
import { environmentsApi } from "../api/environments";
|
||||||
@@ -510,6 +511,7 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
|||||||
</div>
|
</div>
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
|
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
|
||||||
|
<ProjectGitAuthorization key={`git-${project.id}`} project={project} />
|
||||||
<PropertyRow
|
<PropertyRow
|
||||||
label={<FieldLabel label="Env" state={fieldState("env")} />}
|
label={<FieldLabel label="Env" state={fieldState("env")} />}
|
||||||
alignStart
|
alignStart
|
||||||
|
|||||||
Reference in new issue
Block a user