FEAT - CODEX API: multi-workspace project grants
This commit is contained in:
parent
948893bc47
commit
526371014a
|
|
@ -135,6 +135,18 @@ export type ReplaceWorkspaceGrantsInput = {
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReplaceProjectGrantTarget = {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplaceProjectGrantsInput = {
|
||||||
|
grants: ReplaceProjectGrantTarget[];
|
||||||
|
scopes: AgentScope[];
|
||||||
|
mode: AgentGrantMode;
|
||||||
|
actorUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateTokenInput = {
|
export type CreateTokenInput = {
|
||||||
token: string;
|
token: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -347,6 +359,81 @@ export class AgentsRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async replaceProjectGrants(agentId: string, input: ReplaceProjectGrantsInput): Promise<AgentGrantRecord[]> {
|
||||||
|
assertAllowedScopes(input.scopes);
|
||||||
|
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
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<GrantRow>(
|
||||||
|
`
|
||||||
|
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<AgentTokenRecord> {
|
async createToken(agentId: string, input: CreateTokenInput): Promise<AgentTokenRecord> {
|
||||||
const result = await this.pool.query<TokenRow>(
|
const result = await this.pool.query<TokenRow>(
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,20 @@ const replaceWorkspaceGrantsBodySchema = z.object({
|
||||||
created_by_user_id: z.string().min(1),
|
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({
|
const createTokenBodySchema = z.object({
|
||||||
name: z.string().min(1).max(120).default("Local Codex token"),
|
name: z.string().min(1).max(120).default("Local Codex token"),
|
||||||
expires_at: z.string().datetime().nullish(),
|
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) => {
|
app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => {
|
||||||
requireLifecycleAccess(request, deps);
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(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) => {
|
app.get("/api/internal/v1/owners/:ownerUserId/agents/:agentId/tokens", async (request, reply) => {
|
||||||
requireLifecycleAccess(request, deps);
|
requireLifecycleAccess(request, deps);
|
||||||
const repository = requireRepository(deps);
|
const repository = requireRepository(deps);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export function requireProjectGrant(
|
||||||
}
|
}
|
||||||
): AgentGrantRecord {
|
): AgentGrantRecord {
|
||||||
const grant = session.grants.find((candidate) => {
|
const grant = session.grants.find((candidate) => {
|
||||||
if (candidate.projectId === input.projectId) {
|
if (candidate.projectId === input.projectId && (!input.workspaceSlug || candidate.workspaceSlug === input.workspaceSlug)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue