@cosmicdrift/kumiko-bundled-features 0.2.2 → 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 +31 -0
- package/package.json +11 -5
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +33 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +334 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { parseRetentionOverrideOrNull } from "../_internal/parse-override";
|
|
6
|
+
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "../resolver";
|
|
7
|
+
import { tenantRetentionOverrideTable } from "../schema/tenant-retention-override";
|
|
8
|
+
|
|
9
|
+
// retention:query:policy-for — Cross-Feature-API fuer den Forget-Flow.
|
|
10
|
+
//
|
|
11
|
+
// user-data-rights-Sprint-2.U5 ruft das pro Entity um zu wissen ob ein
|
|
12
|
+
// Forget mit "delete" oder "anonymize" oder "blockDelete-bis-Frist"
|
|
13
|
+
// laufen soll. Plus Cleanup-Job-Sprint-2.D2b fuer das gleiche.
|
|
14
|
+
//
|
|
15
|
+
// Tenant-Preset-Storage existiert noch nicht (kommt mit S2.D2b ueber
|
|
16
|
+
// einen tenant-config-key). Bis dahin tenantPreset=null — der Resolver
|
|
17
|
+
// liefert dann nur die Entity-Default-Layer + Override.
|
|
18
|
+
//
|
|
19
|
+
// access: openToAll — andere Features im selben Tenant duerfen das
|
|
20
|
+
// abrufen. Keine PII im Result, nur Policy-Metadata.
|
|
21
|
+
export const policyForQuery = defineQueryHandler({
|
|
22
|
+
name: "policy-for",
|
|
23
|
+
schema: z.object({
|
|
24
|
+
entityName: z.string().min(1).max(100),
|
|
25
|
+
}),
|
|
26
|
+
access: { openToAll: true },
|
|
27
|
+
handler: async (query, ctx): Promise<EffectiveRetentionPolicy> => {
|
|
28
|
+
const entityName = query.payload.entityName;
|
|
29
|
+
|
|
30
|
+
// Layer 3: Tenant-Override aus DB laden (UNIQUE(tenantId, entityName))
|
|
31
|
+
const overrideRow = (await fetchOne(
|
|
32
|
+
ctx.db,
|
|
33
|
+
tenantRetentionOverrideTable,
|
|
34
|
+
eq(tenantRetentionOverrideTable["tenantId"], query.user.tenantId),
|
|
35
|
+
eq(tenantRetentionOverrideTable["entityName"], entityName),
|
|
36
|
+
)) as { config: string | null } | null;
|
|
37
|
+
|
|
38
|
+
const tenantOverride = parseRetentionOverrideOrNull(
|
|
39
|
+
overrideRow?.config ?? null,
|
|
40
|
+
query.user.tenantId,
|
|
41
|
+
"data-retention:policy-for",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Layer 1: Entity-Default aus Registry
|
|
45
|
+
const entityDef = ctx.registry?.getEntity(entityName) ?? null;
|
|
46
|
+
|
|
47
|
+
// Layer 2: Tenant-Preset — Storage kommt mit S2.D2b. Bis dahin null.
|
|
48
|
+
const tenantPreset = null;
|
|
49
|
+
|
|
50
|
+
return resolveRetentionPolicy({
|
|
51
|
+
entityName,
|
|
52
|
+
entityDef,
|
|
53
|
+
tenantPreset,
|
|
54
|
+
tenantOverride,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
EffectiveRetentionPolicy,
|
|
3
|
+
ResolveForTenantArgs,
|
|
4
|
+
ResolveRetentionPolicyArgs,
|
|
5
|
+
RetentionOverride,
|
|
6
|
+
RetentionPreset,
|
|
7
|
+
RetentionPresetKey,
|
|
8
|
+
} from "./feature";
|
|
9
|
+
export {
|
|
10
|
+
createDataRetentionFeature,
|
|
11
|
+
RETENTION_PRESETS,
|
|
12
|
+
resolveRetentionPolicy,
|
|
13
|
+
resolveRetentionPolicyForTenant,
|
|
14
|
+
retentionOverrideSchema,
|
|
15
|
+
SELECTABLE_RETENTION_PRESETS,
|
|
16
|
+
tenantRetentionOverrideEntity,
|
|
17
|
+
tenantRetentionOverrideTable,
|
|
18
|
+
} from "./feature";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// keepFor-Format-Parser: "30d" / "10y" / "6m" / "1w" / "24h" → Cutoff-Instant.
|
|
2
|
+
//
|
|
3
|
+
// Nicht millisekunden-praezise — DSGVO-Aufbewahrungspflichten haben
|
|
4
|
+
// Tag-/Monat-Granularitaet. "6m" = 6×30d, "10y" = 10×365d. Cleanup-Job
|
|
5
|
+
// laeuft taeglich, ein paar Tage Differenz beim Cutoff sind akzeptabel.
|
|
6
|
+
//
|
|
7
|
+
// Boot-Validator (S0.2) hat das Format schon gegen /^\d+[hdwmy]$/
|
|
8
|
+
// gecheckt — hier defensiv nochmal validiert fuer Migration-Edge-Cases.
|
|
9
|
+
//
|
|
10
|
+
// Temporal kommt via globalThis (Polyfill in framework-Boot installiert).
|
|
11
|
+
// `getTemporal()` aus framework/time gibt typed Zugriff.
|
|
12
|
+
|
|
13
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
14
|
+
|
|
15
|
+
const KEEP_FOR_PATTERN = /^(\d+)([hdwmy])$/;
|
|
16
|
+
|
|
17
|
+
const UNIT_TO_DAYS: Readonly<Record<string, number>> = {
|
|
18
|
+
d: 1,
|
|
19
|
+
w: 7,
|
|
20
|
+
m: 30,
|
|
21
|
+
y: 365,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class InvalidKeepForError extends Error {
|
|
25
|
+
constructor(spec: string) {
|
|
26
|
+
super(
|
|
27
|
+
`Invalid keepFor format "${spec}" — expected /^\\d+[hdwmy]$/ (e.g. "30d", "10y", "6m", "1w", "24h")`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Re-export von Temporal.Instant als Type-Alias damit Caller den Type
|
|
33
|
+
// nicht selbst aus globalThis ziehen muessen.
|
|
34
|
+
export type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Berechnet den Cutoff-Instant — Rows mit reference < cutoff sind
|
|
38
|
+
* abgelaufen und werden vom Cleanup-Job geraeumt.
|
|
39
|
+
*
|
|
40
|
+
* @param spec keepFor-String wie "30d", "10y", "6m", "1w", "24h"
|
|
41
|
+
* @param now Aktueller Zeitpunkt (advisor-Pattern: injection-Parameter
|
|
42
|
+
* fuer Time-Travel-Tests, kein global Temporal.Now)
|
|
43
|
+
*/
|
|
44
|
+
export function computeCutoff(spec: string, now: Instant): Instant {
|
|
45
|
+
const match = KEEP_FOR_PATTERN.exec(spec);
|
|
46
|
+
if (!match) {
|
|
47
|
+
throw new InvalidKeepForError(spec);
|
|
48
|
+
}
|
|
49
|
+
const amount = Number.parseInt(match[1] ?? "0", 10);
|
|
50
|
+
const unit = match[2] ?? "";
|
|
51
|
+
|
|
52
|
+
if (unit === "h") {
|
|
53
|
+
return now.subtract({ hours: amount });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const daysFactor = UNIT_TO_DAYS[unit];
|
|
57
|
+
if (daysFactor === undefined) {
|
|
58
|
+
throw new InvalidKeepForError(spec);
|
|
59
|
+
}
|
|
60
|
+
return now.subtract({ hours: amount * daysFactor * 24 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ist referenceTimestamp aelter als der keepFor-Cutoff bei now?
|
|
65
|
+
* Cleanup-Job nutzt das pro Row.
|
|
66
|
+
*/
|
|
67
|
+
export function isPastCutoff(args: {
|
|
68
|
+
readonly referenceTimestamp: Instant;
|
|
69
|
+
readonly keepFor: string;
|
|
70
|
+
readonly now: Instant;
|
|
71
|
+
}): boolean {
|
|
72
|
+
const T = getTemporal();
|
|
73
|
+
const cutoff = computeCutoff(args.keepFor, args.now);
|
|
74
|
+
return T.Instant.compare(args.referenceTimestamp, cutoff) < 0;
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Zod-Schema fuer RetentionOverride mit .strict() — Sprint 2.D2.5 (M2+M3).
|
|
2
|
+
//
|
|
3
|
+
// Symmetrisch zu compliance-profiles override-schema.ts (S1.9 Z3).
|
|
4
|
+
// Sub-Sprint-Pattern (advisor-pinned): bei jedem User-konfigurierbarem
|
|
5
|
+
// JSON-Override → strict-Zod + enum-validated.
|
|
6
|
+
//
|
|
7
|
+
// Schuetzt vor:
|
|
8
|
+
// - Top-Level-Tippfehler ("keepfor" statt "keepFor") — strict()-Reject
|
|
9
|
+
// - Strategy-Enum-Drift ("delete" statt "hardDelete") — z.enum-Reject
|
|
10
|
+
// - keepFor-Format-Drift ("30days" statt "30d") — regex-Reject
|
|
11
|
+
//
|
|
12
|
+
// Tenant-Override darf alle drei Properties weglassen (Resolver
|
|
13
|
+
// fallback auf Preset/Entity-Default), aber WAS gesetzt ist muss
|
|
14
|
+
// gueltig sein.
|
|
15
|
+
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
19
|
+
|
|
20
|
+
const retentionStrategySchema = z.enum(["hardDelete", "softDelete", "anonymize", "blockDelete"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* RetentionOverride-Zod-Schema mit .strict() — fuer (a) set-override-
|
|
24
|
+
* Handler-Validation (S2.D3) und (b) DB-Loader im Cleanup-Job (S2.D2b)
|
|
25
|
+
* der invalides JSON aus der config-Spalte loggt + skipt statt mit
|
|
26
|
+
* undefined behavior weiterzumachen.
|
|
27
|
+
*/
|
|
28
|
+
export const retentionOverrideSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
keepFor: z
|
|
31
|
+
.string()
|
|
32
|
+
.regex(KEEP_FOR_PATTERN, "keepFor must match /^\\d+[hdwmy]$/ (e.g. '30d', '10y', '6m')")
|
|
33
|
+
.optional(),
|
|
34
|
+
strategy: retentionStrategySchema.optional(),
|
|
35
|
+
reference: z.string().min(1).optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Retention-Presets — vorgefertigte Bündel von per-Entity-Aufbewahrungs-
|
|
2
|
+
// Konfigurationen pro Compliance-Regime. Tenant-Admin wählt EINES (analog
|
|
3
|
+
// zu compliance-profiles); Entity-Default + Tenant-Override liegen als
|
|
4
|
+
// Schichten 1+3 darüber (siehe resolver.ts).
|
|
5
|
+
//
|
|
6
|
+
// Plan-Roadmap docs/plans/datenschutz/core-data-retention.md hat 8 Presets
|
|
7
|
+
// vorgeschlagen. Sprint-2-MVP: 3 Production-Presets + 1 dev-Default,
|
|
8
|
+
// rest folgt on-demand wenn Customer fragt.
|
|
9
|
+
//
|
|
10
|
+
// Presets sind pure Daten — keine Logik. Erweitern = Constant erweitern,
|
|
11
|
+
// kein Code-Eingriff.
|
|
12
|
+
|
|
13
|
+
import type { RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pro Entity-Name gemappte Retention-Policy. Entity-Namen sind der
|
|
17
|
+
* String aus r.entity("name", ...) — kebab-case oder lowercase.
|
|
18
|
+
* Cleanup-Job iteriert alle bekannten Entities; was nicht im Preset
|
|
19
|
+
* steht, fällt auf Entity-Default (Layer 1) zurück.
|
|
20
|
+
*/
|
|
21
|
+
export type RetentionPreset = Readonly<Record<string, RetentionDef>>;
|
|
22
|
+
|
|
23
|
+
export type RetentionPresetKey = "default" | "dsgvo-basic" | "dsgvo-hgb" | "swiss-dsg";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* MVP-Set für Sprint 2. Erweiterungen (hipaa, ccpa, aggressive-gdpr,
|
|
27
|
+
* pipeda-default, ca-quebec-l25) kommen on-demand.
|
|
28
|
+
*/
|
|
29
|
+
export const RETENTION_PRESETS: Readonly<Record<RetentionPresetKey, RetentionPreset>> = {
|
|
30
|
+
// Default — keine Auto-Aktion. Nur sinnvoll für Dev/Staging.
|
|
31
|
+
// Production-Tenant der "default" stehen lässt → Cleanup-Job
|
|
32
|
+
// schreibt Audit-Eintrag "skipped: no preset selected".
|
|
33
|
+
default: {},
|
|
34
|
+
|
|
35
|
+
// DSGVO Basic — Datenminimierung ohne Buchhaltungspflichten.
|
|
36
|
+
"dsgvo-basic": {
|
|
37
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
38
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
39
|
+
httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// DSGVO + HGB — deutsche Aufbewahrungspflichten überlagert. Order
|
|
43
|
+
// wird anonymisiert (PII raus, Geschäftsdaten bleiben), Invoice +
|
|
44
|
+
// Booking sind blockDelete bis 10 Jahre, dann Anonymize.
|
|
45
|
+
"dsgvo-hgb": {
|
|
46
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
47
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
48
|
+
httpLog: { keepFor: "90d", strategy: "hardDelete", reference: "createdAt" },
|
|
49
|
+
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
50
|
+
booking: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
51
|
+
contract: { keepFor: "6y", strategy: "blockDelete", reference: "createdAt" },
|
|
52
|
+
order: { keepFor: "6y", strategy: "anonymize", reference: "completedAt" },
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Schweizer DSG — ähnlich DSGVO mit OR Art. 958f Aufbewahrung.
|
|
56
|
+
"swiss-dsg": {
|
|
57
|
+
auditLog: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
58
|
+
session: { keepFor: "30d", strategy: "hardDelete", reference: "lastSeenAt" },
|
|
59
|
+
invoice: { keepFor: "10y", strategy: "blockDelete", reference: "createdAt" },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Auswählbare Presets für den Onboarding-Banner. "default" ist Migration-
|
|
65
|
+
* Edge-Case und wird nicht angezeigt — Production-Tenants wählen ein
|
|
66
|
+
* echtes Preset.
|
|
67
|
+
*/
|
|
68
|
+
export const SELECTABLE_RETENTION_PRESETS: readonly RetentionPresetKey[] = [
|
|
69
|
+
"dsgvo-basic",
|
|
70
|
+
"dsgvo-hgb",
|
|
71
|
+
"swiss-dsg",
|
|
72
|
+
];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Direkter Resolver-Helper fuer Bulk-Iteration (S2.U5b Cleanup-Runner).
|
|
2
|
+
//
|
|
3
|
+
// `policy-for` Query (handlers/policy-for.query.ts) ist die Cross-Feature-
|
|
4
|
+
// API fuer einzelne Lookups. Der Cleanup-Runner iteriert N Entities × M
|
|
5
|
+
// Tenants — Handler-Roundtrip pro Lookup waere zu teuer + braucht einen
|
|
6
|
+
// HandlerContext. Beide Pfade nutzen denselben `parseRetentionOverrideOrNull`
|
|
7
|
+
// + `resolveRetentionPolicy`, also kein Drift-Risiko.
|
|
8
|
+
|
|
9
|
+
import { type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
|
|
13
|
+
import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
|
|
14
|
+
import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
|
|
15
|
+
|
|
16
|
+
export interface ResolveForTenantArgs {
|
|
17
|
+
readonly db: DbRunner;
|
|
18
|
+
readonly registry: Registry;
|
|
19
|
+
readonly tenantId: TenantId;
|
|
20
|
+
readonly entityName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function resolveRetentionPolicyForTenant(
|
|
24
|
+
args: ResolveForTenantArgs,
|
|
25
|
+
): Promise<EffectiveRetentionPolicy> {
|
|
26
|
+
const overrideRow = (await fetchOne(
|
|
27
|
+
args.db,
|
|
28
|
+
tenantRetentionOverrideTable,
|
|
29
|
+
eq(tenantRetentionOverrideTable["tenantId"], args.tenantId),
|
|
30
|
+
eq(tenantRetentionOverrideTable["entityName"], args.entityName),
|
|
31
|
+
)) as { config: string | null } | null;
|
|
32
|
+
|
|
33
|
+
const tenantOverride = parseRetentionOverrideOrNull(
|
|
34
|
+
overrideRow?.config ?? null,
|
|
35
|
+
args.tenantId,
|
|
36
|
+
"data-retention:resolve-for-tenant",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const entityDef = args.registry.getEntity(args.entityName) ?? null;
|
|
40
|
+
|
|
41
|
+
// Layer 2 (Tenant-Preset) kommt mit S2.D2b. Bis dahin null.
|
|
42
|
+
const tenantPreset = null;
|
|
43
|
+
|
|
44
|
+
return resolveRetentionPolicy({
|
|
45
|
+
entityName: args.entityName,
|
|
46
|
+
entityDef,
|
|
47
|
+
tenantPreset,
|
|
48
|
+
tenantOverride,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// 3-Schicht-Retention-Resolver.
|
|
2
|
+
//
|
|
3
|
+
// Layer 1: Entity-Default — Feature-Author setzt r.entity({retention})
|
|
4
|
+
// Layer 2: Tenant-Preset — Tenant-Admin wählt Bundle (RETENTION_PRESETS)
|
|
5
|
+
// Layer 3: Tenant-Override — per (tenantId, entityName) JSON-Override
|
|
6
|
+
//
|
|
7
|
+
// Resolver-Reihenfolge:
|
|
8
|
+
// effective = override(tenantId, entityName)
|
|
9
|
+
// ?? preset[tenant.retentionPreset][entityName]
|
|
10
|
+
// ?? entity.retention (Code-Default aus EntityDefinition)
|
|
11
|
+
// ?? null (keine Auto-Aktion)
|
|
12
|
+
//
|
|
13
|
+
// Cleanup-Job in S2.D2 ruft das pro (tenantId, entityName) und
|
|
14
|
+
// entscheidet was zu tun ist (hardDelete / softDelete / anonymize /
|
|
15
|
+
// blockDelete-mit-Frist-Check).
|
|
16
|
+
//
|
|
17
|
+
// Cross-Feature-API r.exposesApi("retention.policyFor") (S2.D3)
|
|
18
|
+
// macht das aus user-data-rights heraus konsumierbar — Forget-Flow
|
|
19
|
+
// fragt blockDelete-Felder ab + anonymisiert sie statt zu löschen.
|
|
20
|
+
|
|
21
|
+
import type { EntityDefinition, RetentionDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { RETENTION_PRESETS, type RetentionPresetKey } from "./presets";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Roh-Override aus der DB-Tabelle (config-Spalte als JSON-String).
|
|
26
|
+
* Nicht das gleiche wie RetentionDef weil hier alles optional ist —
|
|
27
|
+
* Override darf einzelne Properties überschreiben.
|
|
28
|
+
*/
|
|
29
|
+
export interface RetentionOverride {
|
|
30
|
+
readonly keepFor?: string;
|
|
31
|
+
readonly strategy?: RetentionDef["strategy"];
|
|
32
|
+
readonly reference?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Effektive Policy nach Resolver-Lauf. Source dokumentiert WELCHE
|
|
37
|
+
* Schicht den Wert geliefert hat — Audit-Trail für DPO.
|
|
38
|
+
*
|
|
39
|
+
* `override-incomplete` bedeutet: Tenant-Override ist gesetzt, aber
|
|
40
|
+
* füllt keepFor weder selbst noch via Preset-/Entity-Fallback.
|
|
41
|
+
* Cleanup-Job logt eine Warning + skippt — anstatt mit Default-"0d"
|
|
42
|
+
* sofort alles zu löschen.
|
|
43
|
+
*/
|
|
44
|
+
export interface EffectiveRetentionPolicy {
|
|
45
|
+
readonly entityName: string;
|
|
46
|
+
readonly policy: RetentionDef | null;
|
|
47
|
+
readonly source: "override" | "preset" | "entity-default" | "none" | "override-incomplete";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResolveRetentionPolicyArgs {
|
|
51
|
+
readonly entityName: string;
|
|
52
|
+
readonly entityDef: EntityDefinition | null;
|
|
53
|
+
readonly tenantPreset: RetentionPresetKey | null;
|
|
54
|
+
readonly tenantOverride: RetentionOverride | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Auswertung der drei Schichten. Pure Function — kein DB-Access, alle
|
|
59
|
+
* Inputs werden vom Caller besorgt (Cleanup-Job aggregiert pro Tenant
|
|
60
|
+
* vorher).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveRetentionPolicy(args: ResolveRetentionPolicyArgs): EffectiveRetentionPolicy {
|
|
63
|
+
const { entityName, entityDef, tenantPreset, tenantOverride } = args;
|
|
64
|
+
|
|
65
|
+
// Layer 3: Override wins, aber Override darf Felder weglassen — dann
|
|
66
|
+
// fallen die einzelnen Properties auf Layer 2/1 zurück.
|
|
67
|
+
if (tenantOverride !== null) {
|
|
68
|
+
const baseFromPreset =
|
|
69
|
+
tenantPreset !== null ? (RETENTION_PRESETS[tenantPreset]?.[entityName] ?? null) : null;
|
|
70
|
+
const baseFromEntity = entityDef?.retention ?? null;
|
|
71
|
+
const base = baseFromPreset ?? baseFromEntity;
|
|
72
|
+
|
|
73
|
+
const keepFor = tenantOverride.keepFor ?? base?.keepFor;
|
|
74
|
+
const strategy = tenantOverride.strategy ?? base?.strategy;
|
|
75
|
+
|
|
76
|
+
// keepFor + strategy sind Pflicht für jede aktive Policy. Wenn
|
|
77
|
+
// weder Override noch Base sie liefert, ist das Override semantisch
|
|
78
|
+
// unvollständig — Cleanup-Job soll WARNEN statt mit Default-"0d"
|
|
79
|
+
// sofort alles löschen. Source-Marker dokumentiert das.
|
|
80
|
+
if (keepFor === undefined || strategy === undefined) {
|
|
81
|
+
return { entityName, policy: null, source: "override-incomplete" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const merged: RetentionDef = {
|
|
85
|
+
keepFor,
|
|
86
|
+
strategy,
|
|
87
|
+
reference: tenantOverride.reference ?? base?.reference,
|
|
88
|
+
};
|
|
89
|
+
return { entityName, policy: merged, source: "override" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Layer 2: Preset
|
|
93
|
+
if (tenantPreset !== null) {
|
|
94
|
+
const fromPreset = RETENTION_PRESETS[tenantPreset]?.[entityName];
|
|
95
|
+
if (fromPreset) {
|
|
96
|
+
return { entityName, policy: fromPreset, source: "preset" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Layer 1: Entity-Default
|
|
101
|
+
if (entityDef?.retention) {
|
|
102
|
+
return { entityName, policy: entityDef.retention, source: "entity-default" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Nichts da — Cleanup-Job überspringt diese Entity für diesen Tenant.
|
|
106
|
+
return { entityName, policy: null, source: "none" };
|
|
107
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createTextField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// tenantRetentionOverride — Layer 3 im 3-Schicht-Resolver.
|
|
9
|
+
//
|
|
10
|
+
// Per (tenantId, entityName) eine optionale Override-Config die das
|
|
11
|
+
// Preset (Layer 2) für genau diese eine Entity überschreibt. Use-Cases:
|
|
12
|
+
// - Anwaltskanzlei in DE: caseFile 6y blockDelete (nicht im Preset)
|
|
13
|
+
// - Pilot-Tenant der länger speichert für Test
|
|
14
|
+
// - Branchenspezifische verkürzte Fristen
|
|
15
|
+
//
|
|
16
|
+
// reason ist Pflicht — Audit für DPO + Aufsichtsbehörde nachvollziehbar.
|
|
17
|
+
//
|
|
18
|
+
// config ist JSON-String mit `{ keepFor, strategy, reference? }`. Zod-
|
|
19
|
+
// Schema validiert beim set-override-Call (S2.D2 ggf. erweitert).
|
|
20
|
+
//
|
|
21
|
+
// Tenant-1-zu-N: pro Tenant beliebig viele Entity-Overrides. UNIQUE-
|
|
22
|
+
// Index auf (tenantId, entityName) damit pro Entity max ein Override.
|
|
23
|
+
export const tenantRetentionOverrideEntity = createEntity({
|
|
24
|
+
table: "read_tenant_retention_overrides",
|
|
25
|
+
fields: {
|
|
26
|
+
entityName: createTextField({
|
|
27
|
+
required: true,
|
|
28
|
+
maxLength: 100,
|
|
29
|
+
allowPlaintext: "is-business-data",
|
|
30
|
+
}),
|
|
31
|
+
config: createLongTextField({
|
|
32
|
+
required: true,
|
|
33
|
+
allowPlaintext: "is-business-data",
|
|
34
|
+
}),
|
|
35
|
+
reason: createTextField({
|
|
36
|
+
required: true,
|
|
37
|
+
maxLength: 500,
|
|
38
|
+
allowPlaintext: "is-business-data",
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
indexes: [{ unique: true, columns: ["tenantId", "entityName"] }],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const tenantRetentionOverrideTable = buildDrizzleTable(
|
|
45
|
+
"tenantRetentionOverride",
|
|
46
|
+
tenantRetentionOverrideEntity,
|
|
47
|
+
);
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
25
25
|
import {
|
|
26
26
|
access,
|
|
27
|
+
type ConfigAccessor,
|
|
27
28
|
createTenantConfig,
|
|
28
29
|
defineFeature,
|
|
29
|
-
type
|
|
30
|
+
type Registry,
|
|
30
31
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
32
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
32
33
|
|
|
@@ -36,13 +37,52 @@ const FEATURE_NAME = "file-foundation";
|
|
|
36
37
|
// Plugin-Interface — what a Provider-Plugin must implement
|
|
37
38
|
// =============================================================================
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
|
|
42
|
+
* fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
|
|
43
|
+
* read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
|
|
44
|
+
* gebraucht werden.
|
|
45
|
+
*
|
|
46
|
+
* **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
|
|
47
|
+
* es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
|
|
48
|
+
* `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
|
|
49
|
+
* brechen — und das wuerde NUR mit S3 und nur in production auffallen.
|
|
50
|
+
* Die schmale Surface zwingt Provider zur expliziten Erweiterung
|
|
51
|
+
* (extra-arg) statt silent ctx-feld-ausnutzen.
|
|
52
|
+
*
|
|
53
|
+
* **Felder:**
|
|
54
|
+
* config — fuer tenant-config-reads (bucket/region/endpoint/...)
|
|
55
|
+
* registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
|
|
56
|
+
* secrets — fuer tenant-secret-reads (s3.secretAccessKey)
|
|
57
|
+
* _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
|
|
58
|
+
* der dispatcher das auf die Caller-User-ID; im Worker-Pfad
|
|
59
|
+
* muss der r.job-Wrap das explizit auf eine System-Identity
|
|
60
|
+
* setzen (z.B. "system:user-data-rights:run-export-jobs").
|
|
61
|
+
*/
|
|
62
|
+
export type FileProviderContext = {
|
|
63
|
+
readonly config?: ConfigAccessor;
|
|
64
|
+
readonly registry?: Registry;
|
|
65
|
+
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
66
|
+
readonly _userId?: string | undefined;
|
|
67
|
+
};
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
|
|
41
71
|
* file-provider-azure-blob, ...) registers an implementation via
|
|
42
72
|
* `r.useExtension("fileProvider", "<name>", { build })`.
|
|
73
|
+
*
|
|
74
|
+
* **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
|
|
75
|
+
* nicht ein voller HandlerContext. Felder ausserhalb der schmalen
|
|
76
|
+
* Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
|
|
77
|
+
* im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
|
|
78
|
+
* Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
|
|
79
|
+
* fliegt zur Runtime im Worker — und der Crash kommt erst in production
|
|
80
|
+
* mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
|
|
81
|
+
* FileProviderContext sind: lieber FileProviderContext explizit erweitern
|
|
82
|
+
* (sichtbarer breaking change) als ctx-cast.
|
|
43
83
|
*/
|
|
44
84
|
export type FileProviderPlugin = {
|
|
45
|
-
readonly build: (ctx:
|
|
85
|
+
readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
|
|
46
86
|
};
|
|
47
87
|
|
|
48
88
|
// =============================================================================
|
|
@@ -77,7 +117,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
77
117
|
// =============================================================================
|
|
78
118
|
|
|
79
119
|
export async function createFileProviderForTenant(
|
|
80
|
-
ctx:
|
|
120
|
+
ctx: FileProviderContext,
|
|
81
121
|
tenantId: string,
|
|
82
122
|
handlerName = "file-foundation:provider-factory",
|
|
83
123
|
): Promise<FileStorageProvider> {
|
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
// **NICHT für Production.** Buffer ist Process-Memory, geht beim
|
|
18
18
|
// Restart verloren + wächst monoton mit jedem write.
|
|
19
19
|
|
|
20
|
-
import type {
|
|
21
|
-
|
|
20
|
+
import type {
|
|
21
|
+
FileProviderContext,
|
|
22
|
+
FileProviderPlugin,
|
|
23
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
24
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
25
|
import {
|
|
23
26
|
createInMemoryFileProvider,
|
|
24
27
|
type FileStorageProvider,
|
|
@@ -63,7 +66,7 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
63
66
|
r.requires("file-foundation");
|
|
64
67
|
|
|
65
68
|
const plugin: FileProviderPlugin = {
|
|
66
|
-
build: async (_ctx:
|
|
69
|
+
build: async (_ctx: FileProviderContext, tenantId: string): Promise<FileStorageProvider> => {
|
|
67
70
|
// Returnt den per-tenant Storage. Identitätsstabil zwischen calls
|
|
68
71
|
// damit accumulated state erhalten bleibt.
|
|
69
72
|
return getOrCreateProviderForTenant(tenantId);
|
|
@@ -18,19 +18,17 @@
|
|
|
18
18
|
//
|
|
19
19
|
// **Boot-Dependencies:** config + secrets + file-foundation.
|
|
20
20
|
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
FileProviderContext,
|
|
23
|
+
FileProviderPlugin,
|
|
24
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
22
25
|
import { createS3Provider } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
|
|
23
26
|
import {
|
|
24
27
|
requireDefined,
|
|
25
28
|
requireNonEmpty,
|
|
26
29
|
} from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
27
30
|
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
28
|
-
import {
|
|
29
|
-
access,
|
|
30
|
-
createTenantConfig,
|
|
31
|
-
defineFeature,
|
|
32
|
-
type HandlerContext,
|
|
33
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
31
|
+
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
34
32
|
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
35
33
|
|
|
36
34
|
const FEATURE_NAME = "file-provider-s3";
|
|
@@ -89,7 +87,7 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
89
87
|
// Plugin-Registration. entityName "s3" ist was tenants in
|
|
90
88
|
// file-foundation's `provider` config-key setzen.
|
|
91
89
|
const plugin: FileProviderPlugin = {
|
|
92
|
-
build: async (ctx:
|
|
90
|
+
build: async (ctx: FileProviderContext, tenantId: string) => buildS3Provider(ctx, tenantId),
|
|
93
91
|
};
|
|
94
92
|
r.useExtension("fileProvider", "s3", plugin);
|
|
95
93
|
|
|
@@ -104,7 +102,7 @@ export const S3_SECRET_ACCESS_KEY = fileProviderS3Feature.exports.secretAccessKe
|
|
|
104
102
|
// =============================================================================
|
|
105
103
|
|
|
106
104
|
async function buildS3Provider(
|
|
107
|
-
ctx:
|
|
105
|
+
ctx: FileProviderContext,
|
|
108
106
|
tenantId: string,
|
|
109
107
|
): Promise<FileStorageProvider> {
|
|
110
108
|
const ctxConfig = ctx.config;
|
|
@@ -157,7 +155,7 @@ async function buildS3Provider(
|
|
|
157
155
|
});
|
|
158
156
|
}
|
|
159
157
|
|
|
160
|
-
async function readSecretAccessKey(ctx:
|
|
158
|
+
async function readSecretAccessKey(ctx: FileProviderContext, tenantId: string): Promise<string> {
|
|
161
159
|
const secrets = requireSecretsContext(ctx, FEATURE_NAME);
|
|
162
160
|
const branded = await secrets.get(tenantId, S3_SECRET_ACCESS_KEY);
|
|
163
161
|
if (!branded) {
|