feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user