206 lines
9.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|