Assessor_2.0/server/gpo/gpo-policy-snapshot-service.ts

420 lines
20 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { StoredGpoBackupInstance, StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
import type { GpoPolicyObservation, GpoPolicyValue, GpoRegistryPolicyValue } from '../../src/domain/gpo-policy';
import {
GPO_POLICY_SNAPSHOT_AGGREGATOR,
environmentProfileValidationErrors,
storedPolicySnapshotAggregationValidationErrors,
type AggregatedPolicyState,
type CreateEnvironmentProfileRequest,
type CreatePolicySnapshotAggregationRequest,
type EnvironmentProfile,
type EnvironmentProfileContext,
type PolicyAggregationReviewHint,
type StoredPolicySnapshotAggregation
} from '../../src/domain/gpo-snapshot-aggregation';
import type { GpoTechnicalPolicyIdentity } from '../../src/domain/gpo-policy-identity';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { canonicalJson } from '../../src/shared/canonical-json';
import { buildGpoAnalysisSnapshots } from '../../src/gpo/snapshot-builder';
function stableId(prefix: string, value: string): string {
return prefix + '-' + createHash('sha256').update(value).digest('hex').slice(0, 32);
}
function sortedUnique(values: string[]): string[] {
return [...new Set(values)].sort((left, right) => left.localeCompare(right));
}
function applicableContexts(profile: EnvironmentProfile, observation: GpoPolicyObservation): EnvironmentProfileContext[] {
return profile.contexts.filter(context => context.scope === 'mixed' || context.scope === observation.scope);
}
function valuesUnique(values: GpoPolicyValue[]): GpoPolicyValue[] {
return [...new Map(values.map(value => [canonicalJson(value), value])).entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([, value]) => value);
}
function observationSequence(observation: GpoPolicyObservation): number {
return observation.registryPolicy?.sequence ?? observation.preference?.sequence ?? 0;
}
interface ResolvedObservation {
observation: GpoPolicyObservation;
identity: GpoTechnicalPolicyIdentity;
}
interface BackupContribution {
status: AggregatedPolicyState['status'];
values: GpoPolicyValue[];
observationIds: string[];
targeting: AggregatedPolicyState['targeting'];
diagnosticCodes: string[];
}
const deleteOperations = new Set<GpoRegistryPolicyValue['operation']>([
'delete-value', 'delete-values', 'delete-all-values', 'delete-keys'
]);
function reduceBackupContribution(entries: ResolvedObservation[]): BackupContribution {
const observations = [...entries].sort((left, right) =>
observationSequence(left.observation) - observationSequence(right.observation)
|| left.observation.id.localeCompare(right.observation.id));
const observationIds = sortedUnique(observations.map(entry => entry.observation.id));
const diagnostics = new Set(observations.flatMap(entry => entry.observation.diagnosticCodes));
const targetingUnknown = observations.some(entry => entry.observation.preference?.targeting.status === 'present');
if (targetingUnknown) diagnostics.add('policy-aggregation-targeting-unknown');
if (observations.some(entry => entry.observation.state === 'conflicted')) {
diagnostics.add('policy-aggregation-source-conflict');
return {
status: 'conflicted',
values: valuesUnique(observations.flatMap(entry => entry.observation.values)),
observationIds,
targeting: targetingUnknown ? 'unknown' : 'none',
diagnosticCodes: [...diagnostics].sort()
};
}
const mechanism = observations[0].observation.deliveryMechanism;
if (mechanism === 'security-template' || mechanism === 'advanced-audit-policy') {
const values = valuesUnique(observations.flatMap(entry => entry.observation.values));
const complete = observations.every(entry => entry.observation.state === 'configured') && values.length === 1;
return { status: complete ? 'configured' : 'indeterminate', values, observationIds,
targeting: 'none', diagnosticCodes: [...diagnostics].sort() };
}
if (mechanism !== 'registry-policy') {
diagnostics.add('policy-aggregation-delivery-semantics-unsupported');
return {
status: 'indeterminate',
values: valuesUnique(observations.flatMap(entry => entry.observation.values)),
observationIds,
targeting: targetingUnknown ? 'unknown' : 'none',
diagnosticCodes: [...diagnostics].sort()
};
}
const last = observations[observations.length - 1].observation;
const value = last.values[0] as GpoRegistryPolicyValue;
if (targetingUnknown || !value || value.kind !== 'registry-policy' || value.operation === 'unsupported-special'
|| value.operation === 'set-value-if-absent') {
diagnostics.add(value?.operation === 'set-value-if-absent'
? 'policy-aggregation-prior-state-required'
: 'policy-aggregation-instruction-indeterminate');
return {
status: 'indeterminate',
values: valuesUnique(observations.flatMap(entry => entry.observation.values)),
observationIds,
targeting: targetingUnknown ? 'unknown' : 'none',
diagnosticCodes: [...diagnostics].sort()
};
}
return {
status: deleteOperations.has(value.operation) ? 'deleted' : 'configured',
values: [value],
observationIds,
targeting: 'none',
diagnosticCodes: [...diagnostics].sort()
};
}
function makeHint(
runId: string,
code: string,
observationIds: string[],
backupInstanceIds: string[],
identityId?: string
): PolicyAggregationReviewHint {
const sortedObservations = sortedUnique(observationIds);
const sortedBackups = sortedUnique(backupInstanceIds);
return {
version: 1,
id: stableId('policy-aggregation-hint', [
runId, code, identityId ?? '', ...sortedObservations, ...sortedBackups
].join('\0')),
code,
identityId,
observationIds: sortedObservations,
backupInstanceIds: sortedBackups
};
}
function aggregateIdentityGroup(
runId: string,
profile: EnvironmentProfile,
entries: ResolvedObservation[]
): { state: AggregatedPolicyState; hints: PolicyAggregationReviewHint[] } {
const identity = entries[0].identity;
const mechanism = entries[0].observation.deliveryMechanism;
const byBackup = new Map<string, ResolvedObservation[]>();
for (const entry of entries) {
byBackup.set(entry.observation.backupInstanceId, [...(byBackup.get(entry.observation.backupInstanceId) ?? []), entry]);
}
const contributions = [...byBackup.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([backupInstanceId, values]) => ({ backupInstanceId, contribution: reduceBackupContribution(values) }));
const diagnostics = new Set(contributions.flatMap(item => item.contribution.diagnosticCodes));
const signatures = new Set(contributions.map(item => canonicalJson({
status: item.contribution.status,
values: item.contribution.values
})));
const hasIndeterminate = contributions.some(item => item.contribution.status === 'indeterminate');
const hasConflict = contributions.some(item => item.contribution.status === 'conflicted') || signatures.size > 1;
const status: AggregatedPolicyState['status'] = hasIndeterminate
? 'indeterminate'
: hasConflict ? 'conflicted' : contributions[0].contribution.status;
if (hasConflict) diagnostics.add('policy-aggregation-cross-gpo-conflict');
if (hasIndeterminate) diagnostics.add('policy-aggregation-indeterminate');
const observationIds = sortedUnique(contributions.flatMap(item => item.contribution.observationIds));
const backupInstanceIds = sortedUnique(contributions.map(item => item.backupInstanceId));
const contextIds = sortedUnique(entries.flatMap(entry => applicableContexts(profile, entry.observation).map(context => context.id)));
const values = valuesUnique(contributions.flatMap(item => item.contribution.values));
const targeting = contributions.some(item => item.contribution.targeting === 'unknown') ? 'unknown' : 'none';
const ordering: AggregatedPolicyState['ordering'] = hasIndeterminate
? 'indeterminate'
: contributions.length === 1 ? 'single-gpo'
: hasConflict ? 'cross-gpo-conflict' : 'cross-gpo-identical';
const hints: PolicyAggregationReviewHint[] = [];
if (status === 'conflicted') {
hints.push(makeHint(runId, 'policy-aggregation-cross-gpo-conflict', observationIds, backupInstanceIds, identity.id));
}
if (status === 'indeterminate') {
hints.push(makeHint(runId, targeting === 'unknown'
? 'policy-aggregation-targeting-unknown'
: 'policy-aggregation-indeterminate', observationIds, backupInstanceIds, identity.id));
}
return {
state: {
version: 1,
id: stableId('aggregated-policy-state', runId + '\0' + identity.id + '\0' + mechanism),
aggregationRunId: runId,
identity,
deliveryMechanism: mechanism,
status,
values,
observationIds,
backupInstanceIds,
applicableContextIds: contextIds,
ordering,
targeting,
completeness: status === 'indeterminate' ? 'partial' : 'complete',
effectiveState: 'unknown',
diagnosticCodes: [...diagnostics].sort()
},
hints
};
}
export interface PolicySnapshotAggregationBuildInput {
profile: EnvironmentProfile;
analyses: StoredGpoPolicyAnalysis[];
instances: StoredGpoBackupInstance[];
startedAt: string;
completedAt: string;
}
export function buildPolicySnapshotAggregation(input: PolicySnapshotAggregationBuildInput): StoredPolicySnapshotAggregation {
const sourceAnalysisRunIds = sortedUnique(input.analyses.map(analysis => analysis.run.id));
const runId = stableId('policy-snapshot-aggregation', [
input.profile.projectId,
input.profile.id,
GPO_POLICY_SNAPSHOT_AGGREGATOR.version,
...sourceAnalysisRunIds
].join('\0'));
const snapshots = buildGpoAnalysisSnapshots(input.instances.map(record => record.instance), { createdAt: input.startedAt });
if (snapshots.length !== 1) {
throw new RepositoryValidationError('Die ausgewaehlten Analyselaeufe muessen genau einen konsistent abgrenzbaren Backup-Snapshot bilden.');
}
const snapshot = snapshots[0];
const hints: PolicyAggregationReviewHint[] = [];
const diagnostics = new Set(snapshot.diagnosticCodes);
const domainMismatch = Boolean(input.profile.domainKey)
&& (snapshot.domain.status !== 'resolved' || snapshot.domain.key !== input.profile.domainKey);
const blocked = snapshot.status === 'inconsistent' || domainMismatch;
if (domainMismatch) diagnostics.add('policy-aggregation-environment-domain-mismatch');
if (snapshot.status === 'inconsistent') diagnostics.add('policy-aggregation-snapshot-inconsistent');
if (blocked) {
hints.push(makeHint(runId, domainMismatch
? 'policy-aggregation-environment-domain-mismatch'
: 'policy-aggregation-snapshot-inconsistent', [], snapshot.instanceIds));
}
if (snapshot.completeness !== 'complete') {
diagnostics.add('policy-aggregation-snapshot-incomplete');
hints.push(makeHint(runId, 'policy-aggregation-snapshot-incomplete', [], snapshot.instanceIds));
}
const observations = new Map<string, GpoPolicyObservation>();
for (const record of input.instances) {
for (const observation of record.instance.policyObservations ?? []) observations.set(observation.id, observation);
}
const groups = new Map<string, ResolvedObservation[]>();
let unresolvedIdentityCount = 0;
if (!blocked) {
for (const analysis of input.analyses) {
for (const resolution of analysis.resolutions) {
const observation = observations.get(resolution.observationId);
if (!observation) throw new RepositoryValidationError('Eine Identity-Aufloesung verweist nicht auf die ausgewaehlte Snapshotbeobachtung.');
if (resolution.resolution.status === 'unresolved') {
unresolvedIdentityCount += 1;
diagnostics.add(resolution.resolution.reasonCode);
hints.push(makeHint(runId, resolution.resolution.reasonCode, [observation.id], [observation.backupInstanceId]));
continue;
}
const contexts = applicableContexts(input.profile, observation);
if (!contexts.length) {
hints.push(makeHint(runId, 'policy-scope-outside-environment-profile', [observation.id],
[observation.backupInstanceId], resolution.resolution.identity.id));
continue;
}
const key = resolution.resolution.identity.id + '\0' + observation.deliveryMechanism;
groups.set(key, [...(groups.get(key) ?? []), { observation, identity: resolution.resolution.identity }]);
}
}
}
const states: AggregatedPolicyState[] = [];
for (const [, entries] of [...groups].sort(([left], [right]) => left.localeCompare(right))) {
const aggregated = aggregateIdentityGroup(runId, input.profile, entries);
states.push(aggregated.state);
hints.push(...aggregated.hints);
aggregated.state.diagnosticCodes.forEach(code => diagnostics.add(code));
}
states.sort((left, right) => left.id.localeCompare(right.id));
const uniqueHints = [...new Map(hints.map(hint => [hint.id, hint])).values()]
.sort((left, right) => left.id.localeCompare(right.id));
const partial = snapshot.status !== 'consistent' || snapshot.completeness !== 'complete'
|| unresolvedIdentityCount > 0 || states.some(state => state.status === 'indeterminate');
const record: StoredPolicySnapshotAggregation = {
version: 1,
run: {
version: 1,
id: runId,
projectId: input.profile.projectId,
environmentProfileId: input.profile.id,
sourceAnalysisRunIds,
snapshotId: snapshot.id,
aggregator: GPO_POLICY_SNAPSHOT_AGGREGATOR,
status: blocked ? 'blocked' : partial ? 'partial' : 'complete',
stateCount: states.length,
conflictCount: states.filter(state => state.status === 'conflicted').length,
unresolvedIdentityCount,
reviewHintCount: uniqueHints.length,
permitsFindingGeneration: false,
diagnosticCodes: [...diagnostics].sort(),
startedAt: input.startedAt,
completedAt: input.completedAt
},
snapshot,
states,
reviewHints: uniqueHints
};
const errors = storedPolicySnapshotAggregationValidationErrors(record);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
return record;
}
export class GpoPolicySnapshotService {
private readonly now: () => Date;
constructor(private readonly repository: AssessorRepository, options: { now?: () => Date } = {}) {
this.now = options.now ?? (() => new Date());
}
async createEnvironmentProfile(projectId: string, request: CreateEnvironmentProfileRequest): Promise<EnvironmentProfile> {
if (!request || typeof request.name !== 'string' || !request.name.trim() || !Array.isArray(request.contexts)
|| request.contexts.some(context => !context || typeof context.id !== 'string'
|| !['operating-system', 'product'].includes(context.kind)
|| typeof context.productFamily !== 'string' || typeof context.productVersion !== 'string'
|| typeof context.role !== 'string' || !['computer', 'user', 'mixed'].includes(context.scope))
|| (request.supersedesProfileId !== undefined && typeof request.supersedesProfileId !== 'string')
|| (request.domainKey !== undefined && typeof request.domainKey !== 'string')) {
throw new RepositoryValidationError('Die Environment-Profile-Anfrage ist ungueltig.');
}
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('Das Projekt des Environment Profiles ist nicht vorhanden.');
const profiles = await this.repository.listEnvironmentProfiles(projectId);
const predecessor = request.supersedesProfileId
? profiles.find(profile => profile.id === request.supersedesProfileId)
: undefined;
if (request.supersedesProfileId && !predecessor) {
throw new RepositoryNotFoundError('Das zu ersetzende Environment Profile ist nicht vorhanden.');
}
const contexts = request.contexts.map(context => ({ ...context, version: 1 as const }))
.sort((left, right) => left.id.localeCompare(right.id));
const semantic = canonicalJson({
projectId,
name: request.name.trim(),
supersedesProfileId: request.supersedesProfileId,
domainKey: request.domainKey?.trim(),
contexts
});
const id = stableId('environment-profile', semantic);
const existing = profiles.find(profile => profile.id === id);
if (existing) return existing;
const profile: EnvironmentProfile = {
version: 1,
id,
projectId,
name: request.name.trim(),
revision: (predecessor?.revision ?? 0) + 1,
supersedesProfileId: predecessor?.id,
domainKey: request.domainKey?.trim() || undefined,
contexts,
createdAt: this.now().toISOString()
};
const errors = environmentProfileValidationErrors(profile);
if (errors.length) throw new RepositoryValidationError(errors.join(' '));
await this.repository.saveEnvironmentProfile(profile);
return profile;
}
listEnvironmentProfiles(projectId: string): Promise<EnvironmentProfile[]> {
return this.repository.listEnvironmentProfiles(projectId);
}
async aggregate(projectId: string, request: CreatePolicySnapshotAggregationRequest): Promise<StoredPolicySnapshotAggregation> {
if (!request || typeof request.environmentProfileId !== 'string' || !request.environmentProfileId.trim()
|| !Array.isArray(request.sourceAnalysisRunIds)
|| request.sourceAnalysisRunIds.some(id => typeof id !== 'string' || !id.trim())) {
throw new RepositoryValidationError('Die Policy-Snapshot-Aggregationsanfrage ist ungueltig.');
}
const profile = (await this.repository.listEnvironmentProfiles(projectId))
.find(candidate => candidate.id === request.environmentProfileId);
if (!profile) throw new RepositoryNotFoundError('Das Environment Profile ist im Projekt nicht vorhanden.');
const requestedIds = sortedUnique(request.sourceAnalysisRunIds);
if (!requestedIds.length || requestedIds.length !== request.sourceAnalysisRunIds.length) {
throw new RepositoryValidationError('Mindestens ein eindeutiger Identity-Analysis-Run ist erforderlich.');
}
const allAnalyses = await this.repository.listGpoPolicyAnalyses(projectId);
const analyses = requestedIds.map(id => allAnalyses.find(candidate => candidate.run.id === id));
if (analyses.some(analysis => !analysis)) {
throw new RepositoryNotFoundError('Mindestens ein Identity-Analysis-Run ist im Projekt nicht vorhanden.');
}
const selected = analyses as StoredGpoPolicyAnalysis[];
const expectedRunId = stableId('policy-snapshot-aggregation', [
projectId, profile.id, GPO_POLICY_SNAPSHOT_AGGREGATOR.version, ...requestedIds
].join('\0'));
const existing = (await this.repository.listPolicySnapshotAggregations(projectId))
.find(record => record.run.id === expectedRunId);
if (existing) return existing;
const processingRunIds = new Set(selected.map(analysis => analysis.run.sourceProcessingRunId));
const instances = (await this.repository.listGpoBackupInstances(projectId))
.filter(record => processingRunIds.has(record.processingRunId));
if (!instances.length) throw new RepositoryNotFoundError('Die ausgewaehlten Analyselaeufe besitzen keine Backupinstanzen.');
const startedAt = this.now().toISOString();
const record = buildPolicySnapshotAggregation({
profile,
analyses: selected,
instances,
startedAt,
completedAt: this.now().toISOString()
});
await this.repository.savePolicySnapshotAggregation(record);
return record;
}
listAggregations(projectId: string): Promise<StoredPolicySnapshotAggregation[]> {
return this.repository.listPolicySnapshotAggregations(projectId);
}
}