From 526371014a23047c92d6816ce0bfcb7b860bfe6e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 16 May 2026 14:22:56 +0300 Subject: [PATCH] FEAT - CODEX API: multi-workspace project grants --- src/repositories/agents.ts | 87 +++++++++++++++++++++++++++++++++++ src/routes/agents.ts | 68 +++++++++++++++++++++++++++ src/security/authorization.ts | 2 +- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 3385173..cb39825 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -135,6 +135,18 @@ export type ReplaceWorkspaceGrantsInput = { actorUserId: string; }; +export type ReplaceProjectGrantTarget = { + workspaceSlug: string; + projectId: string; +}; + +export type ReplaceProjectGrantsInput = { + grants: ReplaceProjectGrantTarget[]; + scopes: AgentScope[]; + mode: AgentGrantMode; + actorUserId: string; +}; + export type CreateTokenInput = { token: string; name: string; @@ -347,6 +359,81 @@ export class AgentsRepository { } } + async replaceProjectGrants(agentId: string, input: ReplaceProjectGrantsInput): Promise { + assertAllowedScopes(input.scopes); + + const seenKeys = new Set(); + const grantTargets = input.grants + .map((grant) => ({ + workspaceSlug: grant.workspaceSlug.trim(), + projectId: grant.projectId.trim(), + })) + .filter((grant) => grant.workspaceSlug && grant.projectId) + .filter((grant) => { + const key = `${grant.workspaceSlug}:${grant.projectId}`; + if (seenKeys.has(key)) { + return false; + } + seenKeys.add(key); + return true; + }); + + if (grantTargets.length === 0) { + throw new Error("At least one project grant is required."); + } + + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + await client.query("DELETE FROM agent_grants WHERE agent_id = $1 AND project_id <> ''", [agentId]); + + const grants: AgentGrantRecord[] = []; + for (const grantTarget of grantTargets) { + const result = await client.query( + ` + INSERT INTO agent_grants(agent_id, workspace_slug, project_id, scopes, mode, created_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (agent_id, workspace_slug, project_id) + DO UPDATE SET + scopes = EXCLUDED.scopes, + mode = EXCLUDED.mode, + created_by_user_id = EXCLUDED.created_by_user_id, + updated_at = now() + RETURNING * + `, + [agentId, grantTarget.workspaceSlug, grantTarget.projectId, input.scopes, input.mode, input.actorUserId] + ); + grants.push(mapGrant(result.rows[0])); + } + + await client.query( + ` + INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata) + VALUES ($1, $2, $3, $4) + `, + [ + agentId, + "agent.project_grants.replaced", + input.actorUserId, + { + grants: grantTargets, + scopes: input.scopes, + mode: input.mode, + }, + ] + ); + + await client.query("COMMIT"); + return grants; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async createToken(agentId: string, input: CreateTokenInput): Promise { const result = await this.pool.query( ` diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 2a83c8a..fdcef05 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -95,6 +95,20 @@ const replaceWorkspaceGrantsBodySchema = z.object({ created_by_user_id: z.string().min(1), }); +const replaceProjectGrantsBodySchema = z.object({ + grants: z + .array( + z.object({ + workspace_slug: z.string().min(1), + project_id: z.string().min(1), + }) + ) + .min(1), + scopes: z.array(z.enum(allowedAgentScopes)).min(1), + mode: z.enum(["voluntary", "reporting"]).default("voluntary"), + created_by_user_id: z.string().min(1), +}); + const createTokenBodySchema = z.object({ name: z.string().min(1).max(120).default("Local Codex token"), expires_at: z.string().datetime().nullish(), @@ -263,6 +277,33 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); }); + app.post("/api/v1/agents/:agentId/grants/replace-projects", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const body = replaceProjectGrantsBodySchema.parse(request.body); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.replaceProjectGrants(agentId, { + grants: body.grants.map((grant) => ({ + workspaceSlug: grant.workspace_slug, + projectId: grant.project_id, + })), + scopes: body.scopes, + mode: body.mode, + actorUserId: body.created_by_user_id, + }); + + return reply.status(200).send({ + ok: true, + grants: grants.map(serializeGrant), + }); + }); + app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); @@ -488,6 +529,33 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); }); + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace-projects", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = replaceProjectGrantsBodySchema.omit({ created_by_user_id: true }).parse(request.body); + const agent = await repository.getAgent(agentId); + + if (!agent || agent.ownerUserId !== ownerUserId) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.replaceProjectGrants(agentId, { + grants: body.grants.map((grant) => ({ + workspaceSlug: grant.workspace_slug, + projectId: grant.project_id, + })), + scopes: body.scopes, + mode: body.mode, + actorUserId: ownerUserId, + }); + + return reply.status(200).send({ + ok: true, + grants: grants.map(serializeGrant), + }); + }); + app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => { requireLifecycleAccess(request, deps); const repository = requireRepository(deps); diff --git a/src/security/authorization.ts b/src/security/authorization.ts index c943712..bfca91f 100644 --- a/src/security/authorization.ts +++ b/src/security/authorization.ts @@ -24,7 +24,7 @@ export function requireProjectGrant( } ): AgentGrantRecord { const grant = session.grants.find((candidate) => { - if (candidate.projectId === input.projectId) { + if (candidate.projectId === input.projectId && (!input.workspaceSlug || candidate.workspaceSlug === input.workspaceSlug)) { return true; }