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
+132
View File
@@ -0,0 +1,132 @@
-- Policy sets + DNS hostname rules (v2)
CREATE TABLE IF NOT EXISTS policy_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (enabled IN (0, 1)),
CHECK (length(trim(name)) > 0)
);
CREATE TABLE IF NOT EXISTS agent_policy_sets (
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
set_id TEXT NOT NULL REFERENCES policy_sets (id) ON DELETE CASCADE,
sort INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (agent_id, set_id)
);
CREATE INDEX IF NOT EXISTS idx_agent_policy_sets_set ON agent_policy_sets (set_id);
-- Rebuild policy_rules with set_id + hostname (drop agent_id)
CREATE TABLE IF NOT EXISTS policy_rules_v2 (
id TEXT PRIMARY KEY,
set_id TEXT NOT NULL REFERENCES policy_sets (id) ON DELETE CASCADE,
priority INTEGER NOT NULL,
action TEXT NOT NULL,
list_id TEXT REFERENCES ip_lists (id) ON DELETE CASCADE,
cidr TEXT,
hostname TEXT,
comment TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (action IN ('allow', 'deny')),
CHECK (priority >= 1 AND priority <= 10000)
);
-- Shared default set
INSERT OR IGNORE INTO policy_sets (id, name, description, enabled)
VALUES (
'set-shared-default',
'Общие',
'Набор по умолчанию (бывшие tenant-правила). Назначается approved-агентам.',
1
);
-- Per-agent sets for agents that already have rules
INSERT OR IGNORE INTO policy_sets (id, name, description, enabled)
SELECT
'set-agent-' || a.id,
'Агент: ' || a.name,
'Мигрировано из правил агента',
1
FROM agents a
WHERE EXISTS (
SELECT 1 FROM policy_rules r WHERE r.agent_id = a.id
);
-- Copy tenant rules → Общие
INSERT INTO policy_rules_v2 (
id, set_id, priority, action, list_id, cidr, hostname, comment,
created_by_user_id, created_at, updated_at
)
SELECT
id,
'set-shared-default',
priority,
action,
list_id,
cidr,
NULL,
comment,
created_by_user_id,
created_at,
updated_at
FROM policy_rules
WHERE agent_id IS NULL;
-- Copy agent rules → per-agent sets
INSERT INTO policy_rules_v2 (
id, set_id, priority, action, list_id, cidr, hostname, comment,
created_by_user_id, created_at, updated_at
)
SELECT
id,
'set-agent-' || agent_id,
priority,
action,
list_id,
cidr,
NULL,
comment,
created_by_user_id,
created_at,
updated_at
FROM policy_rules
WHERE agent_id IS NOT NULL;
DROP TABLE policy_rules;
ALTER TABLE policy_rules_v2 RENAME TO policy_rules;
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rules_set_priority
ON policy_rules (set_id, priority);
-- Assign «Общие» to all approved agents
INSERT OR IGNORE INTO agent_policy_sets (agent_id, set_id, sort)
SELECT id, 'set-shared-default', 0
FROM agents
WHERE status = 'approved';
-- Assign per-agent migrated sets
INSERT OR IGNORE INTO agent_policy_sets (agent_id, set_id, sort)
SELECT
substr(id, length('set-agent-') + 1),
id,
10
FROM policy_sets
WHERE id LIKE 'set-agent-%';
CREATE TABLE IF NOT EXISTS policy_rule_resolved (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL REFERENCES policy_rules (id) ON DELETE CASCADE,
cidr TEXT NOT NULL,
resolved_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rule_resolved_rule_cidr
ON policy_rule_resolved (rule_id, cidr);
CREATE INDEX IF NOT EXISTS idx_policy_rule_resolved_rule
ON policy_rule_resolved (rule_id);
+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,
}