- RouteErrorComponent/RouteNotFoundComponent на root-маршруте (RU-копирайт, retry + переход на главную; redirect в портал не мигает ошибкой) - settings: форма инициализируется один раз — фоновый refetch больше не затирает ввод пользователя - code splitting: autoCodeSplitting роутов + manualChunks (react/router/query/ charts/dnd); вход ~507KB вместо единого чанка 1.57MB, recharts (330KB) грузится лениво; версия recharts в packages/ui выровнена с apps/web (3.8.0) - bulk-эндпоинты: POST /agents/approve-bulk и PUT /policy-sets/:id/agents — назначение набора агентам одним запросом вместо N×(GET+PUT) - оптимистичные обновления с rollback: approve, approve-bulk, удаление агента, переключение набора - тесты bulk-операций (45 passed)
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify'
|
|
import { repos } from '@evofw/db'
|
|
import {
|
|
createPolicySetBodySchema,
|
|
patchPolicySetBodySchema,
|
|
agentIdsBodySchema,
|
|
} from '@evofw/shared'
|
|
import { AppError } from '../plugins/error-handler.js'
|
|
import type { AppConfig } from '../config.js'
|
|
import { auditMutation } from '../services/audit.js'
|
|
import { mapPolicySet, mapPolicySets } from '../services/row-mappers.js'
|
|
|
|
export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|
app,
|
|
opts,
|
|
) => {
|
|
const { config } = opts
|
|
|
|
app.get('/policy-sets', async () => ({
|
|
items: mapPolicySets(app.db, repos.listPolicySets(app.db)),
|
|
}))
|
|
|
|
app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
|
|
const s = repos.getPolicySet(app.db, req.params.id)
|
|
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
|
return {
|
|
...mapPolicySet(s, app.db),
|
|
agent_ids: repos.listAgentIdsForSet(app.db, s.id),
|
|
}
|
|
})
|
|
|
|
app.post('/policy-sets', async (req) => {
|
|
const body = createPolicySetBodySchema.parse(req.body)
|
|
const row = repos.insertPolicySet(app.db, {
|
|
id: crypto.randomUUID(),
|
|
name: body.name.trim(),
|
|
description: body.description ?? null,
|
|
enabled: body.enabled === false ? 0 : 1,
|
|
policyMode: 'blacklist',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
})
|
|
auditMutation(app, config, req, {
|
|
action: 'policy_set.create',
|
|
targetType: 'app_resource',
|
|
targetId: row!.id,
|
|
summary: `Создан набор политик: ${row!.name}`,
|
|
details: { set_id: row!.id },
|
|
})
|
|
return mapPolicySet(row!, app.db)
|
|
})
|
|
|
|
app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
|
|
const body = patchPolicySetBodySchema.parse(req.body)
|
|
const s = repos.getPolicySet(app.db, req.params.id)
|
|
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
|
const updated = repos.updatePolicySet(app.db, s.id, {
|
|
name: body.name?.trim(),
|
|
description: body.description,
|
|
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
|
|
})
|
|
if (body.enabled !== undefined) {
|
|
repos.bumpAgentsForSet(app.db, s.id)
|
|
}
|
|
auditMutation(app, config, req, {
|
|
action: 'policy_set.update',
|
|
targetType: 'app_resource',
|
|
targetId: s.id,
|
|
summary: `Обновлён набор политик: ${updated!.name}`,
|
|
details: {
|
|
set_id: s.id,
|
|
enabled: body.enabled,
|
|
name: body.name,
|
|
},
|
|
})
|
|
return mapPolicySet(updated!, app.db)
|
|
})
|
|
|
|
app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => {
|
|
const s = repos.getPolicySet(app.db, req.params.id)
|
|
try {
|
|
const agentIds = repos.listAgentIdsForSet(app.db, req.params.id)
|
|
repos.deletePolicySet(app.db, req.params.id)
|
|
for (const id of agentIds) repos.bumpAgentGeneration(app.db, id)
|
|
if (s) {
|
|
auditMutation(app, config, req, {
|
|
action: 'policy_set.delete',
|
|
severity: 'warning',
|
|
targetType: 'app_resource',
|
|
targetId: s.id,
|
|
summary: `Набор политик удалён: ${s.name}`,
|
|
details: { set_id: s.id, agents_affected: agentIds.length },
|
|
})
|
|
}
|
|
} catch (err) {
|
|
throw new AppError(
|
|
'VALIDATION_ERROR',
|
|
err instanceof Error ? err.message : String(err),
|
|
400,
|
|
)
|
|
}
|
|
return { ok: true }
|
|
})
|
|
|
|
/**
|
|
* Replace which agents have this set assigned: listed agents gain the set
|
|
* (other assignments preserved), unlisted agents lose it.
|
|
*/
|
|
app.put<{ Params: { id: string } }>(
|
|
'/policy-sets/:id/agents',
|
|
async (req) => {
|
|
const set = repos.getPolicySet(app.db, req.params.id)
|
|
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
|
const body = agentIdsBodySchema.parse(req.body)
|
|
|
|
const target = new Set(body.agent_ids)
|
|
for (const agentId of body.agent_ids) {
|
|
if (!repos.getAgent(app.db, agentId)) {
|
|
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
|
|
}
|
|
}
|
|
|
|
const current = repos.listAgentIdsForSet(app.db, set.id)
|
|
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
|
|
const toRemove = current.filter((id) => !target.has(id))
|
|
|
|
const applyAssignment = (agentId: string, withSet: boolean) => {
|
|
const others = repos
|
|
.listSetsForAgent(app.db, agentId)
|
|
.map((s) => s.setId)
|
|
.filter((id) => id !== set.id)
|
|
const next = withSet ? [...others, set.id] : others
|
|
repos.setAgentPolicySets(app.db, agentId, next)
|
|
}
|
|
|
|
app.sqlite.transaction(() => {
|
|
for (const agentId of toAdd) applyAssignment(agentId, true)
|
|
for (const agentId of toRemove) applyAssignment(agentId, false)
|
|
})()
|
|
|
|
auditMutation(app, config, req, {
|
|
action: 'policy_set.agents.update',
|
|
targetType: 'app_resource',
|
|
targetId: set.id,
|
|
summary: `Назначение набора ${set.name} обновлено`,
|
|
details: {
|
|
set_id: set.id,
|
|
added: toAdd,
|
|
removed: toRemove,
|
|
},
|
|
})
|
|
return {
|
|
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
|
|
added: toAdd,
|
|
removed: toRemove,
|
|
}
|
|
},
|
|
)
|
|
}
|