Files
adam-acs/app/services/configuration_service.ts
T
Kevin L'HereecandCopilot aff8e14a63 feat: compliance report + auto-versioning for configurations
- Add ComplianceStatus.PENDING and configVersion to DeviceComplianceInfo
- Add ComplianceReport + ComplianceDeviceResult types to ConfigurationEngine
- Expose evaluateSpecific() as public (used by compliance + dry-run)
- Add complianceReport() method: filter matching devices, evaluate each
- Fix matchesCriteria: skip parameterCriteria entries with null/empty name
- Fix getDeviceParameterValue: fallback to plain object arrays (DeviceRecord)
- Fix findDevice: support lookup by both identifier and deviceId.id
- ConfigurationService.update: version is now auto-managed (no user input)
  - behavioral changes (criteria/params/policy/priority/…) → patch bump
  - bumpType field allows 'patch' | 'minor' | 'major'
  - cosmetic changes (name/tags) → no bump
- Add sanitizeCriteria: filter null/empty parameterCriteria names silently
- Add isBehavioralChange helper
- Add DeviceModel.resetComplianceForConfiguration call on behavioral update
- Add ConfigurationModel.distinctTags() + controller endpoint GET /tags
- Add controller endpoint GET /configurations/:id/compliance
- Add DeviceModel.getDistinctValues for inventory fields
- Fix unit tests: mock DeviceModel, remove obsolete version-validation test
- Add 14 new unit tests (engine: 5, service: 9)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 11:32:17 +02:00

600 lines
20 KiB
TypeScript

import ConfigurationModel from '#models/configuration'
import ConfigurationVersionModel from '#models/configuration_version'
import DeviceModel from '#models/device'
import {
type Configuration,
type ConfigurationCriteria,
type DesiredParameter,
type InventoryPath,
PolicyType,
} from '../domain/configuration/configuration.js'
import type { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
import { isParameterCoveredByInventory, suggestInventoryPath } from '../utils/inventory_coverage.js'
import { matchGlob } from '../utils/glob_match.js'
/**
* Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically).
*/
const ALLOWED_CRITERIA_FIELDS: ReadonlySet<string> = new Set([
'manufacturer',
'oui',
'productClass',
'serialNumber',
'parameterCriteria',
])
const IDENTIFIER_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/
export interface ConfigurationFilters {
page?: number
perPage?: number
tag?: string
name?: string
enabled?: boolean
productClass?: string
}
export interface CreateConfigurationDto {
identifier: string
name: string
version: string
priority: number
policy: PolicyType | string
enabled?: boolean
tags?: string[]
criteria: ConfigurationCriteria
inventory: InventoryPath[]
desiredParameters: DesiredParameter[]
firmwareTarget?: { productClass: string; targetVersion: string }
schedule?: {
daysOfWeek: number[]
startHour: number
endHour: number
timezone: string
}
}
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>> & {
bumpType?: 'patch' | 'minor' | 'major'
}
export interface OverlapWarning {
type: 'overlap_different_priority'
overlappingConfigurations: Array<{ identifier: string; priority: number }>
}
export type OverlapResult = OverlapWarning | null
export class ValidationError extends Error {
public readonly field: string
public readonly statusCode: number
constructor(message: string, field: string = '', statusCode: number = 422) {
super(message)
this.name = 'ValidationError'
this.field = field
this.statusCode = statusCode
}
}
export default class ConfigurationService {
/**
* Create a new Configuration after validation.
*/
async create(data: CreateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
this.validateIdentifier(data.identifier)
this.validateVersion(data.version)
this.validatePriority(data.priority)
this.validatePolicy(data.policy)
if (data.criteria) data.criteria = this.sanitizeCriteria(data.criteria)
this.validateCriteria(data.criteria)
this.validateParameterCriteriaDeadlock(data.criteria, data.desiredParameters)
this.validateInventoryCoverage(data.desiredParameters, data.inventory)
// Detect overlap — throws on same-priority conflict, returns warning for different-priority
const overlapResult = await this.detectOverlap(data.criteria, data.priority)
const configuration: Partial<Configuration> = {
identifier: data.identifier,
name: data.name,
version: data.version,
priority: data.priority,
policy: data.policy as PolicyType,
enabled: data.enabled ?? true,
tags: data.tags ?? [],
criteria: data.criteria,
inventory: data.inventory,
desiredParameters: data.desiredParameters,
firmwareTarget: data.firmwareTarget,
schedule: data.schedule,
}
const created = await ConfigurationModel.createIfNotExists(configuration)
if (overlapResult) {
return { ...created, warning: overlapResult }
}
return created
}
/**
* Return a paginated list of Configurations with optional filters.
*/
async findAll(filters: ConfigurationFilters = {}) {
const page = filters.page ?? 1
const perPage = filters.perPage ?? 25
const mongoFilter: Record<string, any> = {}
if (filters.tag) {
mongoFilter.tags = filters.tag
}
if (filters.name) {
mongoFilter.name = { $regex: filters.name, $options: 'i' }
}
if (filters.enabled !== undefined) {
mongoFilter.enabled = filters.enabled
}
if (filters.productClass) {
mongoFilter['criteria.productClass'] = filters.productClass
}
return ConfigurationModel.paginateConfigurations(page, perPage, mongoFilter)
}
/**
* Find a Configuration by its unique identifier.
*/
async findById(identifier: string): Promise<Configuration | null> {
return ConfigurationModel.findByIdentifier(identifier)
}
/**
* Increment the appropriate semver segment based on bump type.
* patch: 1.0.0 → 1.0.1 | minor: 1.0.0 → 1.1.0 | major: 1.0.0 → 2.0.0
*/
private bumpVersion(version: string, type: 'patch' | 'minor' | 'major' = 'patch'): string {
const [maj, min, pat] = version.split('.').map(Number)
if (type === 'major') return `${maj + 1}.0.0`
if (type === 'minor') return `${maj}.${min + 1}.0`
return `${maj}.${min}.${(pat ?? 0) + 1}`
}
/**
* Returns true if the update payload touches behavioral fields that affect
* device provisioning — as opposed to cosmetic fields like name or tags.
*/
private isBehavioralChange(data: UpdateConfigurationDto): boolean {
return (
data.criteria !== undefined ||
data.desiredParameters !== undefined ||
data.inventory !== undefined ||
data.policy !== undefined ||
data.priority !== undefined ||
data.firmwareTarget !== undefined ||
data.schedule !== undefined ||
data.enabled !== undefined
)
}
/**
* Update an existing Configuration after validation.
* Creates a version snapshot of the current state before applying changes.
* Auto-increments the patch version on behavioral changes and resets device compliance.
*/
async update(identifier: string, data: UpdateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
if (data.priority !== undefined) {
this.validatePriority(data.priority)
}
if (data.policy !== undefined) {
this.validatePolicy(data.policy)
}
if (data.criteria !== undefined) {
data.criteria = this.sanitizeCriteria(data.criteria)
this.validateCriteria(data.criteria)
}
// Validate inventory coverage with merged data
const finalDesiredParameters = data.desiredParameters ?? existing.desiredParameters
const finalInventory = data.inventory ?? existing.inventory
const finalCriteria2 = data.criteria ?? existing.criteria
this.validateParameterCriteriaDeadlock(finalCriteria2, finalDesiredParameters)
this.validateInventoryCoverage(finalDesiredParameters, finalInventory)
// Detect overlap if criteria or priority changed
let overlapResult: OverlapResult = null
const finalCriteria = data.criteria ?? existing.criteria
const finalPriority = data.priority ?? existing.priority
if (data.criteria !== undefined || data.priority !== undefined) {
overlapResult = await this.detectOverlap(finalCriteria, finalPriority, identifier)
}
const behavioralChange = this.isBehavioralChange(data)
// Create a version snapshot BEFORE applying changes
const latestVersionNumber = await ConfigurationVersionModel.getLatestVersionNumber(identifier)
const versionSnapshot: Omit<ConfigurationVersion, '_id'> = {
configurationIdentifier: identifier,
versionNumber: latestVersionNumber + 1,
snapshot: existing,
modifiedAt: new Date(),
}
await ConfigurationVersionModel.create(versionSnapshot)
// Build update payload (only provided fields)
// Version is always auto-managed: bump on behavioral changes using the requested type
const updatePayload: Record<string, any> = {}
if (behavioralChange) {
updatePayload.version = this.bumpVersion(existing.version, data.bumpType ?? 'patch')
}
if (data.name !== undefined) updatePayload.name = data.name
if (data.priority !== undefined) updatePayload.priority = data.priority
if (data.policy !== undefined) updatePayload.policy = data.policy
if (data.enabled !== undefined) updatePayload.enabled = data.enabled
if (data.tags !== undefined) updatePayload.tags = data.tags
if (data.criteria !== undefined) updatePayload.criteria = data.criteria
if (data.inventory !== undefined) updatePayload.inventory = data.inventory
if (data.desiredParameters !== undefined) updatePayload.desiredParameters = data.desiredParameters
if (data.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget
if (data.schedule !== undefined) updatePayload.schedule = data.schedule
await ConfigurationModel.updateOne({ identifier }, updatePayload)
// Reset compliance for all devices linked to this config so they re-evaluate
if (behavioralChange) {
await DeviceModel.resetComplianceForConfiguration(identifier)
}
const updated = await ConfigurationModel.findByIdentifier(identifier)
if (overlapResult) {
return { ...updated!, warning: overlapResult }
}
return updated!
}
/**
* Get the version history of a Configuration with pagination.
* Returns versions in reverse chronological order.
*/
async getHistory(
identifier: string,
page: number = 1,
limit: number = 25
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
return ConfigurationVersionModel.paginateVersions(identifier, page, limit)
}
/**
* Toggle the enabled flag of a Configuration without modifying other fields.
*/
async toggleEnabled(identifier: string, enabled: boolean): Promise<Configuration> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
await ConfigurationModel.updateOne({ identifier }, { enabled })
const updated = await ConfigurationModel.findByIdentifier(identifier)
return updated!
}
/**
* Add and/or remove tags from a Configuration without modifying other fields.
*/
async updateTags(
identifier: string,
add?: string[],
remove?: string[]
): Promise<Configuration> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
if (!existing) {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
let tags = [...existing.tags]
// Add tags (avoid duplicates)
if (add && add.length > 0) {
for (const tag of add) {
if (!tags.includes(tag)) {
tags.push(tag)
}
}
}
// Remove tags
if (remove && remove.length > 0) {
tags = tags.filter((tag) => !remove.includes(tag))
}
await ConfigurationModel.updateOne({ identifier }, { tags })
const updated = await ConfigurationModel.findByIdentifier(identifier)
return updated!
}
/**
* Delete a Configuration and all its associated versions.
*/
async delete(identifier: string): Promise<boolean> {
const deleted = await ConfigurationModel.deleteByIdentifier(identifier)
if (deleted) {
await ConfigurationVersionModel.deleteByConfigurationIdentifier(identifier)
}
return deleted
}
// ─── Overlap Detection ─────────────────────────────────────────────────────
/**
* Detect overlap between a Configuration's criteria and existing enabled Configurations.
*
* Two configurations overlap if their criteria could match the same device.
* - If both define the same field, check if patterns could match overlapping values
* - If one doesn't define a field, it matches all values → potential overlap
*
* @param criteria - The criteria to check for overlap
* @param priority - The priority of the configuration being created/updated
* @param excludeId - Optional identifier to exclude (for updates)
* @returns OverlapWarning if different-priority overlap found, null if no overlap
* @throws ValidationError with 409 if same-priority overlap found
*/
async detectOverlap(
criteria: ConfigurationCriteria,
priority: number,
excludeId?: string
): Promise<OverlapResult> {
const allEnabled = await ConfigurationModel.findAllEnabled()
const samePriorityConflicts: Array<{ identifier: string; priority: number }> = []
const differentPriorityOverlaps: Array<{ identifier: string; priority: number }> = []
for (const existing of allEnabled) {
// Skip the configuration being updated
if (excludeId && existing.identifier === excludeId) {
continue
}
if (this.criteriaOverlap(criteria, existing.criteria)) {
if (existing.priority === priority) {
samePriorityConflicts.push({
identifier: existing.identifier,
priority: existing.priority,
})
} else {
differentPriorityOverlaps.push({
identifier: existing.identifier,
priority: existing.priority,
})
}
}
}
// Same-priority overlap → reject with 409
if (samePriorityConflicts.length > 0) {
const identifiers = samePriorityConflicts.map((c) => c.identifier).join(', ')
throw new ValidationError(
`Priority conflict: configurations with the same priority ${priority} have overlapping criteria: ${identifiers}`,
'priority',
409
)
}
// Different-priority overlap → return warning
if (differentPriorityOverlaps.length > 0) {
return {
type: 'overlap_different_priority',
overlappingConfigurations: differentPriorityOverlaps,
}
}
return null
}
/**
* Check if two sets of criteria could potentially match the same device.
*
* Conservative approach:
* - If one criteria doesn't define a field, it matches ALL values → potential overlap
* - If both define the same field, check if patterns could overlap using glob matching
*/
private criteriaOverlap(
criteriaA: ConfigurationCriteria,
criteriaB: ConfigurationCriteria
): boolean {
const fields: Array<keyof ConfigurationCriteria> = [
'manufacturer',
'oui',
'productClass',
'serialNumber',
]
for (const field of fields) {
const patternA = criteriaA[field] as string | undefined
const patternB = criteriaB[field] as string | undefined
// If either doesn't define the field, it matches all → potential overlap on this field
if (!patternA || !patternB) {
continue
}
// Both define the field — check if patterns could match overlapping values
if (!this.patternsCouldOverlap(patternA, patternB)) {
return false
}
}
// All fields are potentially overlapping
return true
}
/**
* Determine if two glob patterns could match overlapping sets of values.
*
* Conservative approach: if one pattern matches the other (or vice versa),
* they could overlap. Also, if both are identical, they definitely overlap.
* For complex cases, we assume potential overlap (conservative).
*/
private patternsCouldOverlap(patternA: string, patternB: string): boolean {
// Identical patterns always overlap
if (patternA === patternB) {
return true
}
// Check if one pattern could match a value that the other also matches.
// Simple heuristic: try matching each pattern against the other as a literal value.
// If patternA matches patternB (as a value) or vice versa, they could overlap.
if (matchGlob(patternA, patternB) || matchGlob(patternB, patternA)) {
return true
}
// If either contains wildcards, be conservative and check for prefix overlap
const hasWildcardA = patternA.includes('*') || patternA.includes('?')
const hasWildcardB = patternB.includes('*') || patternB.includes('?')
if (hasWildcardA || hasWildcardB) {
// Extract the fixed prefix before the first wildcard
const prefixA = patternA.split(/[*?]/)[0]
const prefixB = patternB.split(/[*?]/)[0]
// If one prefix starts with the other, patterns could overlap
if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) {
return true
}
}
// No overlap detected
return false
}
// ─── Validation Methods ──────────────────────────────────────────────────────
private validateIdentifier(identifier: string): void {
if (!identifier || !IDENTIFIER_REGEX.test(identifier)) {
throw new ValidationError(
`Invalid identifier '${identifier}'. Must start with alphanumeric and contain only alphanumeric, hyphens, or underscores.`,
'identifier'
)
}
}
private validateVersion(version: string): void {
if (!version || !SEMVER_REGEX.test(version)) {
throw new ValidationError(
`Invalid version '${version}'. Must be a valid semver format (e.g. "1.0.0").`,
'version'
)
}
}
private validatePriority(priority: number): void {
if (!Number.isInteger(priority) || priority <= 0) {
throw new ValidationError(
`Invalid priority '${priority}'. Must be a positive integer greater than 0.`,
'priority'
)
}
}
private validatePolicy(policy: string): void {
if (policy !== PolicyType.ONCE && policy !== PolicyType.MAINTAIN) {
throw new ValidationError(
`Invalid policy '${policy}'. Must be "once" or "maintain".`,
'policy'
)
}
}
private validateCriteria(criteria: ConfigurationCriteria): void {
const fields = Object.keys(criteria)
for (const field of fields) {
if (!ALLOWED_CRITERIA_FIELDS.has(field)) {
throw new ValidationError(
`Invalid criteria field '${field}'. Only DeviceId fields are allowed: ${[...ALLOWED_CRITERIA_FIELDS].join(', ')}.`,
'criteria'
)
}
}
}
/** Remove parameterCriteria entries with null/empty name — avoids silent matching failures. */
private sanitizeCriteria(criteria: ConfigurationCriteria): ConfigurationCriteria {
if (!criteria.parameterCriteria?.length) return criteria
const filtered = criteria.parameterCriteria.filter(
(pc) => pc.name != null && String(pc.name).trim() !== ''
)
return {
...criteria,
parameterCriteria: filtered.length > 0 ? filtered : undefined,
}
}
/**
* Warn if a parameterCriteria path is also in desiredParameters — this creates a risk of
* the config never matching (deadlock) if the device doesn't already have that parameter value.
* We throw a ValidationError to inform the caller of this dangerous overlap.
*/
private validateParameterCriteriaDeadlock(
criteria: ConfigurationCriteria,
desiredParameters: DesiredParameter[]
): void {
const pcNames = (criteria.parameterCriteria || []).map((p) => p.name)
const dpNames = new Set((desiredParameters || []).map((p) => p.name))
const conflicts = pcNames.filter((name) => dpNames.has(name))
if (conflicts.length > 0) {
throw new ValidationError(
`Deadlock risk: parameter(s) [${conflicts.join(', ')}] appear in both criteria.parameterCriteria and desiredParameters. ` +
`A device that does not yet have these values will never match, and the config will never apply.`,
'criteria.parameterCriteria'
)
}
}
private validateInventoryCoverage(
desiredParameters: DesiredParameter[],
inventory: InventoryPath[]
): void {
if (!desiredParameters || desiredParameters.length === 0) {
return
}
const uncoveredParams: Array<{ name: string; suggestedPath: string }> = []
for (const param of desiredParameters) {
if (!isParameterCoveredByInventory(param.name, inventory)) {
uncoveredParams.push({
name: param.name,
suggestedPath: suggestInventoryPath(param.name),
})
}
}
if (uncoveredParams.length > 0) {
const details = uncoveredParams
.map((p) => `"${p.name}" (suggest adding "${p.suggestedPath}" to inventory)`)
.join(', ')
throw new ValidationError(
`Desired parameters not covered by inventory: ${details}`,
'desiredParameters'
)
}
}
}