feat(api, web): enhance agent management and linting capabilities
- Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks. - Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`. - Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization. - Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions. These changes enhance the overall development experience and improve the management of agents within the application.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agents } from '../schema.js'
|
||||
|
||||
export function listAgents(db: Db) {
|
||||
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
|
||||
}
|
||||
|
||||
export function getAgent(db: Db, id: string) {
|
||||
return db.select().from(agents).where(eq(agents.id, id)).get()
|
||||
}
|
||||
|
||||
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) {
|
||||
db.insert(agents).values(row).run()
|
||||
return getAgent(db, row.id)
|
||||
}
|
||||
|
||||
export function updateAgent(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof agents.$inferInsert>,
|
||||
) {
|
||||
db.update(agents).set(patch).where(eq(agents.id, id)).run()
|
||||
return getAgent(db, id)
|
||||
}
|
||||
|
||||
export function deleteAgent(db: Db, id: string) {
|
||||
db.delete(agents).where(eq(agents.id, id)).run()
|
||||
}
|
||||
@@ -1,571 +1,153 @@
|
||||
import { eq, and, desc, asc, sql, count, inArray } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
export {
|
||||
listAgents,
|
||||
getAgent,
|
||||
getAgentByTokenHash,
|
||||
insertAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
} from './agents.js'
|
||||
|
||||
export {
|
||||
listIpLists,
|
||||
getIpList,
|
||||
insertIpList,
|
||||
updateIpList,
|
||||
deleteIpList,
|
||||
listIpListEntries,
|
||||
countEntriesByListIds,
|
||||
replaceIpListEntries,
|
||||
} from './lists.js'
|
||||
|
||||
export {
|
||||
bumpAgentGeneration,
|
||||
bumpAgentsForSet,
|
||||
bumpAllApprovedAgents,
|
||||
bumpAgentsForList,
|
||||
listPolicySets,
|
||||
getPolicySet,
|
||||
insertPolicySet,
|
||||
updatePolicySet,
|
||||
deletePolicySet,
|
||||
countRulesInSet,
|
||||
countAgentsForSet,
|
||||
countRulesAndAgentsBySetIds,
|
||||
listAgentIdsForSet,
|
||||
listSetsForAgent,
|
||||
setAgentPolicySets,
|
||||
ensureSharedSetAssigned,
|
||||
listPolicyRules,
|
||||
listPolicyRulesForAgent,
|
||||
getPolicyRule,
|
||||
insertPolicyRule,
|
||||
updatePolicyRule,
|
||||
reorderPolicyRules,
|
||||
nextRulePriority,
|
||||
deletePolicyRule,
|
||||
listHostnameRules,
|
||||
listResolvedForRule,
|
||||
replaceResolvedForRule,
|
||||
listOverrides,
|
||||
insertOverride,
|
||||
deleteOverride,
|
||||
cloneRulesFrom,
|
||||
} from './policy.js'
|
||||
|
||||
export {
|
||||
insertStatsSample,
|
||||
listStatsSamples,
|
||||
listRecentStats,
|
||||
deleteStatsSamplesForAgent,
|
||||
} from './stats.js'
|
||||
|
||||
export {
|
||||
getSetting,
|
||||
setSetting,
|
||||
listSettings,
|
||||
} from './settings.js'
|
||||
|
||||
export {
|
||||
listInstallLinks,
|
||||
getInstallLink,
|
||||
getInstallLinkBySlug,
|
||||
getInstallLinkByAgentId,
|
||||
mapActiveInstallLinksByAgentId,
|
||||
insertInstallLink,
|
||||
revokeInstallLink,
|
||||
touchInstallLink,
|
||||
} from './install-links.js'
|
||||
|
||||
import {
|
||||
agents,
|
||||
ipLists,
|
||||
ipListEntries,
|
||||
policySets,
|
||||
agentPolicySets,
|
||||
policyRules,
|
||||
policyRuleResolved,
|
||||
ipOverrides,
|
||||
agentStatsSamples,
|
||||
agentInstallLinks,
|
||||
settings,
|
||||
SHARED_POLICY_SET_ID,
|
||||
} from '../schema.js'
|
||||
|
||||
export function listAgents(db: Db) {
|
||||
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
|
||||
}
|
||||
|
||||
export function getAgent(db: Db, id: string) {
|
||||
return db.select().from(agents).where(eq(agents.id, id)).get()
|
||||
}
|
||||
|
||||
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) {
|
||||
db.insert(agents).values(row).run()
|
||||
return getAgent(db, row.id)
|
||||
}
|
||||
|
||||
export function updateAgent(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof agents.$inferInsert>,
|
||||
) {
|
||||
db.update(agents).set(patch).where(eq(agents.id, id)).run()
|
||||
return getAgent(db, id)
|
||||
}
|
||||
|
||||
export function deleteAgent(db: Db, id: string) {
|
||||
db.delete(agents).where(eq(agents.id, id)).run()
|
||||
}
|
||||
|
||||
export function bumpAgentGeneration(db: Db, id: string) {
|
||||
db.update(agents)
|
||||
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
|
||||
.where(eq(agents.id, id))
|
||||
.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)
|
||||
}
|
||||
|
||||
/** Bump agents that have a policy rule referencing this IP list. */
|
||||
export function bumpAgentsForList(db: Db, listId: string) {
|
||||
const rules = db
|
||||
.select({ setId: policyRules.setId })
|
||||
.from(policyRules)
|
||||
.where(eq(policyRules.listId, listId))
|
||||
.all()
|
||||
const setIds = [...new Set(rules.map((r) => r.setId))]
|
||||
if (setIds.length === 0) {
|
||||
bumpAllApprovedAgents(db)
|
||||
return
|
||||
}
|
||||
for (const setId of setIds) bumpAgentsForSet(db, setId)
|
||||
}
|
||||
|
||||
export function listIpLists(db: Db) {
|
||||
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
|
||||
}
|
||||
|
||||
export function getIpList(db: Db, id: string) {
|
||||
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
|
||||
db.insert(ipLists).values(row).run()
|
||||
return getIpList(db, row.id)
|
||||
}
|
||||
|
||||
export function updateIpList(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof ipLists.$inferInsert>,
|
||||
) {
|
||||
db.update(ipLists)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ipLists.id, id))
|
||||
.run()
|
||||
return getIpList(db, id)
|
||||
}
|
||||
|
||||
export function deleteIpList(db: Db, id: string) {
|
||||
db.delete(ipLists).where(eq(ipLists.id, id)).run()
|
||||
}
|
||||
|
||||
export function listIpListEntries(db: Db, listId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(ipListEntries)
|
||||
.where(eq(ipListEntries.listId, listId))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
|
||||
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
|
||||
const now = new Date().toISOString()
|
||||
for (const cidr of cidrs) {
|
||||
db.insert(ipListEntries)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
listId,
|
||||
cidr,
|
||||
createdAt: now,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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')
|
||||
}
|
||||
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,
|
||||
policyMode: policySets.policyMode,
|
||||
})
|
||||
.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[]) {
|
||||
for (const setId of setIds) {
|
||||
if (!getPolicySet(db, setId)) {
|
||||
throw new Error(`policy set not found: ${setId}`)
|
||||
}
|
||||
}
|
||||
|
||||
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(eq(policyRules.setId, setId))
|
||||
.orderBy(asc(policyRules.priority))
|
||||
.all()
|
||||
}
|
||||
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(inArray(policyRules.setId, setIds))
|
||||
.all()
|
||||
|
||||
return rules
|
||||
.filter((r) => r.enabled !== 0)
|
||||
.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) {
|
||||
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertPolicyRule(
|
||||
db: Db,
|
||||
row: typeof policyRules.$inferInsert,
|
||||
) {
|
||||
db.insert(policyRules).values(row).run()
|
||||
return getPolicyRule(db, row.id)
|
||||
}
|
||||
|
||||
export function updatePolicyRule(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof policyRules.$inferInsert>,
|
||||
) {
|
||||
db.update(policyRules)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
return getPolicyRule(db, id)
|
||||
}
|
||||
|
||||
/** Renumber priorities 10, 20, … in given order. */
|
||||
export function reorderPolicyRules(
|
||||
db: Db,
|
||||
setId: string,
|
||||
orderedIds: string[],
|
||||
) {
|
||||
const existing = listPolicyRules(db, setId)
|
||||
const existingIds = new Set(existing.map((r) => r.id))
|
||||
if (
|
||||
orderedIds.length !== existing.length ||
|
||||
orderedIds.some((id) => !existingIds.has(id))
|
||||
) {
|
||||
throw new Error('ordered_ids must list every rule in the set exactly once')
|
||||
}
|
||||
// Temporary priorities to avoid UNIQUE collisions
|
||||
orderedIds.forEach((id, i) => {
|
||||
db.update(policyRules)
|
||||
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
})
|
||||
orderedIds.forEach((id, i) => {
|
||||
db.update(policyRules)
|
||||
.set({
|
||||
priority: (i + 1) * 10,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
export function nextRulePriority(db: Db, setId: string): number {
|
||||
const rows = listPolicyRules(db, setId)
|
||||
if (rows.length === 0) return 10
|
||||
const max = Math.max(...rows.map((r) => r.priority))
|
||||
return Math.min(10000, max + 10)
|
||||
}
|
||||
|
||||
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()
|
||||
.from(ipOverrides)
|
||||
.where(eq(ipOverrides.agentId, agentId))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function insertOverride(
|
||||
db: Db,
|
||||
row: typeof ipOverrides.$inferInsert,
|
||||
) {
|
||||
db.insert(ipOverrides).values(row).run()
|
||||
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
|
||||
}
|
||||
|
||||
export function deleteOverride(db: Db, id: string) {
|
||||
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
|
||||
}
|
||||
|
||||
export function insertStatsSample(
|
||||
db: Db,
|
||||
row: typeof agentStatsSamples.$inferInsert,
|
||||
) {
|
||||
db.insert(agentStatsSamples).values(row).run()
|
||||
}
|
||||
|
||||
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentStatsSamples)
|
||||
.where(eq(agentStatsSamples.agentId, agentId))
|
||||
.orderBy(desc(agentStatsSamples.recordedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function listRecentStats(db: Db, limit = 500) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentStatsSamples)
|
||||
.orderBy(desc(agentStatsSamples.recordedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
|
||||
db.delete(agentStatsSamples)
|
||||
.where(eq(agentStatsSamples.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function getSetting(db: Db, key: string): string {
|
||||
const row = db.select().from(settings).where(eq(settings.key, key)).get()
|
||||
return row?.value ?? ''
|
||||
}
|
||||
|
||||
export function setSetting(db: Db, key: string, value: string) {
|
||||
const now = new Date().toISOString()
|
||||
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
|
||||
if (existing) {
|
||||
db.update(settings)
|
||||
.set({ value, updatedAt: now })
|
||||
.where(eq(settings.key, key))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(settings).values({ key, value, updatedAt: now }).run()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
targetAgentId: string,
|
||||
includeOverrides: boolean,
|
||||
) {
|
||||
const source = getAgent(db, sourceAgentId)
|
||||
const target = getAgent(db, targetAgentId)
|
||||
if (!source || !target) return null
|
||||
|
||||
const sourceSets = listSetsForAgent(db, sourceAgentId)
|
||||
setAgentPolicySets(
|
||||
db,
|
||||
targetAgentId,
|
||||
sourceSets.map((s) => s.setId),
|
||||
)
|
||||
|
||||
if (includeOverrides) {
|
||||
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
|
||||
for (const o of listOverrides(db, sourceAgentId)) {
|
||||
db.insert(ipOverrides)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId: targetAgentId,
|
||||
cidr: o.cidr,
|
||||
action: o.action,
|
||||
comment: o.comment,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
updateAgent(db, targetAgentId, {
|
||||
defaultAction: source.defaultAction,
|
||||
policyGeneration: (target.policyGeneration ?? 1) + 1,
|
||||
})
|
||||
return getAgent(db, targetAgentId)
|
||||
}
|
||||
|
||||
export function listInstallLinks(db: Db) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.orderBy(desc(agentInstallLinks.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function getInstallLink(db: Db, id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getInstallLinkBySlug(db: Db, slug: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(eq(agentInstallLinks.slug, slug))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getInstallLinkByAgentId(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(agentInstallLinks.agentId, agentId),
|
||||
sql`${agentInstallLinks.revokedAt} IS NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentInstallLinks.createdAt))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function insertInstallLink(
|
||||
db: Db,
|
||||
row: typeof agentInstallLinks.$inferInsert,
|
||||
) {
|
||||
db.insert(agentInstallLinks).values(row).run()
|
||||
return getInstallLink(db, row.id)
|
||||
}
|
||||
|
||||
export function revokeInstallLink(db: Db, id: string) {
|
||||
const now = new Date().toISOString()
|
||||
db.update(agentInstallLinks)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.run()
|
||||
return getInstallLink(db, id)
|
||||
}
|
||||
|
||||
export function touchInstallLink(db: Db, id: string) {
|
||||
const now = new Date().toISOString()
|
||||
db.update(agentInstallLinks)
|
||||
.set({
|
||||
lastUsedAt: now,
|
||||
useCount: sql`${agentInstallLinks.useCount} + 1`,
|
||||
})
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.run()
|
||||
return getInstallLink(db, id)
|
||||
}
|
||||
listAgents,
|
||||
getAgent,
|
||||
getAgentByTokenHash,
|
||||
insertAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
} from './agents.js'
|
||||
import {
|
||||
listIpLists,
|
||||
getIpList,
|
||||
insertIpList,
|
||||
updateIpList,
|
||||
deleteIpList,
|
||||
listIpListEntries,
|
||||
countEntriesByListIds,
|
||||
replaceIpListEntries,
|
||||
} from './lists.js'
|
||||
import {
|
||||
bumpAgentGeneration,
|
||||
bumpAgentsForSet,
|
||||
bumpAllApprovedAgents,
|
||||
bumpAgentsForList,
|
||||
listPolicySets,
|
||||
getPolicySet,
|
||||
insertPolicySet,
|
||||
updatePolicySet,
|
||||
deletePolicySet,
|
||||
countRulesInSet,
|
||||
countAgentsForSet,
|
||||
countRulesAndAgentsBySetIds,
|
||||
listAgentIdsForSet,
|
||||
listSetsForAgent,
|
||||
setAgentPolicySets,
|
||||
ensureSharedSetAssigned,
|
||||
listPolicyRules,
|
||||
listPolicyRulesForAgent,
|
||||
getPolicyRule,
|
||||
insertPolicyRule,
|
||||
updatePolicyRule,
|
||||
reorderPolicyRules,
|
||||
nextRulePriority,
|
||||
deletePolicyRule,
|
||||
listHostnameRules,
|
||||
listResolvedForRule,
|
||||
replaceResolvedForRule,
|
||||
listOverrides,
|
||||
insertOverride,
|
||||
deleteOverride,
|
||||
cloneRulesFrom,
|
||||
} from './policy.js'
|
||||
import {
|
||||
insertStatsSample,
|
||||
listStatsSamples,
|
||||
listRecentStats,
|
||||
deleteStatsSamplesForAgent,
|
||||
} from './stats.js'
|
||||
import {
|
||||
getSetting,
|
||||
setSetting,
|
||||
listSettings,
|
||||
} from './settings.js'
|
||||
import {
|
||||
listInstallLinks,
|
||||
getInstallLink,
|
||||
getInstallLinkBySlug,
|
||||
getInstallLinkByAgentId,
|
||||
mapActiveInstallLinksByAgentId,
|
||||
insertInstallLink,
|
||||
revokeInstallLink,
|
||||
touchInstallLink,
|
||||
} from './install-links.js'
|
||||
|
||||
export const repos = {
|
||||
listAgents,
|
||||
@@ -584,6 +166,7 @@ export const repos = {
|
||||
updateIpList,
|
||||
deleteIpList,
|
||||
listIpListEntries,
|
||||
countEntriesByListIds,
|
||||
replaceIpListEntries,
|
||||
listPolicySets,
|
||||
getPolicySet,
|
||||
@@ -592,6 +175,7 @@ export const repos = {
|
||||
deletePolicySet,
|
||||
countRulesInSet,
|
||||
countAgentsForSet,
|
||||
countRulesAndAgentsBySetIds,
|
||||
listAgentIdsForSet,
|
||||
listSetsForAgent,
|
||||
setAgentPolicySets,
|
||||
@@ -622,9 +206,10 @@ export const repos = {
|
||||
getInstallLink,
|
||||
getInstallLinkBySlug,
|
||||
getInstallLinkByAgentId,
|
||||
mapActiveInstallLinksByAgentId,
|
||||
insertInstallLink,
|
||||
revokeInstallLink,
|
||||
touchInstallLink,
|
||||
}
|
||||
|
||||
export { SHARED_POLICY_SET_ID }
|
||||
export { SHARED_POLICY_SET_ID } from '../schema.js'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { eq, and, desc, sql } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agentInstallLinks } from '../schema.js'
|
||||
|
||||
export function listInstallLinks(db: Db) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.orderBy(desc(agentInstallLinks.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function getInstallLink(db: Db, id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getInstallLinkBySlug(db: Db, slug: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(eq(agentInstallLinks.slug, slug))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getInstallLinkByAgentId(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(agentInstallLinks.agentId, agentId),
|
||||
sql`${agentInstallLinks.revokedAt} IS NULL`,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentInstallLinks.createdAt))
|
||||
.get()
|
||||
}
|
||||
|
||||
/** Latest active install link per agent (one query). */
|
||||
export function mapActiveInstallLinksByAgentId(db: Db) {
|
||||
const rows = db
|
||||
.select()
|
||||
.from(agentInstallLinks)
|
||||
.where(sql`${agentInstallLinks.revokedAt} IS NULL`)
|
||||
.orderBy(desc(agentInstallLinks.createdAt))
|
||||
.all()
|
||||
const map = new Map<string, (typeof rows)[number]>()
|
||||
for (const row of rows) {
|
||||
if (!row.agentId) continue
|
||||
if (!map.has(row.agentId)) map.set(row.agentId, row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function insertInstallLink(
|
||||
db: Db,
|
||||
row: typeof agentInstallLinks.$inferInsert,
|
||||
) {
|
||||
db.insert(agentInstallLinks).values(row).run()
|
||||
return getInstallLink(db, row.id)
|
||||
}
|
||||
|
||||
export function revokeInstallLink(db: Db, id: string) {
|
||||
const now = new Date().toISOString()
|
||||
db.update(agentInstallLinks)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.run()
|
||||
return getInstallLink(db, id)
|
||||
}
|
||||
|
||||
export function touchInstallLink(db: Db, id: string) {
|
||||
const now = new Date().toISOString()
|
||||
db.update(agentInstallLinks)
|
||||
.set({
|
||||
lastUsedAt: now,
|
||||
useCount: sql`${agentInstallLinks.useCount} + 1`,
|
||||
})
|
||||
.where(eq(agentInstallLinks.id, id))
|
||||
.run()
|
||||
return getInstallLink(db, id)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { eq, desc, count, inArray } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { ipLists, ipListEntries } from '../schema.js'
|
||||
|
||||
export function listIpLists(db: Db) {
|
||||
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
|
||||
}
|
||||
|
||||
export function getIpList(db: Db, id: string) {
|
||||
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
|
||||
db.insert(ipLists).values(row).run()
|
||||
return getIpList(db, row.id)
|
||||
}
|
||||
|
||||
export function updateIpList(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof ipLists.$inferInsert>,
|
||||
) {
|
||||
db.update(ipLists)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ipLists.id, id))
|
||||
.run()
|
||||
return getIpList(db, id)
|
||||
}
|
||||
|
||||
export function deleteIpList(db: Db, id: string) {
|
||||
db.delete(ipLists).where(eq(ipLists.id, id)).run()
|
||||
}
|
||||
|
||||
export function listIpListEntries(db: Db, listId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(ipListEntries)
|
||||
.where(eq(ipListEntries.listId, listId))
|
||||
.all()
|
||||
}
|
||||
|
||||
/** Entry counts for many lists in one query. */
|
||||
export function countEntriesByListIds(
|
||||
db: Db,
|
||||
listIds: string[],
|
||||
): Map<string, number> {
|
||||
const map = new Map<string, number>()
|
||||
if (listIds.length === 0) return map
|
||||
const rows = db
|
||||
.select({
|
||||
listId: ipListEntries.listId,
|
||||
n: count(),
|
||||
})
|
||||
.from(ipListEntries)
|
||||
.where(inArray(ipListEntries.listId, listIds))
|
||||
.groupBy(ipListEntries.listId)
|
||||
.all()
|
||||
for (const r of rows) map.set(r.listId, r.n)
|
||||
return map
|
||||
}
|
||||
|
||||
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
|
||||
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
|
||||
if (cidrs.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(ipListEntries)
|
||||
.values(
|
||||
cidrs.map((cidr) => ({
|
||||
id: crypto.randomUUID(),
|
||||
listId,
|
||||
cidr,
|
||||
createdAt: now,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { eq, and, asc, sql, count, inArray } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import {
|
||||
agents,
|
||||
policySets,
|
||||
agentPolicySets,
|
||||
policyRules,
|
||||
policyRuleResolved,
|
||||
ipOverrides,
|
||||
SHARED_POLICY_SET_ID,
|
||||
} from '../schema.js'
|
||||
import { getAgent, updateAgent } from './agents.js'
|
||||
|
||||
export function bumpAgentGeneration(db: Db, id: string) {
|
||||
db.update(agents)
|
||||
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
|
||||
.where(eq(agents.id, id))
|
||||
.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)
|
||||
}
|
||||
|
||||
/** Bump agents that have a policy rule referencing this IP list. */
|
||||
export function bumpAgentsForList(db: Db, listId: string) {
|
||||
const rules = db
|
||||
.select({ setId: policyRules.setId })
|
||||
.from(policyRules)
|
||||
.where(eq(policyRules.listId, listId))
|
||||
.all()
|
||||
const setIds = [...new Set(rules.map((r) => r.setId))]
|
||||
// Unused list (no rules) — do not churn all approved agents.
|
||||
if (setIds.length === 0) return
|
||||
for (const setId of setIds) bumpAgentsForSet(db, setId)
|
||||
}
|
||||
|
||||
/* ── 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')
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/** Rule + agent counts for many sets in two queries. */
|
||||
export function countRulesAndAgentsBySetIds(
|
||||
db: Db,
|
||||
setIds: string[],
|
||||
): Map<string, { rules: number; agents: number }> {
|
||||
const map = new Map<string, { rules: number; agents: number }>()
|
||||
for (const id of setIds) map.set(id, { rules: 0, agents: 0 })
|
||||
if (setIds.length === 0) return map
|
||||
|
||||
const ruleRows = db
|
||||
.select({ setId: policyRules.setId, n: count() })
|
||||
.from(policyRules)
|
||||
.where(inArray(policyRules.setId, setIds))
|
||||
.groupBy(policyRules.setId)
|
||||
.all()
|
||||
for (const r of ruleRows) {
|
||||
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
|
||||
cur.rules = r.n
|
||||
map.set(r.setId, cur)
|
||||
}
|
||||
|
||||
const agentRows = db
|
||||
.select({ setId: agentPolicySets.setId, n: count() })
|
||||
.from(agentPolicySets)
|
||||
.where(inArray(agentPolicySets.setId, setIds))
|
||||
.groupBy(agentPolicySets.setId)
|
||||
.all()
|
||||
for (const r of agentRows) {
|
||||
const cur = map.get(r.setId) ?? { rules: 0, agents: 0 }
|
||||
cur.agents = r.n
|
||||
map.set(r.setId, cur)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
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,
|
||||
policyMode: policySets.policyMode,
|
||||
})
|
||||
.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[]) {
|
||||
for (const setId of setIds) {
|
||||
if (!getPolicySet(db, setId)) {
|
||||
throw new Error(`policy set not found: ${setId}`)
|
||||
}
|
||||
}
|
||||
|
||||
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(eq(policyRules.setId, setId))
|
||||
.orderBy(asc(policyRules.priority))
|
||||
.all()
|
||||
}
|
||||
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(inArray(policyRules.setId, setIds))
|
||||
.all()
|
||||
|
||||
return rules
|
||||
.filter((r) => r.enabled !== 0)
|
||||
.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) {
|
||||
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertPolicyRule(
|
||||
db: Db,
|
||||
row: typeof policyRules.$inferInsert,
|
||||
) {
|
||||
db.insert(policyRules).values(row).run()
|
||||
return getPolicyRule(db, row.id)
|
||||
}
|
||||
|
||||
export function updatePolicyRule(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<typeof policyRules.$inferInsert>,
|
||||
) {
|
||||
db.update(policyRules)
|
||||
.set({ ...patch, updatedAt: new Date().toISOString() })
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
return getPolicyRule(db, id)
|
||||
}
|
||||
|
||||
/** Renumber priorities 10, 20, … in given order. */
|
||||
export function reorderPolicyRules(
|
||||
db: Db,
|
||||
setId: string,
|
||||
orderedIds: string[],
|
||||
) {
|
||||
const existing = listPolicyRules(db, setId)
|
||||
const existingIds = new Set(existing.map((r) => r.id))
|
||||
if (
|
||||
orderedIds.length !== existing.length ||
|
||||
orderedIds.some((id) => !existingIds.has(id))
|
||||
) {
|
||||
throw new Error('ordered_ids must list every rule in the set exactly once')
|
||||
}
|
||||
// Temporary priorities to avoid UNIQUE collisions
|
||||
orderedIds.forEach((id, i) => {
|
||||
db.update(policyRules)
|
||||
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
})
|
||||
orderedIds.forEach((id, i) => {
|
||||
db.update(policyRules)
|
||||
.set({
|
||||
priority: (i + 1) * 10,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(policyRules.id, id))
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
export function nextRulePriority(db: Db, setId: string): number {
|
||||
const rows = listPolicyRules(db, setId)
|
||||
if (rows.length === 0) return 10
|
||||
const max = Math.max(...rows.map((r) => r.priority))
|
||||
return Math.min(10000, max + 10)
|
||||
}
|
||||
|
||||
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()
|
||||
if (cidrs.length === 0) return
|
||||
const now = new Date().toISOString()
|
||||
db.insert(policyRuleResolved)
|
||||
.values(
|
||||
cidrs.map((cidr) => ({
|
||||
id: crypto.randomUUID(),
|
||||
ruleId,
|
||||
cidr,
|
||||
resolvedAt: now,
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
export function listOverrides(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(ipOverrides)
|
||||
.where(eq(ipOverrides.agentId, agentId))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function insertOverride(
|
||||
db: Db,
|
||||
row: typeof ipOverrides.$inferInsert,
|
||||
) {
|
||||
db.insert(ipOverrides).values(row).run()
|
||||
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
|
||||
}
|
||||
|
||||
export function deleteOverride(db: Db, id: string) {
|
||||
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
|
||||
}
|
||||
|
||||
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
|
||||
export function cloneRulesFrom(
|
||||
db: Db,
|
||||
sourceAgentId: string,
|
||||
targetAgentId: string,
|
||||
includeOverrides: boolean,
|
||||
) {
|
||||
const source = getAgent(db, sourceAgentId)
|
||||
const target = getAgent(db, targetAgentId)
|
||||
if (!source || !target) return null
|
||||
|
||||
const sourceSets = listSetsForAgent(db, sourceAgentId)
|
||||
setAgentPolicySets(
|
||||
db,
|
||||
targetAgentId,
|
||||
sourceSets.map((s) => s.setId),
|
||||
)
|
||||
|
||||
if (includeOverrides) {
|
||||
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
|
||||
for (const o of listOverrides(db, sourceAgentId)) {
|
||||
db.insert(ipOverrides)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId: targetAgentId,
|
||||
cidr: o.cidr,
|
||||
action: o.action,
|
||||
comment: o.comment,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
updateAgent(db, targetAgentId, {
|
||||
defaultAction: source.defaultAction,
|
||||
policyGeneration: (target.policyGeneration ?? 1) + 1,
|
||||
})
|
||||
return getAgent(db, targetAgentId)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { settings } from '../schema.js'
|
||||
|
||||
export function getSetting(db: Db, key: string): string {
|
||||
const row = db.select().from(settings).where(eq(settings.key, key)).get()
|
||||
return row?.value ?? ''
|
||||
}
|
||||
|
||||
export function setSetting(db: Db, key: string, value: string) {
|
||||
const now = new Date().toISOString()
|
||||
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
|
||||
if (existing) {
|
||||
db.update(settings)
|
||||
.set({ value, updatedAt: now })
|
||||
.where(eq(settings.key, key))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(settings).values({ key, value, updatedAt: now }).run()
|
||||
}
|
||||
}
|
||||
|
||||
export function listSettings(db: Db) {
|
||||
return db.select().from(settings).all()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agentStatsSamples } from '../schema.js'
|
||||
|
||||
export function insertStatsSample(
|
||||
db: Db,
|
||||
row: typeof agentStatsSamples.$inferInsert,
|
||||
) {
|
||||
db.insert(agentStatsSamples).values(row).run()
|
||||
}
|
||||
|
||||
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentStatsSamples)
|
||||
.where(eq(agentStatsSamples.agentId, agentId))
|
||||
.orderBy(desc(agentStatsSamples.recordedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function listRecentStats(db: Db, limit = 500) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentStatsSamples)
|
||||
.orderBy(desc(agentStatsSamples.recordedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
|
||||
db.delete(agentStatsSamples)
|
||||
.where(eq(agentStatsSamples.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { putSettingsBodySchema } from './contracts.js'
|
||||
|
||||
describe('putSettingsBodySchema', () => {
|
||||
it('accepts whitelisted keys', () => {
|
||||
const parsed = putSettingsBodySchema.parse({
|
||||
enroll_seed: 'x',
|
||||
show_quick_actions: 'true',
|
||||
})
|
||||
expect(parsed.show_quick_actions).toBe('true')
|
||||
})
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
expect(() =>
|
||||
putSettingsBodySchema.parse({ evil_key: 'nope' }),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -320,6 +320,29 @@ export const evobgpCommunitySchema = z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
/** Whitelist keys for PUT /api/v1/settings. */
|
||||
export const SETTINGS_KEYS = [
|
||||
'enroll_seed',
|
||||
'evobgp_api_url',
|
||||
'evobgp_api_token',
|
||||
'agent_sync_interval_sec',
|
||||
'show_quick_actions',
|
||||
] as const
|
||||
|
||||
export const putSettingsBodySchema = z
|
||||
.record(z.string(), z.string())
|
||||
.superRefine((obj, ctx) => {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!(SETTINGS_KEYS as readonly string[]).includes(key)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: `Unknown settings key: ${key}`,
|
||||
path: [key],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type Agent = z.infer<typeof agentSchema>
|
||||
export type IpList = z.infer<typeof ipListSchema>
|
||||
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
||||
@@ -331,3 +354,4 @@ export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
||||
export type DefaultAction = z.infer<typeof defaultActionSchema>
|
||||
export type PutSettingsBody = z.infer<typeof putSettingsBodySchema>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { permissionForRequest } from '@evofw/shared'
|
||||
|
||||
describe('permissionForRequest', () => {
|
||||
it('maps install-links to agents permissions', () => {
|
||||
expect(permissionForRequest('GET', '/api/v1/install-links')).toBe(
|
||||
'fw:agents:read',
|
||||
)
|
||||
expect(permissionForRequest('POST', '/api/v1/install-links')).toBe(
|
||||
'fw:agents:write',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps integrations to lists read / settings admin', () => {
|
||||
expect(
|
||||
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
|
||||
).toBe('fw:lists:read')
|
||||
expect(
|
||||
permissionForRequest('POST', '/api/v1/integrations/evobgp/refresh'),
|
||||
).toBe('fw:settings:admin')
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ export function permissionForRequest(
|
||||
const m = method.toUpperCase()
|
||||
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
|
||||
|
||||
if (path.startsWith('/api/v1/agents')) {
|
||||
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
|
||||
return write ? 'fw:agents:write' : 'fw:agents:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/lists')) {
|
||||
@@ -45,6 +45,9 @@ export function permissionForRequest(
|
||||
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
|
||||
return 'fw:stats:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/integrations')) {
|
||||
return write ? 'fw:settings:admin' : 'fw:lists:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
|
||||
return write ? 'fw:settings:admin' : 'fw:settings:read'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user