Files
EvoFirewall/apps/api/src/services/labels.ts
T
Denozordec 9fa6269174
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
feat: harden firewall policies telemetry and operations
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
2026-10-26 13:10:58 +07:00

67 lines
4.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { repos, type Db } from '@evofw/db'
import type { PreviewLabelAssignment, ApplyLabelAssignment, LabelAssignmentPreview } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
const fail = (code: string, message: string, status = 409): never => { throw new AppError(code, message, status) }
const requireAgent = (db: Db, id: string) => repos.getAgent(db, id) ?? fail('NOT_FOUND', 'Agent not found', 404)
const requireLabel = (db: Db, id: string) => repos.getAgentLabel(db, id) ?? fail('NOT_FOUND', 'Label not found', 404)
const nameKey = (name: string) => name.normalize('NFKC').toLocaleLowerCase('en-US')
export function createAgentLabel(db: Db, body: { name: string; color?: string | null }, now = new Date().toISOString()) {
return db.transaction((tx) => {
const key = nameKey(body.name)
if (repos.getAgentLabelByName(tx, key)) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists')
if (repos.countAgentLabels(tx) >= 1000) fail('LABEL_CAPACITY', 'At most 1000 labels may be stored', 422)
const row = { id: randomUUID(), name: body.name, name_key: key, color: body.color ?? null, created_at: now }
repos.insertAgentLabel(tx, row)
return row
})
}
export function patchAgentLabel(db: Db, id: string, patch: { name?: string; color?: string | null }) {
return db.transaction((tx) => {
const previous = requireLabel(tx, id)
const name = patch.name ?? previous.name
const key = nameKey(name)
const conflict = repos.getAgentLabelByName(tx, key)
if (conflict && conflict.id !== id) fail('LABEL_NAME_CONFLICT', 'A label with this name already exists')
const row = { ...previous, name, name_key: key, color: patch.color === undefined ? previous.color : patch.color }
repos.updateAgentLabel(tx, row)
return row
})
}
export function putAgentLabels(db: Db, id: string, labelIds: string[]) {
return db.transaction((tx) => {
requireAgent(tx, id)
for (const labelId of labelIds) requireLabel(tx, labelId)
repos.replaceAgentLabels(tx, id, labelIds)
return { agent_id: id, label_ids: repos.agentLabelIds(tx, id) }
})
}
export function previewLabelAssignment(db: Db, body: PreviewLabelAssignment): LabelAssignmentPreview {
// Resolve the whole selection once. Never silently cap a larger label group.
for (const labelId of body.selector.label_ids ?? []) requireLabel(db, labelId)
const ids = body.selector.agent_ids ?? repos.agentIdsWithLabels(db, body.selector.label_ids!, 101)
if (ids.length > 100) fail('GROUP_TOO_LARGE', 'An assignment may select at most 100 agents', 422)
for (const setId of body.set_ids) if (!repos.getPolicySet(db, setId)) fail('NOT_FOUND', 'Policy set not found', 404)
const items = ids.map((id) => {
const agent = requireAgent(db, id)
repos.assertAgentPolicyMutable(db, id)
const previous = repos.listSetsForAgent(db, id).map((set) => set.setId)
const final = body.mode === 'replace' ? [...body.set_ids] : [...previous, ...body.set_ids.filter((setId) => !previous.includes(setId))]
if (final.length > 100) fail('ASSIGNMENT_TOO_LARGE', 'An agent may receive at most 100 policy sets through a group assignment', 422)
return { agent_id: agent.id, generation: agent.policyGeneration, previous_set_ids: previous, set_ids: final, changed: JSON.stringify(previous) !== JSON.stringify(final) }
})
return { mode: body.mode, items, total: items.length }
}
export function applyLabelAssignment(db: Db, body: ApplyLabelAssignment) {
return db.transaction((tx) => {
const preview = previewLabelAssignment(tx, body)
if (preview.total === 0) fail('EMPTY_SELECTION', 'No agents match this selection')
const expected = new Map(body.expected.map((row) => [row.agent_id, row.generation]))
if (expected.size !== preview.total || preview.items.some((item) => expected.get(item.agent_id) !== item.generation)) fail('REVISION_CONFLICT', 'Group membership or an agent generation changed; refresh preview')
// Every target/set/pin/precondition has been validated before the first write.
for (const item of preview.items) if (item.changed) repos.setAgentPolicySets(tx, item.agent_id, item.set_ids)
return { affected_agent_ids: preview.items.filter((item) => item.changed).map((item) => item.agent_id),
items: preview.items.map((item) => ({ agent_id: item.agent_id, generation: requireAgent(tx, item.agent_id).policyGeneration, before_count: item.previous_set_ids.length, after_count: item.set_ids.length, changed: item.changed })) }
})
}