415 lines
19 KiB
TypeScript
415 lines
19 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 !== '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);
|
|
}
|
|
}
|
|
|