@cosmicdrift/kumiko-framework 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/package.json +4 -3
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/table-builder.ts +18 -1
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/boot-validator.ts +276 -0
- package/src/engine/define-feature.ts +39 -0
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +15 -5
- package/src/engine/feature-ast/extractors.ts +40 -0
- package/src/engine/feature-ast/parse.ts +6 -0
- package/src/engine/feature-ast/patterns.ts +22 -0
- package/src/engine/feature-ast/render.ts +14 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
- package/src/engine/pattern-library/library.ts +36 -0
- package/src/engine/schema-builder.ts +8 -0
- package/src/engine/types/feature.ts +51 -0
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/index.ts +3 -0
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +34 -6
- package/src/files/zip-stream.ts +251 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// DurationSpec ↔ SQL-Interval ↔ Temporal.Duration converter.
|
|
2
|
+
//
|
|
3
|
+
// Hintergrund: ComplianceProfile.userRights.gracePeriod ist
|
|
4
|
+
// `{ days: number } | { hours: number }` (Discriminated Union). Caller
|
|
5
|
+
// die das in eine Postgres-`interval`-SQL einsetzen brauchen einen
|
|
6
|
+
// einzigen vertrauenswuerdigen Punkt, sonst springt ein
|
|
7
|
+
// `{ hours: 6 }`-Override stillschweigend auf einen days-Default.
|
|
8
|
+
//
|
|
9
|
+
// Single source of truth: hier. Andere Spec-Forms (months/years) im
|
|
10
|
+
// retention-Pfad gehen ueber den breiteren `keep-for`-Parser, sind hier
|
|
11
|
+
// bewusst nicht abgedeckt — das engt den Type ein und macht die SQL-
|
|
12
|
+
// Renderung total.
|
|
13
|
+
|
|
14
|
+
import { getTemporal } from "../time";
|
|
15
|
+
import type { DurationSpec } from "./profiles";
|
|
16
|
+
|
|
17
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
18
|
+
|
|
19
|
+
// Spec in Millisekunden. Source of truth fuer alle Konvertierungen
|
|
20
|
+
// (Instant-add, optional fuer JS-Datumsrechnung).
|
|
21
|
+
export function durationSpecToMs(spec: DurationSpec): number {
|
|
22
|
+
if ("days" in spec) return spec.days * 24 * 60 * 60 * 1000;
|
|
23
|
+
return spec.hours * 60 * 60 * 1000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Frist berechnen ohne DB-now() — der App-Server-Clock ist
|
|
27
|
+
// authoritative. Fuer Forget-Grace, Token-TTLs und Frist-Setzungen
|
|
28
|
+
// where eine Toleranz von wenigen ms zwischen App und DB irrelevant
|
|
29
|
+
// ist (Grace-Periods >= 6h, Tokens >= Minuten).
|
|
30
|
+
//
|
|
31
|
+
// Schreibt direkt in `instant()`-customType-Spalten — kein interval-
|
|
32
|
+
// SQL-Fragment, kein Codec-Bypass.
|
|
33
|
+
export function addDurationSpec(now: Instant, spec: DurationSpec): Instant {
|
|
34
|
+
return getTemporal().Instant.fromEpochMilliseconds(
|
|
35
|
+
now.epochMilliseconds + durationSpecToMs(spec),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Lesbare Beschreibung fuer Logs / Error-Messages. Nicht i18n —
|
|
40
|
+
// English-only-Operator-Output.
|
|
41
|
+
export function describeDurationSpec(spec: DurationSpec): string {
|
|
42
|
+
if ("days" in spec) return `${spec.days} day${spec.days === 1 ? "" : "s"}`;
|
|
43
|
+
return `${spec.hours} hour${spec.hours === 1 ? "" : "s"}`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// `@cosmicdrift/kumiko-framework/compliance` — Datenschutz/Compliance-
|
|
2
|
+
// Foundation. Wird von Sprint-1+ Features genutzt (compliance-profiles,
|
|
3
|
+
// data-retention, user-data-rights, ...).
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
addDurationSpec,
|
|
7
|
+
describeDurationSpec,
|
|
8
|
+
durationSpecToMs,
|
|
9
|
+
} from "./duration-spec";
|
|
10
|
+
export { complianceProfileOverrideSchema } from "./override-schema";
|
|
11
|
+
export type {
|
|
12
|
+
AuthorityNotificationDeadline,
|
|
13
|
+
ComplianceProfile,
|
|
14
|
+
ComplianceProfileKey,
|
|
15
|
+
ComplianceProfileOverride,
|
|
16
|
+
DurationSpec,
|
|
17
|
+
EffectiveComplianceProfile,
|
|
18
|
+
UserNotificationRequiredPolicy,
|
|
19
|
+
} from "./profiles";
|
|
20
|
+
export {
|
|
21
|
+
COMPLIANCE_PROFILES,
|
|
22
|
+
OVERRIDABLE_PROFILE_KEYS,
|
|
23
|
+
resolveComplianceProfile,
|
|
24
|
+
SELECTABLE_PROFILE_KEYS,
|
|
25
|
+
} from "./profiles";
|
|
26
|
+
export type { BundleTier, SubProcessor } from "./sub-processors";
|
|
27
|
+
export {
|
|
28
|
+
getActiveSubProcessors,
|
|
29
|
+
getPlannedSubProcessors,
|
|
30
|
+
KUMIKO_SUB_PROCESSORS,
|
|
31
|
+
} from "./sub-processors";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Zod-Schema fuer ComplianceProfileOverride mit .strict() rekursiv —
|
|
2
|
+
// Sprint 1.9 Z3.
|
|
3
|
+
//
|
|
4
|
+
// Vorher: set-profile.write.ts pruefte nur Top-Level-Keys gegen
|
|
5
|
+
// OVERRIDABLE_PROFILE_KEYS. Sub-Level-Tippfehler (z.B.
|
|
6
|
+
// `{ userRights: { weeks: 3 } }` statt `{ userRights: { gracePeriod: { days: 30 } } }`)
|
|
7
|
+
// kamen durch — der deepMerge spliced das nonsense ins effektive
|
|
8
|
+
// Profile, und ein Caller der `userRights.gracePeriod.days` liest
|
|
9
|
+
// crashed mit `undefined`.
|
|
10
|
+
//
|
|
11
|
+
// Lösung: Zod-Schema das die ComplianceProfile-Struktur in DeepPartial-
|
|
12
|
+
// Form abbildet, mit .strict() auf jedem Object damit unbekannte Keys
|
|
13
|
+
// werfen. Single source of truth wie OVERRIDABLE_PROFILE_KEYS, plus
|
|
14
|
+
// Sub-Level-Coverage.
|
|
15
|
+
//
|
|
16
|
+
// Identifikations-Felder (key, region, label, extends) sind NICHT im
|
|
17
|
+
// Schema — wer die overriden will, würde die Profile-Identitaet
|
|
18
|
+
// zerstören.
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
|
|
22
|
+
// DurationSpec: { days } | { hours } — strict bedeutet beide Forms
|
|
23
|
+
// muessen exakt 1 property haben (kein "{ days: 30, hours: 1 }").
|
|
24
|
+
const durationSpecSchema = z.union([
|
|
25
|
+
z.object({ days: z.number().int().nonnegative() }).strict(),
|
|
26
|
+
z.object({ hours: z.number().int().nonnegative() }).strict(),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// retention.* erlaubt zusaetzlich "months" und "years". Wieder eine
|
|
30
|
+
// strikte Disjunktion.
|
|
31
|
+
const auditRetentionSchema = z.union([
|
|
32
|
+
durationSpecSchema,
|
|
33
|
+
z.object({ months: z.number().int().nonnegative() }).strict(),
|
|
34
|
+
z.object({ years: z.number().int().nonnegative() }).strict(),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const authorityNotificationDeadlineSchema = z.union([
|
|
38
|
+
durationSpecSchema,
|
|
39
|
+
z.literal("as-soon-as-feasible"),
|
|
40
|
+
z.literal("in-most-expedient-time"),
|
|
41
|
+
z.literal("manual"),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const userNotificationRequiredSchema = z.union([
|
|
45
|
+
z.literal("if-high-risk"),
|
|
46
|
+
z.literal("if-real-risk-of-significant-harm"),
|
|
47
|
+
z.literal("if-serious-risk-of-injury"),
|
|
48
|
+
z.literal("always-if-encrypted-data-or-pii"),
|
|
49
|
+
z.literal("always-without-undue-delay"),
|
|
50
|
+
z.literal("manual"),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const userRightsOverrideSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
gracePeriod: durationSpecSchema.optional(),
|
|
56
|
+
restrictionAllowed: z.boolean().optional(),
|
|
57
|
+
objectionAllowed: z.boolean().optional(),
|
|
58
|
+
portabilityFormat: z.array(z.string()).optional(),
|
|
59
|
+
auskunftFrist: durationSpecSchema.optional(),
|
|
60
|
+
employeeAccessRight: z.boolean().optional(),
|
|
61
|
+
explicitConsentForAutomatedDecision: z.boolean().optional(),
|
|
62
|
+
doNotSellRequired: z.boolean().optional(),
|
|
63
|
+
// Async-Export-Pipeline (S2.U3+U4) — TTL Compliance-relevant,
|
|
64
|
+
// Stale/Cleanup Operations-Settings.
|
|
65
|
+
exportDownloadTtl: durationSpecSchema.optional(),
|
|
66
|
+
exportStaleTimeoutMinutes: z.number().int().nonnegative().optional(),
|
|
67
|
+
exportStorageCleanupGraceHours: z.number().int().nonnegative().optional(),
|
|
68
|
+
})
|
|
69
|
+
.strict();
|
|
70
|
+
|
|
71
|
+
const notificationsOverrideSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
languages: z.array(z.string()).optional(),
|
|
74
|
+
languageDefault: z.string().optional(),
|
|
75
|
+
mandatoryBreachNotification: z.boolean().optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict();
|
|
78
|
+
|
|
79
|
+
const breachOverrideSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
authorityNotificationDeadline: authorityNotificationDeadlineSchema.optional(),
|
|
82
|
+
authorityContact: z.string().optional(),
|
|
83
|
+
userNotificationRequired: userNotificationRequiredSchema.optional(),
|
|
84
|
+
worksCouncilNotificationRequired: z.boolean().optional(),
|
|
85
|
+
mandatoryRegisterOfBreaches: z.boolean().optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
|
|
89
|
+
const auditLogOverrideSchema = z
|
|
90
|
+
.object({
|
|
91
|
+
retention: auditRetentionSchema.optional(),
|
|
92
|
+
reportFrequency: z
|
|
93
|
+
.union([
|
|
94
|
+
z.literal("quarterly"),
|
|
95
|
+
z.literal("yearly"),
|
|
96
|
+
z.literal("annual-required"),
|
|
97
|
+
z.literal("manual"),
|
|
98
|
+
])
|
|
99
|
+
.optional(),
|
|
100
|
+
})
|
|
101
|
+
.strict();
|
|
102
|
+
|
|
103
|
+
const subProcessorOverrideSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
consentRequired: z.boolean().optional(),
|
|
106
|
+
changeNotificationLeadDays: z.number().int().nonnegative().optional(),
|
|
107
|
+
mandatoryBaaWithSubProcessors: z.boolean().optional(),
|
|
108
|
+
worksCouncilApprovalRequired: z.boolean().optional(),
|
|
109
|
+
tierFilter: z.array(z.string()).optional(),
|
|
110
|
+
})
|
|
111
|
+
.strict();
|
|
112
|
+
|
|
113
|
+
const forgetDiscoveryOverrideSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
enabled: z.boolean().optional(),
|
|
116
|
+
mode: z.union([z.literal("manual-redact"), z.literal("auto-redact-strict")]).optional(),
|
|
117
|
+
})
|
|
118
|
+
.strict();
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Komplett-Schema fuer ComplianceProfileOverride. Alle Top-Level- UND
|
|
122
|
+
* Sub-Level-Keys sind gewhitelisted via .strict() — Tippfehler werfen
|
|
123
|
+
* sofort. Set-profile-Handler (Sprint 1.9 Z3) validiert das Override
|
|
124
|
+
* gegen dieses Schema vor dem Persist.
|
|
125
|
+
*/
|
|
126
|
+
export const complianceProfileOverrideSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
userRights: userRightsOverrideSchema.optional(),
|
|
129
|
+
notifications: notificationsOverrideSchema.optional(),
|
|
130
|
+
breach: breachOverrideSchema.optional(),
|
|
131
|
+
auditLog: auditLogOverrideSchema.optional(),
|
|
132
|
+
subProcessor: subProcessorOverrideSchema.optional(),
|
|
133
|
+
tenantDestroyGracePeriod: durationSpecSchema.optional(),
|
|
134
|
+
forgetDiscovery: forgetDiscoveryOverrideSchema.optional(),
|
|
135
|
+
})
|
|
136
|
+
.strict();
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// Compliance-Profiles — DSGVO + sektorspezifische Regelwerke.
|
|
2
|
+
//
|
|
3
|
+
// Tenant-Admin waehlt ein Profile beim Onboarding (Pflicht). Profile
|
|
4
|
+
// buendelt User-Rights (Forget-Grace, Restriction, Auskunftsfrist),
|
|
5
|
+
// Notification-Sprache, Breach-Disclosure, Audit-Log-Retention,
|
|
6
|
+
// Sub-Processor-Anforderungen und Tenant-Destroy-Grace.
|
|
7
|
+
//
|
|
8
|
+
// MVP-Set: 3 Profile + 1 Default-Fallback. Erweiterung via `extends`
|
|
9
|
+
// trivial — neue Laender (uk-gdpr, ca-pipeda, us-ccpa, hipaa-healthcare)
|
|
10
|
+
// kommen on-demand wenn Customer fragt.
|
|
11
|
+
//
|
|
12
|
+
// Edge-Case-Decisions (advisor-pinned 2026-05-06):
|
|
13
|
+
//
|
|
14
|
+
// 1. Default-Fallback: `resolveComplianceProfile(undefined)` returnt
|
|
15
|
+
// `minimal-no-region` mit `warning: "no-profile-selected"`. Caller
|
|
16
|
+
// sieht das und kann Onboarding-Banner triggern. Nie still
|
|
17
|
+
// resolven.
|
|
18
|
+
//
|
|
19
|
+
// 2. extends-Chain-Tiefe: nur 1-Level. de-hr-dsgvo-hgb extends
|
|
20
|
+
// eu-dsgvo, aber eu-dsgvo darf NICHT selbst extends haben. Boot-
|
|
21
|
+
// Validator (Sprint 1.3) prueft.
|
|
22
|
+
//
|
|
23
|
+
// 3. Deep-merge-Semantik vom Override: rekursiv. Override
|
|
24
|
+
// `{ userRights: { gracePeriod: { days: 60 } } }` auf eu-dsgvo
|
|
25
|
+
// laesst andere userRights-Felder unveraendert. Top-Level-Replace
|
|
26
|
+
// gibt es NICHT — Override muss strukturell exakt das Pfad-Tree
|
|
27
|
+
// treffen.
|
|
28
|
+
//
|
|
29
|
+
// 4. Required-Field-Override: Override-Type ist `DeepPartial<...>`.
|
|
30
|
+
// TypeScript verhindert null-Drops zur Compile-Time. Runtime-Cast
|
|
31
|
+
// durch Tenant-Admin-Override-Endpoint laeuft durch Zod-Schema
|
|
32
|
+
// mit allen-Properties-optional (kein nullable).
|
|
33
|
+
//
|
|
34
|
+
// Siehe docs/plans/datenschutz/compliance-profiles.md.
|
|
35
|
+
|
|
36
|
+
import type { BundleTier } from "./sub-processors";
|
|
37
|
+
|
|
38
|
+
// --- Profile-Schema ---
|
|
39
|
+
|
|
40
|
+
export type ComplianceProfileKey =
|
|
41
|
+
| "eu-dsgvo"
|
|
42
|
+
| "swiss-dsg"
|
|
43
|
+
| "de-hr-dsgvo-hgb"
|
|
44
|
+
| "minimal-no-region";
|
|
45
|
+
|
|
46
|
+
export type DurationSpec = { readonly days: number } | { readonly hours: number };
|
|
47
|
+
|
|
48
|
+
export type AuthorityNotificationDeadline =
|
|
49
|
+
| DurationSpec
|
|
50
|
+
| "as-soon-as-feasible"
|
|
51
|
+
| "in-most-expedient-time"
|
|
52
|
+
| "manual";
|
|
53
|
+
|
|
54
|
+
export type UserNotificationRequiredPolicy =
|
|
55
|
+
| "if-high-risk"
|
|
56
|
+
| "if-real-risk-of-significant-harm"
|
|
57
|
+
| "if-serious-risk-of-injury"
|
|
58
|
+
| "always-if-encrypted-data-or-pii"
|
|
59
|
+
| "always-without-undue-delay"
|
|
60
|
+
| "manual";
|
|
61
|
+
|
|
62
|
+
export interface ComplianceProfile {
|
|
63
|
+
readonly key: ComplianceProfileKey;
|
|
64
|
+
readonly region: string;
|
|
65
|
+
readonly label: string;
|
|
66
|
+
readonly extends?: ComplianceProfileKey;
|
|
67
|
+
|
|
68
|
+
readonly userRights: {
|
|
69
|
+
readonly gracePeriod: DurationSpec;
|
|
70
|
+
readonly restrictionAllowed: boolean;
|
|
71
|
+
readonly objectionAllowed: boolean;
|
|
72
|
+
readonly portabilityFormat: readonly string[];
|
|
73
|
+
readonly auskunftFrist: DurationSpec;
|
|
74
|
+
readonly employeeAccessRight?: boolean;
|
|
75
|
+
readonly explicitConsentForAutomatedDecision?: boolean;
|
|
76
|
+
readonly doNotSellRequired?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* DSGVO Art. 15 + 20 Async-Export — Pipeline-Konfiguration. Spec:
|
|
79
|
+
* docs/plans/architecture/user-data-rights.md "Async Export-Pipeline".
|
|
80
|
+
*/
|
|
81
|
+
readonly exportDownloadTtl: DurationSpec;
|
|
82
|
+
readonly exportStaleTimeoutMinutes: number;
|
|
83
|
+
readonly exportStorageCleanupGraceHours: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
readonly notifications: {
|
|
87
|
+
readonly languages: readonly string[];
|
|
88
|
+
readonly languageDefault?: string;
|
|
89
|
+
readonly mandatoryBreachNotification: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
readonly breach: {
|
|
93
|
+
readonly authorityNotificationDeadline: AuthorityNotificationDeadline;
|
|
94
|
+
readonly authorityContact: string;
|
|
95
|
+
readonly userNotificationRequired: UserNotificationRequiredPolicy;
|
|
96
|
+
readonly worksCouncilNotificationRequired?: boolean;
|
|
97
|
+
readonly mandatoryRegisterOfBreaches?: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
readonly auditLog: {
|
|
101
|
+
readonly retention: DurationSpec | { readonly months: number } | { readonly years: number };
|
|
102
|
+
readonly reportFrequency: "quarterly" | "yearly" | "annual-required" | "manual";
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
readonly subProcessor: {
|
|
106
|
+
readonly consentRequired: boolean;
|
|
107
|
+
readonly changeNotificationLeadDays: number;
|
|
108
|
+
readonly mandatoryBaaWithSubProcessors?: boolean;
|
|
109
|
+
readonly worksCouncilApprovalRequired?: boolean;
|
|
110
|
+
readonly tierFilter?: readonly BundleTier[];
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
readonly tenantDestroyGracePeriod: DurationSpec;
|
|
114
|
+
|
|
115
|
+
readonly forgetDiscovery?: {
|
|
116
|
+
readonly enabled: boolean;
|
|
117
|
+
readonly mode?: "manual-redact" | "auto-redact-strict";
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Profile-Definitions (raw, before extends-Resolution) ---
|
|
122
|
+
|
|
123
|
+
const RAW_PROFILES: Readonly<Record<ComplianceProfileKey, ComplianceProfileRaw>> = {
|
|
124
|
+
"eu-dsgvo": {
|
|
125
|
+
key: "eu-dsgvo",
|
|
126
|
+
region: "EU",
|
|
127
|
+
label: "EU — DSGVO Standard",
|
|
128
|
+
userRights: {
|
|
129
|
+
gracePeriod: { days: 30 },
|
|
130
|
+
restrictionAllowed: true,
|
|
131
|
+
objectionAllowed: true,
|
|
132
|
+
portabilityFormat: ["json"],
|
|
133
|
+
auskunftFrist: { days: 30 },
|
|
134
|
+
exportDownloadTtl: { days: 7 },
|
|
135
|
+
exportStaleTimeoutMinutes: 30,
|
|
136
|
+
exportStorageCleanupGraceHours: 24,
|
|
137
|
+
},
|
|
138
|
+
notifications: {
|
|
139
|
+
languages: ["de", "en"],
|
|
140
|
+
mandatoryBreachNotification: true,
|
|
141
|
+
},
|
|
142
|
+
breach: {
|
|
143
|
+
authorityNotificationDeadline: { hours: 72 },
|
|
144
|
+
authorityContact: "BlnBDI Berlin",
|
|
145
|
+
userNotificationRequired: "if-high-risk",
|
|
146
|
+
},
|
|
147
|
+
auditLog: {
|
|
148
|
+
retention: { months: 24 },
|
|
149
|
+
reportFrequency: "quarterly",
|
|
150
|
+
},
|
|
151
|
+
subProcessor: {
|
|
152
|
+
consentRequired: false,
|
|
153
|
+
changeNotificationLeadDays: 30,
|
|
154
|
+
},
|
|
155
|
+
tenantDestroyGracePeriod: { days: 30 },
|
|
156
|
+
forgetDiscovery: { enabled: false },
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
"swiss-dsg": {
|
|
160
|
+
key: "swiss-dsg",
|
|
161
|
+
region: "CH",
|
|
162
|
+
label: "Schweiz — Bundesgesetz über den Datenschutz (rev. 2023)",
|
|
163
|
+
extends: "eu-dsgvo",
|
|
164
|
+
notifications: {
|
|
165
|
+
languages: ["de", "fr", "it", "en"],
|
|
166
|
+
mandatoryBreachNotification: true,
|
|
167
|
+
},
|
|
168
|
+
breach: {
|
|
169
|
+
authorityNotificationDeadline: { hours: 72 },
|
|
170
|
+
authorityContact: "EDÖB Bern",
|
|
171
|
+
userNotificationRequired: "if-high-risk",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
"de-hr-dsgvo-hgb": {
|
|
176
|
+
key: "de-hr-dsgvo-hgb",
|
|
177
|
+
region: "DE",
|
|
178
|
+
label: "Deutschland HR — DSGVO + HGB + Personalakten",
|
|
179
|
+
extends: "eu-dsgvo",
|
|
180
|
+
userRights: {
|
|
181
|
+
gracePeriod: { days: 30 },
|
|
182
|
+
restrictionAllowed: true,
|
|
183
|
+
objectionAllowed: true,
|
|
184
|
+
portabilityFormat: ["json"],
|
|
185
|
+
auskunftFrist: { days: 30 },
|
|
186
|
+
employeeAccessRight: true,
|
|
187
|
+
exportDownloadTtl: { days: 7 },
|
|
188
|
+
exportStaleTimeoutMinutes: 30,
|
|
189
|
+
exportStorageCleanupGraceHours: 24,
|
|
190
|
+
},
|
|
191
|
+
notifications: {
|
|
192
|
+
languages: ["de"],
|
|
193
|
+
mandatoryBreachNotification: true,
|
|
194
|
+
},
|
|
195
|
+
breach: {
|
|
196
|
+
authorityNotificationDeadline: { hours: 72 },
|
|
197
|
+
authorityContact: "Landes-Datenschutzbehörde",
|
|
198
|
+
userNotificationRequired: "if-high-risk",
|
|
199
|
+
worksCouncilNotificationRequired: true,
|
|
200
|
+
},
|
|
201
|
+
auditLog: {
|
|
202
|
+
retention: { years: 10 },
|
|
203
|
+
reportFrequency: "yearly",
|
|
204
|
+
},
|
|
205
|
+
subProcessor: {
|
|
206
|
+
consentRequired: false,
|
|
207
|
+
changeNotificationLeadDays: 30,
|
|
208
|
+
worksCouncilApprovalRequired: true,
|
|
209
|
+
},
|
|
210
|
+
tenantDestroyGracePeriod: { days: 60 },
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
"minimal-no-region": {
|
|
214
|
+
key: "minimal-no-region",
|
|
215
|
+
region: "—",
|
|
216
|
+
label: "Minimal — kein Compliance-Profile (NICHT für Production)",
|
|
217
|
+
userRights: {
|
|
218
|
+
gracePeriod: { days: 30 },
|
|
219
|
+
restrictionAllowed: false,
|
|
220
|
+
objectionAllowed: false,
|
|
221
|
+
portabilityFormat: ["json"],
|
|
222
|
+
auskunftFrist: { days: 30 },
|
|
223
|
+
exportDownloadTtl: { days: 7 },
|
|
224
|
+
exportStaleTimeoutMinutes: 30,
|
|
225
|
+
exportStorageCleanupGraceHours: 24,
|
|
226
|
+
},
|
|
227
|
+
notifications: {
|
|
228
|
+
languages: ["en"],
|
|
229
|
+
mandatoryBreachNotification: false,
|
|
230
|
+
},
|
|
231
|
+
breach: {
|
|
232
|
+
authorityNotificationDeadline: "manual",
|
|
233
|
+
authorityContact: "",
|
|
234
|
+
userNotificationRequired: "manual",
|
|
235
|
+
},
|
|
236
|
+
auditLog: {
|
|
237
|
+
retention: { months: 3 },
|
|
238
|
+
reportFrequency: "manual",
|
|
239
|
+
},
|
|
240
|
+
subProcessor: {
|
|
241
|
+
consentRequired: false,
|
|
242
|
+
changeNotificationLeadDays: 30,
|
|
243
|
+
},
|
|
244
|
+
tenantDestroyGracePeriod: { days: 30 },
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Raw-Profile (vor extends-Resolution) — `extends`-Profile dürfen
|
|
249
|
+
// Required-Felder weglassen, sie kommen vom Base-Profile dazu.
|
|
250
|
+
type ComplianceProfileRaw = Partial<Omit<ComplianceProfile, "key" | "region" | "label">> & {
|
|
251
|
+
readonly key: ComplianceProfileKey;
|
|
252
|
+
readonly region: string;
|
|
253
|
+
readonly label: string;
|
|
254
|
+
readonly extends?: ComplianceProfileKey;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// --- Tenant-Auswählbare Liste (ohne minimal-no-region) ---
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Profile-Schluessel die der Tenant-Admin im Onboarding waehlen darf.
|
|
261
|
+
* `minimal-no-region` ist bewusst NICHT in der Liste — es ist der
|
|
262
|
+
* Default-Fallback fuer "noch keine Wahl getroffen", mit sichtbarer
|
|
263
|
+
* Warning. Production-Tenants sollen ein echtes Profile waehlen.
|
|
264
|
+
*/
|
|
265
|
+
export const SELECTABLE_PROFILE_KEYS: readonly ComplianceProfileKey[] = [
|
|
266
|
+
"eu-dsgvo",
|
|
267
|
+
"swiss-dsg",
|
|
268
|
+
"de-hr-dsgvo-hgb",
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Top-Level-Properties des `ComplianceProfile`-Type, die ein Tenant-
|
|
273
|
+
* Override modifizieren darf. Identifikations-Felder (key, region,
|
|
274
|
+
* label, extends) sind ausgeschlossen — wer die overriden wuerde,
|
|
275
|
+
* würde die Profile-Identität zerstoeren.
|
|
276
|
+
*
|
|
277
|
+
* Single source of truth fuer:
|
|
278
|
+
* - set-profile-Handler-Whitelist (Tippfehler-Reject)
|
|
279
|
+
* - Snapshot-Test gegen Profile-Top-Level (Drift-Guard)
|
|
280
|
+
*
|
|
281
|
+
* Wenn ein neues Top-Level-Property zu `ComplianceProfile` kommt
|
|
282
|
+
* (z.B. `dataSubjectRights` oder `crossBorderTransferRules`), MUSS es
|
|
283
|
+
* hier ergaenzt werden — sonst lehnt set-profile valide Overrides ab.
|
|
284
|
+
* Der Snapshot-Test in profiles.test.ts faengt Drift in beide
|
|
285
|
+
* Richtungen.
|
|
286
|
+
*/
|
|
287
|
+
export const OVERRIDABLE_PROFILE_KEYS: ReadonlySet<string> = new Set([
|
|
288
|
+
"userRights",
|
|
289
|
+
"notifications",
|
|
290
|
+
"breach",
|
|
291
|
+
"auditLog",
|
|
292
|
+
"subProcessor",
|
|
293
|
+
"tenantDestroyGracePeriod",
|
|
294
|
+
"forgetDiscovery",
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
// --- Extends-Resolver (deep-merge) ---
|
|
298
|
+
|
|
299
|
+
type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
|
|
300
|
+
|
|
301
|
+
export type ComplianceProfileOverride = DeepReadonly<DeepPartial<ComplianceProfile>>;
|
|
302
|
+
|
|
303
|
+
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
|
|
304
|
+
|
|
305
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
306
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Pfade die als ganzes Atom ersetzt werden (NICHT rekursiv gemergt).
|
|
310
|
+
// Notwendig fuer Diskriminierte-Union-Types wo das Patch ein Schwester-
|
|
311
|
+
// Property statt einer Override sein kann — z.B. retention von
|
|
312
|
+
// { months: 24 } auf { years: 10 } wuerde sonst zu { months: 24, years: 10 }
|
|
313
|
+
// werden, semantisch nonsense.
|
|
314
|
+
const ATOMIC_PATHS: ReadonlySet<string> = new Set([
|
|
315
|
+
"userRights.gracePeriod",
|
|
316
|
+
"userRights.auskunftFrist",
|
|
317
|
+
"userRights.exportDownloadTtl",
|
|
318
|
+
"tenantDestroyGracePeriod",
|
|
319
|
+
"breach.authorityNotificationDeadline",
|
|
320
|
+
"auditLog.retention",
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
function deepMerge<T extends Record<string, unknown>>(
|
|
324
|
+
base: T,
|
|
325
|
+
patch: Record<string, unknown>,
|
|
326
|
+
path = "",
|
|
327
|
+
): T {
|
|
328
|
+
const out: Record<string, unknown> = { ...base };
|
|
329
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
330
|
+
if (v === undefined) continue;
|
|
331
|
+
const fullPath = path ? `${path}.${k}` : k;
|
|
332
|
+
const existing = out[k];
|
|
333
|
+
if (ATOMIC_PATHS.has(fullPath)) {
|
|
334
|
+
// Atomic — replace komplett statt rekursiv mergen.
|
|
335
|
+
out[k] = v;
|
|
336
|
+
} else if (isPlainObject(existing) && isPlainObject(v)) {
|
|
337
|
+
out[k] = deepMerge(existing, v, fullPath);
|
|
338
|
+
} else {
|
|
339
|
+
out[k] = v;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return out as T;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Resolved ein Profile inklusive `extends`-Auflösung. Wirft wenn die
|
|
347
|
+
* extends-Chain tiefer als 1 Level ist (vermeidet Cycles + macht
|
|
348
|
+
* Boot-Validation einfach).
|
|
349
|
+
*
|
|
350
|
+
* Edge-Case (gepinnt): das `extends`-Target darf selbst KEIN extends
|
|
351
|
+
* haben. Wer doch eine Mehrstufen-Hierarchie braucht, definiert die
|
|
352
|
+
* Stufen explizit (z.B. `de-hr-strict extends eu-dsgvo`, statt
|
|
353
|
+
* `de-hr-strict extends de-hr-dsgvo-hgb extends eu-dsgvo`).
|
|
354
|
+
*/
|
|
355
|
+
function resolveExtends(key: ComplianceProfileKey): ComplianceProfile {
|
|
356
|
+
const raw = RAW_PROFILES[key];
|
|
357
|
+
if (!raw.extends) {
|
|
358
|
+
return raw as ComplianceProfile;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const base = RAW_PROFILES[raw.extends];
|
|
362
|
+
if (base.extends) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`Compliance-Profile "${key}" extends "${raw.extends}" which itself extends "${base.extends}" — chain depth >1 not supported. Define a flat extends-hierarchy instead.`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return deepMerge(
|
|
369
|
+
base as Record<string, unknown>,
|
|
370
|
+
raw as unknown as Record<string, unknown>,
|
|
371
|
+
) as unknown as ComplianceProfile;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Pre-baked Profile-Liste (extends bereits aufgelöst). Beim Modul-Load
|
|
376
|
+
* einmal berechnet — wirft bei Definition-Fehlern (Cycle, missing target)
|
|
377
|
+
* sofort, nicht erst beim ersten Resolver-Call.
|
|
378
|
+
*/
|
|
379
|
+
export const COMPLIANCE_PROFILES: Readonly<Record<ComplianceProfileKey, ComplianceProfile>> =
|
|
380
|
+
Object.fromEntries(
|
|
381
|
+
(Object.keys(RAW_PROFILES) as ComplianceProfileKey[]).map((k) => [k, resolveExtends(k)]),
|
|
382
|
+
) as Readonly<Record<ComplianceProfileKey, ComplianceProfile>>;
|
|
383
|
+
|
|
384
|
+
// --- Effective-Profile-Resolver ---
|
|
385
|
+
|
|
386
|
+
export interface EffectiveComplianceProfile {
|
|
387
|
+
readonly profile: ComplianceProfile;
|
|
388
|
+
readonly warning?: "no-profile-selected";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Liefert das effektive Compliance-Profile fuer einen Tenant inklusive
|
|
393
|
+
* Tenant-Override.
|
|
394
|
+
*
|
|
395
|
+
* Edge-Case-Verhalten (gepinnt):
|
|
396
|
+
* - selection=undefined → minimal-no-region + warning="no-profile-selected"
|
|
397
|
+
* - selection=valid + override=undefined → effective profile, kein warning
|
|
398
|
+
* - selection=valid + override → deep-merged effective, kein warning
|
|
399
|
+
*
|
|
400
|
+
* Production-Marker: das frueher hier vorgesehene "minimal-in-production"-
|
|
401
|
+
* warning ist entfallen weil set-profile (Sprint 1.7 X1) minimal-no-region
|
|
402
|
+
* nicht mehr als Tenant-Wahl akzeptiert. Wer Production-spezifisches
|
|
403
|
+
* Verhalten braucht (z.B. Block-bei-Default), addiert den Marker spaeter
|
|
404
|
+
* bei Bedarf.
|
|
405
|
+
*/
|
|
406
|
+
export function resolveComplianceProfile(args: {
|
|
407
|
+
readonly selection?: ComplianceProfileKey;
|
|
408
|
+
readonly override?: ComplianceProfileOverride;
|
|
409
|
+
}): EffectiveComplianceProfile {
|
|
410
|
+
if (!args.selection) {
|
|
411
|
+
return {
|
|
412
|
+
profile: COMPLIANCE_PROFILES["minimal-no-region"],
|
|
413
|
+
warning: "no-profile-selected",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const base = COMPLIANCE_PROFILES[args.selection];
|
|
418
|
+
if (!args.override) {
|
|
419
|
+
return { profile: base };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const merged = deepMerge(
|
|
423
|
+
base as unknown as Record<string, unknown>,
|
|
424
|
+
args.override as Record<string, unknown>,
|
|
425
|
+
) as unknown as ComplianceProfile;
|
|
426
|
+
return { profile: merged };
|
|
427
|
+
}
|