diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 468afa571f3..8fa547b31e1 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -62,6 +62,12 @@ vi.mock("../services/index.js", () => ({ workspaceOperationService: () => ({}), accessService: () => ({ canUser: vi.fn(), + decide: vi.fn(async (input: { action: string }) => ({ + allowed: input.action === "project:read", + action: input.action, + reason: input.action === "project:read" ? "allow_explicit_grant" : "deny_missing_grant", + explanation: input.action === "project:read" ? "Allowed by test default." : "Missing permission.", + })), hasPermission: vi.fn(), }), agentService: () => ({ diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 5045c9fea10..1883b842c3e 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -222,7 +222,9 @@ function registerRouteMocks() { issueThreadInteractionService: () => mockIssueThreadInteractionService, taskWatchdogService: () => mockTaskWatchdogService, logActivity: mockLogActivity, - projectService: () => ({}), + projectService: () => ({ + getById: vi.fn(async (id: string) => ({ id, companyId })), + }), routineService: () => ({ syncRunStatusForIssue: vi.fn(async () => undefined), }), @@ -387,14 +389,16 @@ describe("agent issue mutation checkout ownership", () => { input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read", + input.action === "company_scope:read" || + input.action === "project:read", action: input.action, reason: input.action === "tasks:assign" || input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read" + input.action === "company_scope:read" || + input.action === "project:read" ? "allow_explicit_grant" : "deny_missing_grant", explanation: @@ -402,7 +406,8 @@ describe("agent issue mutation checkout ownership", () => { input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate" || - input.action === "company_scope:read" + input.action === "company_scope:read" || + input.action === "project:read" ? "Allowed by test default." : "Missing permission.", })); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 7d46b656cab..7afcd0fa8b9 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -39,6 +39,7 @@ import { // Project createProjectSchema, updateProjectSchema, + addProjectAccessMemberSchema, createProjectWorkspaceSchema, updateProjectWorkspaceSchema, // Company @@ -776,6 +777,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "PUT /api/companies/{companyId}/resource-memberships/me/documents/{documentId}", "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", + "POST /api/projects/{id}/access-members", + "DELETE /api/projects/{id}/access-members/{memberId}", "GET /api/companies/{companyId}/secret-provider-configs", "POST /api/companies/{companyId}/secret-provider-configs", "GET /api/companies/{companyId}/secret-providers/health", @@ -924,6 +927,7 @@ const CREATED_OPERATIONS = new Set([ "POST /api/issues/{id}/comments", "POST /api/companies/{companyId}/issues/{issueId}/attachments", "POST /api/companies/{companyId}/projects", + "POST /api/projects/{id}/access-members", "POST /api/projects/{id}/workspaces", "POST /api/companies/{companyId}/routines", "POST /api/companies/{companyId}/folders", @@ -2571,6 +2575,49 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/projects/{id}/access-members", + tags: ["projects"], + summary: "List project access members", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/projects/{id}/access-members", + tags: ["projects"], + summary: "Add a project access member", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(addProjectAccessMemberSchema), + }, + responses: { + 201: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 422: r.unprocessable, + }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/projects/{id}/access-members/{memberId}", + tags: ["projects"], + summary: "Remove a project access member", + request: { params: z.object({ id: z.string(), memberId: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 422: r.unprocessable, + }, +}); + registry.registerPath({ method: "get", path: "/api/projects/{id}/workspaces",