diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 627ce40..3385173 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -127,6 +127,14 @@ export type UpsertGrantInput = { createdByUserId: string; }; +export type ReplaceWorkspaceGrantsInput = { + workspaceSlug: string; + projectIds: string[]; + scopes: AgentScope[]; + mode: AgentGrantMode; + actorUserId: string; +}; + export type CreateTokenInput = { token: string; name: string; @@ -274,6 +282,71 @@ export class AgentsRepository { return result.rows.map(mapGrant); } + async replaceWorkspaceProjectGrants(agentId: string, input: ReplaceWorkspaceGrantsInput): Promise { + assertAllowedScopes(input.scopes); + + const projectIds = [...new Set(input.projectIds.map((projectId) => projectId.trim()).filter(Boolean))]; + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + await client.query( + ` + DELETE FROM agent_grants + WHERE agent_id = $1 + AND workspace_slug = $2 + AND NOT (project_id = ANY($3::text[])) + `, + [agentId, input.workspaceSlug, projectIds] + ); + + const grants: AgentGrantRecord[] = []; + for (const projectId of projectIds) { + 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, input.workspaceSlug, 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.grants.replaced", + input.actorUserId, + { + workspaceSlug: input.workspaceSlug, + projectIds, + 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 67337b8..2a83c8a 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -87,6 +87,14 @@ const upsertGrantBodySchema = z.object({ created_by_user_id: z.string().min(1), }); +const replaceWorkspaceGrantsBodySchema = z.object({ + workspace_slug: z.string().min(1), + project_ids: z.array(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(), @@ -230,6 +238,31 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }; }); + app.post("/api/v1/agents/:agentId/grants/replace", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const body = replaceWorkspaceGrantsBodySchema.parse(request.body); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.replaceWorkspaceProjectGrants(agentId, { + workspaceSlug: body.workspace_slug, + projectIds: body.project_ids, + 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); @@ -430,6 +463,31 @@ export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRoute }); }); + app.post("/api/internal/v1/owners/:ownerUserId/agents/:agentId/grants/replace", async (request, reply) => { + requireLifecycleAccess(request, deps); + const repository = requireRepository(deps); + const { ownerUserId, agentId } = ownerAgentParamsSchema.parse(request.params); + const body = replaceWorkspaceGrantsBodySchema.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.replaceWorkspaceProjectGrants(agentId, { + workspaceSlug: body.workspace_slug, + projectIds: body.project_ids, + 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);