feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m44s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 22:11:03 +07:00
co-authored by Cursor
parent cad05659b1
commit 0bed4367c7
18 changed files with 1524 additions and 312 deletions
+212 -31
View File
@@ -1,13 +1,17 @@
import { eq, and, desc, isNull, sql } from 'drizzle-orm'
import { eq, and, desc, asc, sql, count, inArray } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agents,
ipLists,
ipListEntries,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
agentStatsSamples,
settings,
SHARED_POLICY_SET_ID,
} from '../schema.js'
export function listAgents(db: Db) {
@@ -22,10 +26,7 @@ export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(
db: Db,
row: typeof agents.$inferInsert,
) {
export function insertAgent(db: Db, row: typeof agents.$inferInsert) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
@@ -50,6 +51,24 @@ export function bumpAgentGeneration(db: Db, id: string) {
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
}
export function bumpAllApprovedAgents(db: Db) {
const rows = db
.select({ id: agents.id })
.from(agents)
.where(eq(agents.status, 'approved'))
.all()
for (const r of rows) bumpAgentGeneration(db, r.id)
}
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
@@ -102,24 +121,144 @@ export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
}
}
export function listPolicyRules(db: Db, agentId?: string | null) {
if (agentId === undefined) {
return db.select().from(policyRules).orderBy(policyRules.priority).all()
/* ── Policy sets ── */
export function listPolicySets(db: Db) {
return db.select().from(policySets).orderBy(asc(policySets.name)).all()
}
export function getPolicySet(db: Db, id: string) {
return db.select().from(policySets).where(eq(policySets.id, id)).get()
}
export function insertPolicySet(db: Db, row: typeof policySets.$inferInsert) {
db.insert(policySets).values(row).run()
return getPolicySet(db, row.id)
}
export function updatePolicySet(
db: Db,
id: string,
patch: Partial<typeof policySets.$inferInsert>,
) {
db.update(policySets)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(policySets.id, id))
.run()
return getPolicySet(db, id)
}
export function deletePolicySet(db: Db, id: string) {
if (id === SHARED_POLICY_SET_ID) {
throw new Error('cannot delete shared default set')
}
if (agentId === null) {
db.delete(policySets).where(eq(policySets.id, id)).run()
}
export function countRulesInSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(policyRules)
.where(eq(policyRules.setId, setId))
.get()
return row?.n ?? 0
}
export function countAgentsForSet(db: Db, setId: string): number {
const row = db
.select({ n: count() })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.get()
return row?.n ?? 0
}
export function listAgentIdsForSet(db: Db, setId: string): string[] {
return db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
.map((r) => r.agentId)
}
export function listSetsForAgent(db: Db, agentId: string) {
return db
.select({
setId: agentPolicySets.setId,
sort: agentPolicySets.sort,
name: policySets.name,
description: policySets.description,
enabled: policySets.enabled,
})
.from(agentPolicySets)
.innerJoin(policySets, eq(agentPolicySets.setId, policySets.id))
.where(eq(agentPolicySets.agentId, agentId))
.orderBy(asc(agentPolicySets.sort), asc(policySets.name))
.all()
}
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
}
/* ── Policy rules ── */
export function listPolicyRules(db: Db, setId?: string) {
if (setId) {
return db
.select()
.from(policyRules)
.where(isNull(policyRules.agentId))
.orderBy(policyRules.priority)
.where(eq(policyRules.setId, setId))
.orderBy(asc(policyRules.priority))
.all()
}
return db
return db.select().from(policyRules).orderBy(asc(policyRules.priority)).all()
}
export function listPolicyRulesForAgent(db: Db, agentId: string) {
const assignments = listSetsForAgent(db, agentId).filter((s) => s.enabled === 1)
if (assignments.length === 0) return []
const setIds = assignments.map((a) => a.setId)
const sortBySet = new Map(assignments.map((a) => [a.setId, a.sort]))
const rules = db
.select()
.from(policyRules)
.where(eq(policyRules.agentId, agentId))
.orderBy(policyRules.priority)
.where(inArray(policyRules.setId, setIds))
.all()
return rules
.map((r) => ({
...r,
_setSort: sortBySet.get(r.setId) ?? 0,
}))
.sort((a, b) => a._setSort - b._setSort || a.priority - b.priority)
}
export function getPolicyRule(db: Db, id: string) {
@@ -138,6 +277,39 @@ export function deletePolicyRule(db: Db, id: string) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listHostnameRules(db: Db) {
return db
.select()
.from(policyRules)
.where(sql`${policyRules.hostname} IS NOT NULL AND trim(${policyRules.hostname}) != ''`)
.all()
}
export function listResolvedForRule(db: Db, ruleId: string) {
return db
.select()
.from(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.all()
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(policyRuleResolved)
.values({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})
.run()
}
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
@@ -206,6 +378,7 @@ export function listSettings(db: Db) {
return db.select().from(settings).all()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
@@ -216,23 +389,12 @@ export function cloneRulesFrom(
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
db.delete(policyRules).where(eq(policyRules.agentId, targetAgentId)).run()
const rules = listPolicyRules(db, sourceAgentId)
for (const r of rules) {
db.insert(policyRules)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
priority: r.priority,
action: r.action,
listId: r.listId,
cidr: r.cidr,
comment: r.comment,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.run()
}
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
targetAgentId,
sourceSets.map((s) => s.setId),
)
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
@@ -265,6 +427,8 @@ export const repos = {
updateAgent,
deleteAgent,
bumpAgentGeneration,
bumpAgentsForSet,
bumpAllApprovedAgents,
listIpLists,
getIpList,
insertIpList,
@@ -272,10 +436,25 @@ export const repos = {
deleteIpList,
listIpListEntries,
replaceIpListEntries,
listPolicySets,
getPolicySet,
insertPolicySet,
updatePolicySet,
deletePolicySet,
countRulesInSet,
countAgentsForSet,
listAgentIdsForSet,
listSetsForAgent,
setAgentPolicySets,
ensureSharedSetAssigned,
listPolicyRules,
listPolicyRulesForAgent,
getPolicyRule,
insertPolicyRule,
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
replaceResolvedForRule,
listOverrides,
insertOverride,
deleteOverride,
@@ -287,3 +466,5 @@ export const repos = {
listSettings,
cloneRulesFrom,
}
export { SHARED_POLICY_SET_ID }
+60 -2
View File
@@ -78,15 +78,49 @@ export const ipListEntries = sqliteTable(
}),
)
/** Named reusable policy sets (M:N with agents). */
export const policySets = sqliteTable('policy_sets', {
id: text('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
enabled: integer('enabled').notNull().default(1),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
})
export const agentPolicySets = sqliteTable(
'agent_policy_sets',
{
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
setId: text('set_id')
.notNull()
.references(() => policySets.id, { onDelete: 'cascade' }),
sort: integer('sort').notNull().default(0),
},
(t) => ({
pk: uniqueIndex('idx_agent_policy_sets_pk').on(t.agentId, t.setId),
setIdx: index('idx_agent_policy_sets_set').on(t.setId),
}),
)
export const policyRules = sqliteTable(
'policy_rules',
{
id: text('id').primaryKey(),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), // null = tenant default
setId: text('set_id')
.notNull()
.references(() => policySets.id, { onDelete: 'cascade' }),
priority: integer('priority').notNull(),
action: text('action').notNull(), // allow | deny
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
cidr: text('cidr'),
hostname: text('hostname'),
comment: text('comment'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
@@ -97,7 +131,26 @@ export const policyRules = sqliteTable(
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentPriority: uniqueIndex('idx_policy_rules_agent_priority').on(t.agentId, t.priority),
setPriority: uniqueIndex('idx_policy_rules_set_priority').on(t.setId, t.priority),
}),
)
/** DNS resolve cache for hostname rules. */
export const policyRuleResolved = sqliteTable(
'policy_rule_resolved',
{
id: text('id').primaryKey(),
ruleId: text('rule_id')
.notNull()
.references(() => policyRules.id, { onDelete: 'cascade' }),
cidr: text('cidr').notNull(),
resolvedAt: text('resolved_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
ruleCidr: uniqueIndex('idx_policy_rule_resolved_rule_cidr').on(t.ruleId, t.cidr),
ruleIdx: index('idx_policy_rule_resolved_rule').on(t.ruleId),
}),
)
@@ -141,12 +194,17 @@ export const agentStatsSamples = sqliteTable(
}),
)
export const SHARED_POLICY_SET_ID = 'set-shared-default'
export const schema = {
settings,
agents,
ipLists,
ipListEntries,
policySets,
agentPolicySets,
policyRules,
policyRuleResolved,
ipOverrides,
agentStatsSamples,
}