feat(api, web): implement policy mode management for agents and rules
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m46s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added support for policy modes ('blacklist' and 'whitelist') in agent and policy set management.
- Updated API endpoints to handle policy mode during agent assignment and rule operations.
- Enhanced the web UI to display and manage policy modes for agents and rules, ensuring all assigned sets share a consistent mode.
- Introduced new validation to enforce single policy mode across assigned sets for agents.
- Improved error handling for policy mode conflicts and updated documentation accordingly.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 02:16:51 +07:00
co-authored by Cursor
parent d5784b9f35
commit 90d50c2556
15 changed files with 1261 additions and 266 deletions
+92 -3
View File
@@ -7,6 +7,8 @@ import {
createPolicySetBodySchema,
createInstallLinkBodySchema,
patchPolicySetBodySchema,
patchPolicyRuleBodySchema,
reorderPolicyRulesBodySchema,
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
@@ -74,6 +76,8 @@ function mapPolicySet(
name: s.name,
description: s.description,
enabled: s.enabled === 1,
policy_mode:
s.policyMode === 'whitelist' ? ('whitelist' as const) : ('blacklist' as const),
rules_count: repos.countRulesInSet(db, s.id),
agents_count: repos.countAgentsForSet(db, s.id),
created_at: s.createdAt,
@@ -90,6 +94,7 @@ function mapPolicyRule(
set_id: r.setId,
priority: r.priority,
action: r.action,
enabled: r.enabled !== 0,
list_id: r.listId,
cidr: r.cidr,
hostname: r.hostname,
@@ -496,6 +501,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
name: body.name.trim(),
description: body.description ?? null,
enabled: body.enabled === false ? 0 : 1,
policyMode: body.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
@@ -510,8 +516,37 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
name: body.name?.trim(),
description: body.description,
enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0,
policyMode: body.policy_mode,
})
if (body.enabled !== undefined) repos.bumpAgentsForSet(app.db, s.id)
if (body.enabled !== undefined || body.policy_mode !== undefined) {
repos.bumpAgentsForSet(app.db, s.id)
}
// Sync agent.policy_mode cache when set mode changes
if (body.policy_mode) {
for (const agentId of repos.listAgentIdsForSet(app.db, s.id)) {
try {
const sets = repos.listSetsForAgent(app.db, agentId)
const modes = new Set(
sets
.filter((x) => x.enabled === 1)
.map((x) =>
x.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
),
)
if (modes.size > 1) {
throw new AppError(
'VALIDATION_ERROR',
'агент имеет наборы с разными режимами — выровняйте mode',
400,
)
}
const mode = [...modes][0] ?? body.policy_mode
repos.updateAgent(app.db, agentId, { policyMode: mode })
} catch (err) {
if (err instanceof AppError) throw err
}
}
}
return mapPolicySet(updated!, app.db)
})
@@ -554,7 +589,15 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404)
}
}
repos.setAgentPolicySets(app.db, a.id, body.set_ids)
try {
repos.setAgentPolicySets(app.db, a.id, body.set_ids)
} catch (err) {
throw new AppError(
'VALIDATION_ERROR',
err instanceof Error ? err.message : String(err),
400,
)
}
return {
items: repos.listSetsForAgent(app.db, a.id).map((s) => ({
set_id: s.setId,
@@ -562,6 +605,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
name: s.name,
description: s.description,
enabled: s.enabled === 1,
policy_mode:
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
})),
}
},
@@ -579,6 +624,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
name: s.name,
description: s.description,
enabled: s.enabled === 1,
policy_mode:
s.policyMode === 'whitelist' ? 'whitelist' : 'blacklist',
})),
}
},
@@ -627,12 +674,16 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
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: body.priority,
priority,
action: body.action,
enabled: body.enabled === false ? 0 : 1,
listId,
cidr,
hostname,
@@ -659,6 +710,44 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
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)
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)
return {
items: repos
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, 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)
+19 -3
View File
@@ -44,6 +44,19 @@ function expandRule(
return expandList(db, rule.listId)
}
/** Effective mode = first enabled assigned set (by sort); default blacklist. */
export function resolveAgentPolicyMode(
db: Db,
agentId: string,
): 'blacklist' | 'whitelist' {
const sets = repos
.listSetsForAgent(db, agentId)
.filter((s) => s.enabled === 1)
if (sets.length === 0) return 'blacklist'
const mode = sets[0]?.policyMode
return mode === 'whitelist' ? 'whitelist' : 'blacklist'
}
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const agent = repos.getAgent(db, agentId)
@@ -69,9 +82,12 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const denyCidrs = uniq(deny)
const allowCidrs = uniq(allow)
const policyMode = (agent.policyMode === 'whitelist'
? 'whitelist'
: 'blacklist') as 'blacklist' | 'whitelist'
const policyMode = resolveAgentPolicyMode(db, agentId)
// Keep agent.policy_mode cache in sync for list/API compat
if (agent.policyMode !== policyMode) {
repos.updateAgent(db, agentId, { policyMode })
}
const payload = JSON.stringify({
generation: agent.policyGeneration,
@@ -0,0 +1,127 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../../app.js'
import type { AppConfig } from '../../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('policy set mode + rules', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('set policy_mode and reorder; disabled rules skipped in policy', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: {
name: 'WL set',
policy_mode: 'whitelist',
},
})
expect(created.statusCode).toBe(200)
const set = created.json() as { id: string; policy_mode: string }
expect(set.policy_mode).toBe('whitelist')
const r1 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '10.0.0.1/32',
},
})
expect(r1.statusCode).toBe(200)
const rule1 = r1.json() as { id: string; enabled: boolean; priority: number }
const r2 = await app.inject({
method: 'POST',
url: '/api/v1/rules',
payload: {
set_id: set.id,
action: 'allow',
cidr: '10.0.0.2/32',
},
})
const rule2 = r2.json() as { id: string }
const reordered = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${set.id}/rules/reorder`,
payload: { ordered_ids: [rule2.id, rule1.id] },
})
expect(reordered.statusCode).toBe(200)
const items = (
reordered.json() as { items: { id: string; priority: number }[] }
).items
expect(items[0]?.id).toBe(rule2.id)
expect(items[0]!.priority).toBeLessThan(items[1]!.priority)
await app.inject({
method: 'PATCH',
url: `/api/v1/rules/${rule1.id}`,
payload: { enabled: false },
})
// enroll + approve agent, assign set
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name: 'mt-wl',
platform: 'linux',
token: 'evofw_policy_mode_token_abcdef12',
},
})
const agent = enroll.json() as { id: string }
await app.inject({
method: 'POST',
url: `/api/v1/agents/${agent.id}/approve`,
})
const assign = await app.inject({
method: 'PUT',
url: `/api/v1/agents/${agent.id}/policy-sets`,
payload: { set_ids: [set.id] },
})
expect(assign.statusCode).toBe(200)
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: {
authorization: 'Bearer evofw_policy_mode_token_abcdef12',
},
})
expect(policy.statusCode).toBe(200)
const body = policy.json() as {
policy_mode: string
allow_cidrs: string[]
}
expect(body.policy_mode).toBe('whitelist')
expect(body.allow_cidrs).toContain('10.0.0.2/32')
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
expect(rule1.enabled).toBe(true)
})
})