Basis: Certvia dev@a48c5fb als Fundament für Craftvia
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s

Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:05:39 +02:00
co-authored by Claude Opus 5
commit c8e6f30a27
720 changed files with 140143 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
// Storage-Adapter (Story B4-2 → Epic S1). Gekapselte Schnittstelle für die Datei-
// Persistenz hochgeladener eigener Richtlinien und Audit-Nachweise. Aufrufer nutzen
// ausschließlich `storage`, sodass das Backend ohne UI-/Action-Änderung austauschbar
// bleibt (Konsumenten: policy-upload.ts, audit-evidence.ts).
//
// Backend-Wahl zur Laufzeit (Graceful Fallback):
// - Sind alle S3_*-Env gesetzt → echter S3StorageAdapter (Garage/S3-kompatibel).
// - Sonst → StubStorageAdapter (nur Metadaten/Key, keine Bytes) — kein Crash.
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadBucketCommand,
} from "@aws-sdk/client-s3";
import { randomUUID } from "node:crypto";
export interface StoredObject {
/** Referenz zum späteren Abruf (Schema abhängig vom Backend). */
storageKey: string;
filename: string;
size: number;
contentType: string | null;
}
export interface PutInput {
tenantId: string;
filename: string;
contentType?: string | null;
/** Roh-Bytes; im Stub NICHT persistiert (nur Größe wird erfasst). */
bytes?: Uint8Array | null;
}
/** Abrufbares Objekt: Web-Stream + Metadaten für die Download-Route. */
export interface StoredContent {
stream: ReadableStream<Uint8Array>;
contentType: string | null;
size: number | null;
filename: string;
}
export interface StorageAdapter {
put(input: PutInput): Promise<StoredObject>;
/**
* Objekt anhand seines Keys abrufen. `null`, wenn nicht vorhanden oder das
* Backend keine Bytes persistiert (Stub). Der Aufrufer (Download-Route) MUSS
* die Mandantenbindung des Keys vorab prüfen.
*/
get(key: string): Promise<StoredContent | null>;
}
/** Dateinamen auf ein ASCII-/pfadsicheres Fragment reduzieren (auch S3-Metadata-tauglich). */
function safeFilename(filename: string): string {
return filename.replace(/[^\w.\-]+/g, "_").slice(0, 120) || "datei";
}
/** Stub: vergibt einen deterministischen Key, speichert keine Bytes. */
class StubStorageAdapter implements StorageAdapter {
async put({ tenantId, filename, contentType, bytes }: PutInput): Promise<StoredObject> {
const safe = safeFilename(filename);
return {
storageKey: `stub://${tenantId}/uploads/${safe}`,
filename: safe,
size: bytes?.byteLength ?? 0,
contentType: contentType ?? null,
};
}
/** Stub persistiert keine Bytes → kein Abruf möglich. */
async get(): Promise<StoredContent | null> {
return null;
}
}
interface S3Config {
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
region: string;
}
/**
* Echter Objektspeicher (Garage / S3-kompatibel). `forcePathStyle: true` ist zwingend
* (Bucket im Pfad statt Subdomain) — Garage bedient ausschließlich Path-Style.
*
* Der Bucket wird beim ersten Zugriff nur noch VERIFIZIERT (HeadBucket), NICHT mehr
* angelegt: Garage verwaltet Buckets/Keys über seine Admin-API/CLI, nicht über die
* S3-Operation `CreateBucket`. Die Provisionierung erfolgt out-of-band (Init-Job
* `garage-provision`, scripts/garage-provision.ts). Fehlt der Bucket, wird ein klarer
* Konfigurationsfehler geworfen statt still zu heilen.
*
* Key-Schema: `<tenantId>/uploads/<uuid>-<safeFilename>` — mandantenpräfixiert, sodass
* die Download-Route die Tenant-Zugehörigkeit rein am Key-Präfix verifizieren kann.
* Der Original-(bereinigte) Dateiname wird zusätzlich als Objekt-Metadatum abgelegt
* (für den Content-Disposition-Header beim Abruf).
*/
class S3StorageAdapter implements StorageAdapter {
private readonly client: S3Client;
private readonly bucket: string;
private bucketReady: Promise<void> | null = null;
constructor(cfg: S3Config) {
this.bucket = cfg.bucket;
this.client = new S3Client({
endpoint: cfg.endpoint,
region: cfg.region,
forcePathStyle: true,
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
});
}
/**
* Bucket einmal je Prozess VERIFIZIEREN (HeadBucket). Kein `CreateBucket` mehr:
* Garage legt Buckets nicht über die S3-API an (siehe Klassen-Doc) → fehlt der
* Bucket, ist das ein Konfigurationsfehler (Provisioning nicht gelaufen), kein
* selbstheilbarer Zustand.
*/
private ensureBucket(): Promise<void> {
if (!this.bucketReady) {
this.bucketReady = (async () => {
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
} catch (err) {
this.bucketReady = null; // erneuten Versuch beim nächsten Aufruf zulassen
const name = (err as { name?: string })?.name ?? String(err);
throw new Error(
`Objektspeicher-Bucket „${this.bucket}" nicht erreichbar oder nicht provisioniert ` +
`(${name}). Garage legt Buckets NICHT über die S3-API an — bitte das ` +
`Garage-Provisioning ausführen (Compose-Service „garage-provision").`,
);
}
})();
}
return this.bucketReady;
}
async put({ tenantId, filename, contentType, bytes }: PutInput): Promise<StoredObject> {
await this.ensureBucket();
const safe = safeFilename(filename);
const key = `${tenantId}/uploads/${randomUUID()}-${safe}`;
const body = bytes ?? new Uint8Array(0);
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: body,
ContentType: contentType ?? undefined,
Metadata: { filename: safe },
}),
);
return {
storageKey: key,
filename: safe,
size: body.byteLength,
contentType: contentType ?? null,
};
}
async get(key: string): Promise<StoredContent | null> {
try {
const res = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
);
if (!res.Body) return null;
// AWS SDK v3 (Node): Body ist ein Stream mit transformToWebStream().
const stream = (res.Body as {
transformToWebStream: () => ReadableStream<Uint8Array>;
}).transformToWebStream();
return {
stream,
contentType: res.ContentType ?? null,
size: typeof res.ContentLength === "number" ? res.ContentLength : null,
filename: res.Metadata?.filename ?? key.slice(key.lastIndexOf("/") + 1),
};
} catch (err) {
const name = (err as { name?: string })?.name ?? "";
if (name === "NoSuchKey" || name === "NotFound") return null;
throw err;
}
}
}
/** S3-Konfiguration aus der Umgebung lesen; nur vollständig gesetzt → S3-Backend. */
function readS3Config(): S3Config | null {
const endpoint = process.env.S3_ENDPOINT?.trim();
const accessKey = process.env.S3_ACCESS_KEY?.trim();
const secretKey = process.env.S3_SECRET_KEY?.trim();
const bucket = process.env.S3_BUCKET?.trim();
if (!endpoint || !accessKey || !secretKey || !bucket) return null;
return {
endpoint,
accessKey,
secretKey,
bucket,
region: process.env.S3_REGION?.trim() || "us-east-1",
};
}
/** Backend-Auswahl zur Laufzeit: echtes S3, sonst Stub-Fallback. */
function createStorage(): StorageAdapter {
const cfg = readS3Config();
return cfg ? new S3StorageAdapter(cfg) : new StubStorageAdapter();
}
/** Prozessweiter Adapter. */
export const storage: StorageAdapter = createStorage();
+336
View File
@@ -0,0 +1,336 @@
// ── Objektspeicher für Backup-Artefakte (Garage/S3, Prefix je Mandant) ────────
//
// Getrennt vom Upload-Adapter (adapter.ts), weil Backups arbiträre Keys,
// Auflistung und Löschung brauchen. Key-Schema: `<tenantId>/backups/<snapshot>/…`
// — mandantenpräfixiert, damit die Zugehörigkeit rein am Prefix verifizierbar ist.
//
// Backend-Wahl zur Laufzeit (Lane „Konfigurierbarer Backup-Zielspeicher"):
// Präzedenz: DB-Config (PlatformSetting) → Env (S3_*/BACKUP_LOCAL_DIR)
// → lokaler Default `<cwd>/.backups`.
// - DB-Config zählt als explizit, wenn `backupTarget = s3` ODER `backupTarget =
// local` MIT gesetztem `backupLocalDir`. Ein unberührter Default-Datensatz
// (target=local, dir=NULL) fällt bewusst auf Env zurück → bestehende
// Env-Deployments (S3_*) bleiben unverändert lauffähig (Rückwärtskompatibilität).
// - Fail-secure: `backupTarget = s3` mit unvollständiger Config wirft einen
// klaren Fehler und fällt NICHT still auf lokal.
// - Das S3-Secret liegt in der DB nur verschlüsselt (secret-crypto) und wird
// erst hier — im Speicher — entschlüsselt. Der lokale Datei-Store persistiert
// Bytes echt (Export→Restore end-to-end; anders als der Upload-Stub).
//
// Nutzung: IMMER `await getBackupStore()` aufrufen (kein statisches Singleton mehr),
// damit eine geänderte Betreiber-Konfiguration ohne Redeploy greift (Cache über
// PlatformSetting.updatedAt invalidiert).
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectsCommand,
HeadBucketCommand,
} from "@aws-sdk/client-s3";
import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { prisma } from "../db";
import { decryptSecret } from "../secret-crypto";
export interface BackupStore {
put(key: string, bytes: Buffer): Promise<void>;
get(key: string): Promise<Buffer | null>;
list(prefix: string): Promise<string[]>;
remove(prefix: string): Promise<number>;
}
interface S3Config {
endpoint: string;
accessKey: string;
secretKey: string;
bucket: string;
region: string;
}
function readS3Config(): S3Config | null {
const endpoint = process.env.S3_ENDPOINT?.trim();
const accessKey = process.env.S3_ACCESS_KEY?.trim();
const secretKey = process.env.S3_SECRET_KEY?.trim();
const bucket = process.env.S3_BUCKET?.trim();
if (!endpoint || !accessKey || !secretKey || !bucket) return null;
return {
endpoint,
accessKey,
secretKey,
bucket,
region: process.env.S3_REGION?.trim() || "us-east-1",
};
}
class S3BackupStore implements BackupStore {
private readonly client: S3Client;
private readonly bucket: string;
private ensured = false;
constructor(cfg: S3Config) {
this.bucket = cfg.bucket;
this.client = new S3Client({
endpoint: cfg.endpoint,
region: cfg.region,
forcePathStyle: true,
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
});
}
/**
* Bucket beim ersten Zugriff nur noch VERIFIZIEREN (HeadBucket), NICHT anlegen:
* Garage verwaltet Buckets/Keys über seine Admin-API/CLI, nicht über die S3-
* Operation `CreateBucket`. Die Provisionierung erfolgt out-of-band (Init-Job
* `garage-provision`). Fehlt der Bucket, wird ein sprechender Konfigurationsfehler
* geworfen — statt (wie früher) den `CreateBucket`-Fehler still zu schlucken, was
* spätere Puts erst kryptisch scheitern ließ.
*/
private async ensureBucket(): Promise<void> {
if (this.ensured) return;
try {
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
} catch (err) {
const name = (err as { name?: string })?.name ?? String(err);
throw new Error(
`Backup-Bucket „${this.bucket}" nicht erreichbar oder nicht provisioniert (${name}). ` +
`Garage legt Buckets NICHT über die S3-API an — bitte das Garage-Provisioning ausführen ` +
`(Compose-Service „garage-provision") oder den Backup-Zielspeicher auf „Lokal" stellen.`,
);
}
this.ensured = true;
}
async put(key: string, bytes: Buffer): Promise<void> {
await this.ensureBucket();
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: bytes }),
);
}
async get(key: string): Promise<Buffer | null> {
try {
const res = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
);
if (!res.Body) return null;
const bytes = await (res.Body as { transformToByteArray: () => Promise<Uint8Array> }).transformToByteArray();
return Buffer.from(bytes);
} catch (err) {
const name = (err as { name?: string })?.name ?? "";
if (name === "NoSuchKey" || name === "NotFound") return null;
throw err;
}
}
async list(prefix: string): Promise<string[]> {
const keys: string[] = [];
let token: string | undefined;
do {
const res = await this.client.send(
new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }),
);
for (const o of res.Contents ?? []) if (o.Key) keys.push(o.Key);
token = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (token);
return keys;
}
async remove(prefix: string): Promise<number> {
const keys = await this.list(prefix);
if (!keys.length) return 0;
// DeleteObjects: max 1000 pro Aufruf.
for (let i = 0; i < keys.length; i += 1000) {
await this.client.send(
new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: { Objects: keys.slice(i, i + 1000).map((Key) => ({ Key })) },
}),
);
}
return keys.length;
}
}
class LocalBackupStore implements BackupStore {
private readonly root: string;
constructor(root: string) {
this.root = root;
}
private path(key: string): string {
return join(this.root, key);
}
async put(key: string, bytes: Buffer): Promise<void> {
const p = this.path(key);
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, bytes);
}
async get(key: string): Promise<Buffer | null> {
const p = this.path(key);
return existsSync(p) ? readFileSync(p) : null;
}
async list(prefix: string): Promise<string[]> {
const base = this.root;
const out: string[] = [];
const walk = (dir: string) => {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else {
const rel = full.slice(base.length + 1).split("\\").join("/");
if (rel.startsWith(prefix)) out.push(rel);
}
}
};
walk(base);
return out.sort();
}
async remove(prefix: string): Promise<number> {
const keys = await this.list(prefix);
for (const k of keys) rmSync(this.path(k), { force: true });
return keys.length;
}
}
/** Lokaler Default-Ordner, wenn weder DB- noch Env-Config ein Ziel vorgibt. */
function defaultLocalDir(): string {
return process.env.BACKUP_LOCAL_DIR?.trim() || join(process.cwd(), ".backups");
}
/**
* Betreiber-Zielkonfiguration (aus PlatformSetting) MIT bereits ENTSCHLÜSSELTEM
* S3-Secret (Klartext nur im Speicher). Rein datenhaltend, damit die Auflösung
* (`resolveBackupStore`) ohne DB testbar bleibt.
*/
export interface BackupTargetConfig {
backupTarget: string; // "local" | "s3"
backupLocalDir: string | null;
backupS3Endpoint: string | null;
backupS3Bucket: string | null;
backupS3Region: string | null;
backupS3AccessKey: string | null;
/** ENTSCHLÜSSELTES S3-Secret (nie aus der DB im Klartext). */
backupS3SecretKey: string | null;
}
/**
* Zählt die DB-Config als explizite Betreiber-Wahl (→ hat Vorrang vor Env)?
* - `s3` : immer explizit (Vollständigkeit prüft resolveBackupStore, fail-secure).
* - `local` : nur explizit, wenn ein `backupLocalDir` persistiert wurde. Ein
* unberührter Default-Datensatz (local/NULL) fällt auf Env zurück.
*/
function isDbConfigured(cfg: BackupTargetConfig): boolean {
if (cfg.backupTarget === "s3") return true;
if (cfg.backupTarget === "local") return !!cfg.backupLocalDir?.trim();
return false;
}
/**
* Reine Auflösung Config→Store (ohne DB/IO). Präzedenz: explizite DB-Config →
* Env → lokaler Default. Wirft bei `backupTarget = s3` mit unvollständiger Config
* (fail-secure: NICHT still auf lokal fallen).
*/
export function resolveBackupStore(cfg: BackupTargetConfig | null): BackupStore {
if (cfg && isDbConfigured(cfg)) {
if (cfg.backupTarget === "s3") {
const endpoint = cfg.backupS3Endpoint?.trim();
const bucket = cfg.backupS3Bucket?.trim();
const accessKey = cfg.backupS3AccessKey?.trim();
const secretKey = cfg.backupS3SecretKey?.trim();
const missing = [
!endpoint && "Endpoint",
!bucket && "Bucket",
!accessKey && "Access-Key",
!secretKey && "Secret-Key",
].filter(Boolean);
if (missing.length) {
throw new Error(
`Backup-Zielspeicher „S3" ist unvollständig konfiguriert (fehlt: ${missing.join(", ")}). ` +
`Konfiguration im Betreiber-Portal vervollständigen oder Ziel auf „Lokal" stellen.`,
);
}
return new S3BackupStore({
endpoint: endpoint!,
bucket: bucket!,
accessKey: accessKey!,
secretKey: secretKey!,
region: cfg.backupS3Region?.trim() || "us-east-1",
});
}
// Explizit lokal (backupLocalDir garantiert gesetzt).
return new LocalBackupStore(cfg.backupLocalDir!.trim());
}
// Keine explizite DB-Config → Env-Fallback (Rückwärtskompatibilität).
const env = readS3Config();
if (env) return new S3BackupStore(env);
return new LocalBackupStore(defaultLocalDir());
}
// Cache: der aufgelöste Store wird memoisiert und über PlatformSetting.updatedAt
// invalidiert (Betreiber ändert das Ziel → nächster Aufruf baut neu, kein Redeploy).
let cached: { key: string; store: BackupStore } | null = null;
/** Setzt den Store-Cache zurück (nach Config-Änderung; auch für Tests). */
export function invalidateBackupStore(): void {
cached = null;
}
/**
* Liefert den zur Laufzeit gültigen Backup-Store. Liest PlatformSetting,
* entschlüsselt das S3-Secret und wendet die Präzedenz DB→Env→Default an.
* Ergebnis wird bis zur nächsten Settings-Änderung (updatedAt) gecacht.
*/
export async function getBackupStore(): Promise<BackupStore> {
let setting: {
backupTarget: string;
backupLocalDir: string | null;
backupS3Endpoint: string | null;
backupS3Bucket: string | null;
backupS3Region: string | null;
backupS3AccessKey: string | null;
backupS3SecretKeyEnc: string | null;
updatedAt: Date;
} | null = null;
try {
setting = await prisma.platformSetting.findUnique({
where: { id: "singleton" },
select: {
backupTarget: true,
backupLocalDir: true,
backupS3Endpoint: true,
backupS3Bucket: true,
backupS3Region: true,
backupS3AccessKey: true,
backupS3SecretKeyEnc: true,
updatedAt: true,
},
});
} catch {
// DB (noch) nicht erreichbar → Env-Fallback statt harter Fehler beim Import-Kontext.
setting = null;
}
const key = setting ? `db:${setting.updatedAt.getTime()}` : "env";
if (cached && cached.key === key) return cached.store;
const cfg: BackupTargetConfig | null = setting
? {
backupTarget: setting.backupTarget,
backupLocalDir: setting.backupLocalDir,
backupS3Endpoint: setting.backupS3Endpoint,
backupS3Bucket: setting.backupS3Bucket,
backupS3Region: setting.backupS3Region,
backupS3AccessKey: setting.backupS3AccessKey,
backupS3SecretKey: setting.backupS3SecretKeyEnc
? decryptSecret(setting.backupS3SecretKeyEnc)
: null,
}
: null;
const store = resolveBackupStore(cfg);
cached = { key, store };
return store;
}