Files
EvoFirewall/apps/api/src/routes/rules.ts
T
Denozordec 7a3f1fad25 perf(api): ретенция статистики, устранение N+1 и пагинация списков
- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
2026-09-20 19:20:30 +07:00

201 lines
6.1 KiB
TypeScript

import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import {
createPolicyRuleBodySchema,
patchPolicyRuleBodySchema,
reorderPolicyRulesBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import {
resolveAndStoreHostnameRule,
resolveHostnameToCidrs,
} from '../services/policy/resolve-hostname.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { mapPolicyRule, mapPolicyRules } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
opts,
) => {
const { config } = opts
app.get<{ Params: { id: string } }>(
'/policy-sets/:id/rules',
async (req) => {
const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
return {
items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), app.db),
}
},
)
app.get<{
Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
}>('/rules', async (req) => {
if (req.query.agent_id) {
return {
items: mapPolicyRules(
repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
app.db,
),
}
}
const paged = applyPagination(
mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
req.query,
)
return { items: paged.items, total: paged.total }
})
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
const set = repos.getPolicySet(app.db, body.set_id)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const hostname = body.hostname?.trim() || null
const cidr = body.cidr?.trim() || null
const listId = body.list_id?.trim() || null
if (hostname) {
try {
await resolveHostnameToCidrs(hostname)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
}
if (listId && !repos.getIpList(app.db, listId)) {
throw new AppError('NOT_FOUND', 'IP list not found', 404)
}
const priority =
body.priority ?? repos.nextRulePriority(app.db, body.set_id)
const id = crypto.randomUUID()
const row = repos.insertPolicyRule(app.db, {
id,
setId: body.set_id,
priority,
action: body.action,
enabled: body.enabled === false ? 0 : 1,
listId,
cidr,
hostname,
comment: body.comment ?? null,
createdByUserId: req.authUser?.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
if (hostname) {
try {
await resolveAndStoreHostnameRule(app.db, id, hostname)
} catch (err) {
repos.deletePolicyRule(app.db, id)
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
}
repos.bumpAgentsForSet(app.db, body.set_id)
auditMutation(app, config, req, {
action: 'rule.create',
targetType: 'app_resource',
targetId: row!.id,
summary: `Создано правило ${body.action} в наборе ${set.name}`,
details: {
rule_id: row!.id,
set_id: body.set_id,
action: body.action,
priority,
},
})
return mapPolicyRule(row!, app.db)
})
app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => {
const body = patchPolicyRuleBodySchema.parse(req.body)
const rule = repos.getPolicyRule(app.db, req.params.id)
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
const updated = repos.updatePolicyRule(app.db, rule.id, {
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
action: body.action,
comment: body.comment,
priority: body.priority,
})
repos.bumpAgentsForSet(app.db, rule.setId)
auditMutation(app, config, req, {
action: 'rule.update',
targetType: 'app_resource',
targetId: rule.id,
summary: `Обновлено правило ${rule.id}`,
details: {
rule_id: rule.id,
set_id: rule.setId,
enabled: body.enabled,
action: body.action,
priority: body.priority,
},
})
return mapPolicyRule(updated!, app.db)
})
app.put<{ Params: { id: string } }>(
'/policy-sets/:id/rules/reorder',
async (req) => {
const body = reorderPolicyRulesBodySchema.parse(req.body)
const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
try {
repos.reorderPolicyRules(app.db, s.id, body.ordered_ids)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
repos.bumpAgentsForSet(app.db, s.id)
auditMutation(app, config, req, {
action: 'rule.reorder',
targetType: 'app_resource',
targetId: s.id,
summary: `Порядок правил изменён в наборе ${s.name}`,
details: { set_id: s.id, ordered_ids: body.ordered_ids },
})
return {
items: mapPolicyRules(
repos.listPolicyRules(app.db, s.id),
app.db,
),
}
},
)
app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => {
const rule = repos.getPolicyRule(app.db, req.params.id)
if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404)
repos.deletePolicyRule(app.db, req.params.id)
repos.bumpAgentsForSet(app.db, rule.setId)
auditMutation(app, config, req, {
action: 'rule.delete',
severity: 'warning',
targetType: 'app_resource',
targetId: rule.id,
summary: `Правило удалено из набора ${rule.setId}`,
details: { rule_id: rule.id, set_id: rule.setId },
})
return { ok: true }
})
}