SPB Git

Repository Public

index.ts 3.3 KB 60 lines typescript

1/** Owner policy rules (deny → ask → allow, CONTRACT §6). Evaluated by the engine; CRUD only here. */2import type { FastifyInstance } from 'fastify';3import { z } from 'zod';4import { CreatePolicyRule, Id, PolicyRuleDto } from '@aos/shared';5import { HttpError, authOf, notFound, parse, type Ctx } from '../../http.js';67type PolicyRule = z.infer<typeof PolicyRuleDto>;8interface Row { id: string; project_id: string | null; decision: string; tool: string; match: Record<string, unknown>; note: string; created_at: Date }9const COLS = 'id, project_id, decision, tool, match, note, created_at';10const dto = (r: Row): PolicyRule => ({11  id: r.id, projectId: r.project_id, decision: r.decision as PolicyRule['decision'], tool: r.tool, match: r.match ?? {}, note: r.note, createdAt: r.created_at.toISOString(),12});1314const IdParams = z.object({ id: Id });15const ListQuery = z.object({ projectId: Id.optional() });16/** Tool name or '*'; the matcher stays small (it is evaluated on every tool call). */17const Body = CreatePolicyRule.extend({18  tool: z.string().regex(/^(\*|[a-z][a-z0-9_]{0,63})$/),19  note: z.string().max(500).default(''),20  match: z.record(z.string().max(64), z.unknown()).default({}).refine((m) => JSON.stringify(m).length <= 4000, 'matcher too large'),21});2223export default async function policyPlugin(app: FastifyInstance, ctx: Ctx): Promise<void> {24  const { db } = ctx;2526  app.get('/policy/rules', { config: { doc: { summary: 'Policy rules (global and per project)', query: ListQuery, response: z.array(PolicyRuleDto), responseName: 'PolicyRuleList' } } }, async (req) => {27    const a = authOf(req);28    const q = parse(ListQuery, req.query);29    const rows = await db.q<Row>(30      `SELECT ${COLS} FROM policy_rules WHERE owner_id = $1 AND ($2::uuid IS NULL OR project_id = $2::uuid) ORDER BY created_at DESC, id`,31      [a.userId, q.projectId ?? null],32    );33    return rows.map(dto);34  });3536  app.post('/policy/rules', { config: { doc: { summary: 'Create a policy rule', body: Body, bodyName: 'CreatePolicyRule', response: PolicyRuleDto, responseName: 'PolicyRule' } } }, async (req, reply) => {37    const a = authOf(req);38    const body = parse(Body, req.body);39    if (body.projectId && !(await db.one('SELECT 1 AS x FROM projects WHERE id = $1 AND owner_id = $2', [body.projectId, a.userId]))) {40      throw new HttpError(404, 'not_found', 'project not found');41    }42    const row = await db.one<Row>(43      `INSERT INTO policy_rules (owner_id, project_id, decision, tool, match, note) VALUES ($1, $2, $3, $4, $5, $6) RETURNING ${COLS}`,44      [a.userId, body.projectId, body.decision, body.tool, JSON.stringify(body.match), body.note],45    );46    if (!row) throw new Error('policy rule insert failed');47    await ctx.audit.req(req, 'policy.rule.create', { target: row.id, detail: { decision: body.decision, tool: body.tool, projectId: body.projectId } });48    return reply.code(201).send(dto(row));49  });5051  app.delete('/policy/rules/:id', { config: { doc: { summary: 'Delete a policy rule' } } }, async (req) => {52    const a = authOf(req);53    const { id } = parse(IdParams, req.params);54    const rows = await db.q('DELETE FROM policy_rules WHERE id = $1 AND owner_id = $2 RETURNING id', [id, a.userId]);55    if (rows.length === 0) throw notFound('policy rule');56    await ctx.audit.req(req, 'policy.rule.delete', { target: id });57    return { ok: true };58  });59}60
Files main
Menu

Theme