Assessor_2.0/server/gpo/gpo-backup-submission-servi...

206 lines
9.9 KiB
TypeScript

import { createHash } from 'node:crypto';
import type { ImportArtifact, IngestionJob } from '../../src/domain/ingestion-persistence';
import { normalizeArtifactRelativePath } from '../../src/domain/artifact-path';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import type { GpoInventoryExecutor } from './gpo-inventory-executor';
import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.6';
export interface SubmitGpoBackupRequest {
restoreArtifactId?: string;
projectId: string;
assessmentId?: string;
fileName: string;
bytes: Uint8Array;
receivedAt: string;
}
export interface SubmitGpoBackupResult {
sourceRestored?: boolean;
version: 1;
artifactId: string;
jobId: string;
sha256: string;
sizeBytes: number;
status: 'queued';
deduplicated: boolean;
repositoryRevision: number;
}
function stableId(prefix: string, value: string): string {
return `${prefix}-${createHash('sha256').update(value).digest('hex').slice(0, 32)}`;
}
function validatedRelativePath(fileName: string): string {
if (!fileName || fileName.length > 255 || fileName.includes('/') || fileName.includes('\\')) {
throw new RepositoryValidationError('Der GPO-Dateiname ist ungültig.');
}
const path = normalizeArtifactRelativePath(`gpo-backups/${fileName}`);
if (!path) throw new RepositoryValidationError('Der GPO-Dateiname ist kein sicherer relativer Artefaktpfad.');
return path;
}
export function createGpoBackupAnalysisJob(artifact: ImportArtifact, receivedAt: string): IngestionJob {
const idempotencyKey = `${GPO_BACKUP_ANALYZER_VERSION}:${artifact.id}:${artifact.relativePath}:${artifact.sha256}`;
return {
version: 1,
id: stableId('gpo-job', `${artifact.projectId}\0${idempotencyKey}`),
projectId: artifact.projectId,
artifactId: artifact.id,
kind: 'analyze-gpo-backup',
idempotencyKey,
status: 'queued',
attempts: 0,
maxAttempts: 3,
availableAt: receivedAt,
createdAt: receivedAt,
updatedAt: receivedAt
};
}
export function createGpoReprocessingJob(
artifact: ImportArtifact,
supersedesProcessingRunId: string,
requestedAt: string
): IngestionJob {
if (!supersedesProcessingRunId.trim()) throw new RepositoryValidationError('Der zu ersetzende GPO-Processing Run ist erforderlich.');
const idempotencyKey = `${GPO_BACKUP_ANALYZER_VERSION}:reprocess:${artifact.id}:${artifact.sha256}:${supersedesProcessingRunId}`;
return {
version: 1,
id: stableId('gpo-job', `${artifact.projectId}\0${idempotencyKey}`),
projectId: artifact.projectId,
artifactId: artifact.id,
supersedesProcessingRunId,
kind: 'analyze-gpo-backup',
idempotencyKey,
status: 'queued',
attempts: 0,
maxAttempts: 3,
availableAt: requestedAt,
createdAt: requestedAt,
updatedAt: requestedAt
};
}
export class GpoBackupSubmissionService {
constructor(
private readonly repository: AssessorRepository,
private readonly artifactStore: BackendArtifactStore,
private readonly inventoryExecutor: GpoInventoryExecutor,
private readonly maximumBytes: number
) {
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) throw new Error('Das GPO-Uploadlimit ist ungültig.');
}
async close(): Promise<void> {
await this.inventoryExecutor.close?.();
}
async submit(request: SubmitGpoBackupRequest): Promise<SubmitGpoBackupResult> {
if (!request.projectId.trim()) throw new RepositoryValidationError('Eine Projekt-ID ist erforderlich.');
if (!Number.isFinite(Date.parse(request.receivedAt))) throw new RepositoryValidationError('Der GPO-Eingangszeitpunkt ist ungültig.');
if (request.bytes.byteLength < 1 || request.bytes.byteLength > this.maximumBytes) {
throw new RepositoryValidationError('Der GPO-Container überschreitet das zulässige Größenlimit.');
}
let relativePath = validatedRelativePath(request.fileName);
if (!await this.repository.getProject(request.projectId)) throw new RepositoryNotFoundError(`Projekt ${request.projectId} ist nicht vorhanden.`);
if (request.assessmentId) {
const assessment = (await this.repository.listAssessments(request.projectId)).find(item => item.id === request.assessmentId);
if (!assessment) throw new RepositoryNotFoundError('Der gewählte Timeslot ist für diesen Mandanten nicht vorhanden.');
}
const sha256 = createHash('sha256').update(request.bytes).digest('hex');
const artifacts = await this.repository.listImportArtifacts(request.projectId);
const target = request.restoreArtifactId ? artifacts.find(item => item.id === request.restoreArtifactId
&& item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/')) : undefined;
if (request.restoreArtifactId && !target) throw new RepositoryNotFoundError('The GPO source to restore is not present in this project.');
if (target && (target.sha256 !== sha256 || target.sizeBytes !== request.bytes.byteLength
|| (request.assessmentId !== undefined && target.assessmentId !== request.assessmentId))) {
throw new RepositoryValidationError('This upload does not match the missing source. Select the unchanged original GPO ZIP or backup folder.');
}
if (target) relativePath = target.relativePath;
const artifactId = target?.id ?? stableId('gpo-artifact', `${request.projectId}\0${request.assessmentId ?? 'unassigned'}\0${relativePath}\0${sha256}`);
const existing = target ?? artifacts.find(item => item.id === artifactId);
let candidate: ImportArtifact;
let newStorageKey: string | undefined;
let sourceRestored = false;
if (existing) {
if (existing.sha256 !== sha256 || existing.sizeBytes !== request.bytes.byteLength || existing.storage.kind !== 'backend-controlled') {
throw new RepositoryValidationError('Die vorhandene GPO-Artefaktidentität ist inkonsistent.');
}
candidate = existing;
if (await gpoSourceAvailability(existing, this.artifactStore) === 'missing') {
await this.artifactStore.restore({ storageKey: existing.storage.storageKey, expectedSha256: sha256,
maximumBytes: this.maximumBytes, bytes: request.bytes });
sourceRestored = true;
} else {
await requireGpoSource(existing, this.artifactStore);
}
} else {
const recognition = await this.inventoryExecutor.recognize({ bytes: request.bytes });
if (recognition.recognition.status !== 'gpo-backup-candidate') throw new RepositoryValidationError('Der Container ist kein unterstützter GPO-Backupkandidat.');
const receipt = await this.artifactStore.put({ bytes: request.bytes, expectedSha256: sha256, maximumBytes: this.maximumBytes });
newStorageKey = receipt.storageKey;
candidate = {
version: 1,
id: artifactId,
projectId: request.projectId,
assessmentId: request.assessmentId,
kind: 'source-file',
relativePath,
sha256,
sizeBytes: receipt.sizeBytes,
sensitivity: 'secret',
parseStatus: 'pending',
storage: { kind: 'backend-controlled', storageKey: receipt.storageKey },
createdAt: request.receivedAt
};
}
try {
const latestRun = existing
? (await this.repository.listProcessingRuns(candidate.id))
.sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0]
: undefined;
if (latestRun) {
const currentAnalysis = (await this.repository.listGpoPolicyAnalyses(request.projectId))
.find(item => item.run.sourceProcessingRunId === latestRun.id && item.run.analyzer.version === GPO_BACKUP_ANALYZER_VERSION);
if (currentAnalysis) {
const latestJob = (await this.repository.listIngestionJobs(request.projectId))
.filter(item => item.artifactId === candidate.id && item.kind === 'analyze-gpo-backup')
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id))[0];
if (!latestJob) throw new RepositoryNotFoundError('Der aktuelle GPO-Analysejob ist nicht vorhanden.');
if (latestJob.status === 'dead-letter' && latestJob.lastErrorCode === 'artifact-store-object-missing') {
await this.repository.retryIngestionJob(request.projectId, latestJob.id, request.receivedAt);
}
return {
version: 1, artifactId: candidate.id, jobId: latestJob.id, sha256, sizeBytes: candidate.sizeBytes,
status: 'queued', deduplicated: true, sourceRestored, repositoryRevision: this.repository.revision
};
}
}
const analysisJob = latestRun
? createGpoReprocessingJob(candidate, latestRun.id, request.receivedAt)
: createGpoBackupAnalysisJob(candidate, request.receivedAt);
const committed = await this.repository.saveImportArtifactAndEnqueueJob(candidate, analysisJob);
if (committed.job.status === 'dead-letter' && committed.job.lastErrorCode === 'artifact-store-object-missing') {
await this.repository.retryIngestionJob(request.projectId, committed.job.id, request.receivedAt);
}
if (newStorageKey && !committed.artifactCreated) await this.artifactStore.remove(newStorageKey);
return {
version: 1,
artifactId: committed.artifact.id,
jobId: committed.job.id,
sha256,
sizeBytes: committed.artifact.sizeBytes,
status: 'queued',
deduplicated: Boolean(existing) || !committed.artifactCreated || !committed.jobCreated,
sourceRestored,
repositoryRevision: this.repository.revision
};
} catch (error) {
if (newStorageKey) await this.artifactStore.remove(newStorageKey).catch(() => undefined);
throw error;
}
}
}