refactor(admin): split 8 oversized lib/ files into focused modules under 500 LOC
Split these files that exceeded the 500-line hard cap: - privacy-policy.ts (965 LOC) -> sections + renderers - academy/api.ts (787 LOC) -> courses + mock-data - whistleblower/api.ts (755 LOC) -> operations + mock-data - vvt-profiling.ts (659 LOC) -> data + logic - cookie-banner.ts (595 LOC) -> config + embed - dsr/types.ts (581 LOC) -> core + api types - tom-generator/rules-engine.ts (560 LOC) -> evaluator + gap-analysis - datapoint-helpers.ts (548 LOC) -> generators + validators Each original file becomes a barrel re-export for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
276
admin-compliance/lib/sdk/tom-generator/rules-evaluator.ts
Normal file
276
admin-compliance/lib/sdk/tom-generator/rules-evaluator.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* TOM Rules Engine — Control Evaluation
|
||||
*
|
||||
* Evaluates control applicability based on company context.
|
||||
* Core engine class with condition evaluation and TOM derivation.
|
||||
*/
|
||||
|
||||
import {
|
||||
ControlLibraryEntry,
|
||||
ApplicabilityCondition,
|
||||
ControlApplicability,
|
||||
RulesEngineResult,
|
||||
RulesEngineEvaluationContext,
|
||||
DerivedTOM,
|
||||
EvidenceDocument,
|
||||
GapAnalysisResult,
|
||||
ConditionOperator,
|
||||
} from './types'
|
||||
import { getAllControls, getControlById } from './controls/loader'
|
||||
|
||||
// =============================================================================
|
||||
// RULES ENGINE CLASS — Evaluation Methods
|
||||
// =============================================================================
|
||||
|
||||
export class TOMRulesEngine {
|
||||
private controls: ControlLibraryEntry[]
|
||||
|
||||
constructor() {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
|
||||
evaluateControls(context: RulesEngineEvaluationContext): RulesEngineResult[] {
|
||||
return this.controls.map((control) => this.evaluateControl(control, context))
|
||||
}
|
||||
|
||||
evaluateControl(
|
||||
control: ControlLibraryEntry,
|
||||
context: RulesEngineEvaluationContext
|
||||
): RulesEngineResult {
|
||||
const sortedConditions = [...control.applicabilityConditions].sort(
|
||||
(a, b) => b.priority - a.priority
|
||||
)
|
||||
|
||||
for (const condition of sortedConditions) {
|
||||
const matches = this.evaluateCondition(condition, context)
|
||||
if (matches) {
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: condition.result,
|
||||
reason: this.formatConditionReason(condition, context),
|
||||
matchedCondition: condition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
controlId: control.id,
|
||||
applicability: control.defaultApplicability,
|
||||
reason: 'Standard-Anwendbarkeit (keine spezifische Bedingung erfuellt)',
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): boolean {
|
||||
const value = this.getFieldValue(condition.field, context)
|
||||
if (value === undefined || value === null) return false
|
||||
return this.evaluateOperator(condition.operator, value, condition.value)
|
||||
}
|
||||
|
||||
private getFieldValue(fieldPath: string, context: RulesEngineEvaluationContext): unknown {
|
||||
const parts = fieldPath.split('.')
|
||||
let current: unknown = context
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined
|
||||
if (typeof current === 'object') {
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
private evaluateOperator(
|
||||
operator: ConditionOperator,
|
||||
actualValue: unknown,
|
||||
expectedValue: unknown
|
||||
): boolean {
|
||||
switch (operator) {
|
||||
case 'EQUALS':
|
||||
return actualValue === expectedValue
|
||||
case 'NOT_EQUALS':
|
||||
return actualValue !== expectedValue
|
||||
case 'CONTAINS':
|
||||
if (Array.isArray(actualValue)) return actualValue.includes(expectedValue)
|
||||
if (typeof actualValue === 'string' && typeof expectedValue === 'string')
|
||||
return actualValue.includes(expectedValue)
|
||||
return false
|
||||
case 'GREATER_THAN':
|
||||
if (typeof actualValue === 'number' && typeof expectedValue === 'number')
|
||||
return actualValue > expectedValue
|
||||
return false
|
||||
case 'IN':
|
||||
if (Array.isArray(expectedValue)) return expectedValue.includes(actualValue)
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private formatConditionReason(
|
||||
condition: ApplicabilityCondition,
|
||||
context: RulesEngineEvaluationContext
|
||||
): string {
|
||||
const fieldValue = this.getFieldValue(condition.field, context)
|
||||
const fieldLabel = this.getFieldLabel(condition.field)
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'EQUALS':
|
||||
return `${fieldLabel} ist "${this.formatValue(fieldValue)}"`
|
||||
case 'NOT_EQUALS':
|
||||
return `${fieldLabel} ist nicht "${this.formatValue(condition.value)}"`
|
||||
case 'CONTAINS':
|
||||
return `${fieldLabel} enthaelt "${this.formatValue(condition.value)}"`
|
||||
case 'GREATER_THAN':
|
||||
return `${fieldLabel} ist groesser als ${this.formatValue(condition.value)}`
|
||||
case 'IN':
|
||||
return `${fieldLabel} ("${this.formatValue(fieldValue)}") ist in [${Array.isArray(condition.value) ? condition.value.join(', ') : condition.value}]`
|
||||
default:
|
||||
return `Bedingung erfuellt: ${condition.field} ${condition.operator} ${this.formatValue(condition.value)}`
|
||||
}
|
||||
}
|
||||
|
||||
private getFieldLabel(fieldPath: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'companyProfile.role': 'Unternehmensrolle',
|
||||
'companyProfile.size': 'Unternehmensgroesse',
|
||||
'dataProfile.hasSpecialCategories': 'Besondere Datenkategorien',
|
||||
'dataProfile.processesMinors': 'Verarbeitung von Minderjaehrigen-Daten',
|
||||
'dataProfile.dataVolume': 'Datenvolumen',
|
||||
'dataProfile.thirdCountryTransfers': 'Drittlanduebermittlungen',
|
||||
'architectureProfile.hostingModel': 'Hosting-Modell',
|
||||
'architectureProfile.hostingLocation': 'Hosting-Standort',
|
||||
'architectureProfile.multiTenancy': 'Mandantentrennung',
|
||||
'architectureProfile.hasSubprocessors': 'Unterauftragsverarbeiter',
|
||||
'architectureProfile.encryptionAtRest': 'Verschluesselung ruhender Daten',
|
||||
'securityProfile.hasMFA': 'Multi-Faktor-Authentifizierung',
|
||||
'securityProfile.hasSSO': 'Single Sign-On',
|
||||
'securityProfile.hasPAM': 'Privileged Access Management',
|
||||
'riskProfile.protectionLevel': 'Schutzbedarf',
|
||||
'riskProfile.dsfaRequired': 'DSFA erforderlich',
|
||||
'riskProfile.ciaAssessment.confidentiality': 'Vertraulichkeit',
|
||||
'riskProfile.ciaAssessment.integrity': 'Integritaet',
|
||||
'riskProfile.ciaAssessment.availability': 'Verfuegbarkeit',
|
||||
}
|
||||
return labels[fieldPath] || fieldPath
|
||||
}
|
||||
|
||||
private formatValue(value: unknown): string {
|
||||
if (value === true) return 'Ja'
|
||||
if (value === false) return 'Nein'
|
||||
if (value === null || value === undefined) return 'nicht gesetzt'
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
deriveAllTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
const results = this.evaluateControls(context)
|
||||
|
||||
return results.map((result) => {
|
||||
const control = getControlById(result.controlId)
|
||||
if (!control) throw new Error(`Control not found: ${result.controlId}`)
|
||||
|
||||
return {
|
||||
id: `derived-${result.controlId}`,
|
||||
controlId: result.controlId,
|
||||
name: control.name.de,
|
||||
description: control.description.de,
|
||||
applicability: result.applicability,
|
||||
applicabilityReason: result.reason,
|
||||
implementationStatus: 'NOT_IMPLEMENTED',
|
||||
responsiblePerson: null,
|
||||
responsibleDepartment: null,
|
||||
implementationDate: null,
|
||||
reviewDate: null,
|
||||
linkedEvidence: [],
|
||||
evidenceGaps: [...control.evidenceRequirements],
|
||||
aiGeneratedDescription: null,
|
||||
aiRecommendations: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getApplicableTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
return this.deriveAllTOMs(context).filter(
|
||||
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
}
|
||||
|
||||
getRequiredTOMs(context: RulesEngineEvaluationContext): DerivedTOM[] {
|
||||
return this.deriveAllTOMs(context).filter((tom) => tom.applicability === 'REQUIRED')
|
||||
}
|
||||
|
||||
getControlsByApplicability(
|
||||
context: RulesEngineEvaluationContext,
|
||||
applicability: ControlApplicability
|
||||
): ControlLibraryEntry[] {
|
||||
return this.evaluateControls(context)
|
||||
.filter((r) => r.applicability === applicability)
|
||||
.map((r) => getControlById(r.controlId))
|
||||
.filter((c): c is ControlLibraryEntry => c !== undefined)
|
||||
}
|
||||
|
||||
getSummaryStatistics(context: RulesEngineEvaluationContext): {
|
||||
total: number; required: number; recommended: number; optional: number; notApplicable: number
|
||||
byCategory: Map<string, { required: number; recommended: number }>
|
||||
} {
|
||||
const results = this.evaluateControls(context)
|
||||
const stats = {
|
||||
total: results.length, required: 0, recommended: 0, optional: 0, notApplicable: 0,
|
||||
byCategory: new Map<string, { required: number; recommended: number }>(),
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
switch (result.applicability) {
|
||||
case 'REQUIRED': stats.required++; break
|
||||
case 'RECOMMENDED': stats.recommended++; break
|
||||
case 'OPTIONAL': stats.optional++; break
|
||||
case 'NOT_APPLICABLE': stats.notApplicable++; break
|
||||
}
|
||||
|
||||
const control = getControlById(result.controlId)
|
||||
if (control) {
|
||||
const existing = stats.byCategory.get(control.category) || { required: 0, recommended: 0 }
|
||||
if (result.applicability === 'REQUIRED') existing.required++
|
||||
else if (result.applicability === 'RECOMMENDED') existing.recommended++
|
||||
stats.byCategory.set(control.category, existing)
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
isControlApplicable(controlId: string, context: RulesEngineEvaluationContext): boolean {
|
||||
const control = getControlById(controlId)
|
||||
if (!control) return false
|
||||
const result = this.evaluateControl(control, context)
|
||||
return result.applicability === 'REQUIRED' || result.applicability === 'RECOMMENDED'
|
||||
}
|
||||
|
||||
getControlsByTagWithApplicability(
|
||||
tag: string,
|
||||
context: RulesEngineEvaluationContext
|
||||
): Array<{ control: ControlLibraryEntry; result: RulesEngineResult }> {
|
||||
return this.controls
|
||||
.filter((control) => control.tags.includes(tag))
|
||||
.map((control) => ({ control, result: this.evaluateControl(control, context) }))
|
||||
}
|
||||
|
||||
performGapAnalysis(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
documents: EvidenceDocument[]
|
||||
): GapAnalysisResult {
|
||||
// Delegate to standalone function to keep this class focused on evaluation
|
||||
const { performGapAnalysis: doGapAnalysis } = require('./gap-analysis')
|
||||
return doGapAnalysis(derivedTOMs, documents)
|
||||
}
|
||||
|
||||
reloadControls(): void {
|
||||
this.controls = getAllControls()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user