@cosmicdrift/kumiko-bundled-features 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 +108 -0
- package/package.json +12 -6
- 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,93 @@
|
|
|
1
|
+
// seedComplianceProfile-Helper-Tests (S1.9 Z2).
|
|
2
|
+
//
|
|
3
|
+
// Beweist:
|
|
4
|
+
// 1. Helper umgeht set-profile-Zod-Engung (kann minimal-no-region
|
|
5
|
+
// setzen für Migration-Edge-Case-Tests in Sprint 2+)
|
|
6
|
+
// 2. Idempotent: zweiter Call mit gleichem tenantId updated den
|
|
7
|
+
// bestehenden Eintrag
|
|
8
|
+
// 3. Override wird als JSON-String persistiert + via for-tenant
|
|
9
|
+
// korrekt zurueckgelesen
|
|
10
|
+
|
|
11
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import {
|
|
13
|
+
createTestUser,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
testTenantId,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
|
|
21
|
+
import { seedComplianceProfile } from "../seeding";
|
|
22
|
+
|
|
23
|
+
const FOR_TENANT = "compliance-profiles:query:for-tenant";
|
|
24
|
+
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
|
|
27
|
+
const feature = createComplianceProfilesFeature();
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
stack = await setupTestStack({ features: [feature] });
|
|
31
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
|
+
await createEventsTable(stack.db);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await stack.cleanup();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("seedComplianceProfile", () => {
|
|
40
|
+
test("kann eu-dsgvo direkt seeden, for-tenant liefert das Profile", async () => {
|
|
41
|
+
const tenantId = testTenantId(200);
|
|
42
|
+
const user = createTestUser({ id: 200, tenantId, roles: ["TenantAdmin"] });
|
|
43
|
+
|
|
44
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
45
|
+
|
|
46
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
47
|
+
expect(result.profile.key).toBe("eu-dsgvo");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("idempotent: zweiter Call updated den bestehenden Eintrag", async () => {
|
|
51
|
+
const tenantId = testTenantId(201);
|
|
52
|
+
const user = createTestUser({ id: 201, tenantId, roles: ["TenantAdmin"] });
|
|
53
|
+
|
|
54
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
55
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "swiss-dsg" });
|
|
56
|
+
|
|
57
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
58
|
+
expect(result.profile.key).toBe("swiss-dsg");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("kann minimal-no-region direkt seeden (Migration-Edge-Case, ohne set-profile-Zod-Engung)", async () => {
|
|
62
|
+
const tenantId = testTenantId(202);
|
|
63
|
+
const user = createTestUser({ id: 202, tenantId, roles: ["TenantAdmin"] });
|
|
64
|
+
|
|
65
|
+
// set-profile (Sprint 1.7 X1) wuerde minimal-no-region rejecten —
|
|
66
|
+
// seedComplianceProfile umgeht das fuer Test-Migration-Szenarien.
|
|
67
|
+
await seedComplianceProfile(stack.db, {
|
|
68
|
+
tenantId,
|
|
69
|
+
profileKey: "minimal-no-region",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
73
|
+
expect(result.profile.key).toBe("minimal-no-region");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("Override wird persistiert + im for-tenant deep-merged zurueckgelesen", async () => {
|
|
77
|
+
const tenantId = testTenantId(203);
|
|
78
|
+
const user = createTestUser({ id: 203, tenantId, roles: ["TenantAdmin"] });
|
|
79
|
+
|
|
80
|
+
await seedComplianceProfile(stack.db, {
|
|
81
|
+
tenantId,
|
|
82
|
+
profileKey: "eu-dsgvo",
|
|
83
|
+
override: { userRights: { gracePeriod: { days: 90 } } },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await stack.http.queryOk<{
|
|
87
|
+
profile: { userRights: { gracePeriod: { days: number }; portabilityFormat: string[] } };
|
|
88
|
+
}>(FOR_TENANT, {}, user);
|
|
89
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 90 });
|
|
90
|
+
// Andere userRights bleiben aus eu-dsgvo
|
|
91
|
+
expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { forTenantQuery } from "./handlers/for-tenant.query";
|
|
3
|
+
import { listProfilesQuery } from "./handlers/list-profiles.query";
|
|
4
|
+
import { needsProfileQuery } from "./handlers/needs-profile.query";
|
|
5
|
+
import { setProfileWrite } from "./handlers/set-profile.write";
|
|
6
|
+
import { subProcessorsQuery } from "./handlers/sub-processors.query";
|
|
7
|
+
import { tenantComplianceProfileEntity } from "./schema/profile-selection";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
tenantComplianceProfileEntity,
|
|
11
|
+
tenantComplianceProfileTable,
|
|
12
|
+
} from "./schema/profile-selection";
|
|
13
|
+
|
|
14
|
+
// compliance-profiles — Tenant-weite DSGVO/Compliance-Profile-Wahl.
|
|
15
|
+
//
|
|
16
|
+
// Pflicht beim Tenant-Onboarding (Sprint 1.5 Banner-API). Profile
|
|
17
|
+
// buendelt User-Rights-Grace, Notification-Sprache, Breach-Disclosure,
|
|
18
|
+
// Audit-Retention und Sub-Processor-Anforderungen.
|
|
19
|
+
//
|
|
20
|
+
// Cross-Feature-API: r.exposesApi("compliance.forTenant") — andere
|
|
21
|
+
// Features (user-data-rights in Sprint 2, tenant-lifecycle in Sprint 5)
|
|
22
|
+
// rufen den Profile-Resolver via QN-Pattern (siehe legal-pages →
|
|
23
|
+
// text-content fuer Pattern-Beispiel).
|
|
24
|
+
//
|
|
25
|
+
// Architektur-Note: Profile-Selection lebt als separate Entity
|
|
26
|
+
// (tenantComplianceProfile), nicht als config-key im tenant-Feature.
|
|
27
|
+
// Begruendung in schema/profile-selection.ts.
|
|
28
|
+
export function createComplianceProfilesFeature(): FeatureDefinition {
|
|
29
|
+
return defineFeature("compliance-profiles", (r) => {
|
|
30
|
+
// Standalone — kein r.requires noetig: tenantId kommt aus dem User-
|
|
31
|
+
// Context, Profile-Selection ist eigene Entity, sub-processor-Liste
|
|
32
|
+
// sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
|
|
33
|
+
// r.requires hier rein.
|
|
34
|
+
r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
|
|
35
|
+
|
|
36
|
+
r.exposesApi("compliance.forTenant");
|
|
37
|
+
|
|
38
|
+
const handlers = {
|
|
39
|
+
setProfile: r.writeHandler(setProfileWrite),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const queries = {
|
|
43
|
+
forTenant: r.queryHandler(forTenantQuery),
|
|
44
|
+
listProfiles: r.queryHandler(listProfilesQuery),
|
|
45
|
+
subProcessors: r.queryHandler(subProcessorsQuery),
|
|
46
|
+
needsProfile: r.queryHandler(needsProfileQuery),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { handlers, queries };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ComplianceProfileKey,
|
|
3
|
+
type ComplianceProfileOverride,
|
|
4
|
+
type EffectiveComplianceProfile,
|
|
5
|
+
resolveComplianceProfile,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
7
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { eq } from "drizzle-orm";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { tenantComplianceProfileTable } from "../schema/profile-selection";
|
|
12
|
+
|
|
13
|
+
// Liefert das effektive Compliance-Profile fuer den aktuellen Tenant.
|
|
14
|
+
// Macht den exposesApi-Marker aus feature.ts mit echtem Inhalt.
|
|
15
|
+
//
|
|
16
|
+
// Default-Verhalten (Edge-Case-Decision aus S1.1): kein Profile-
|
|
17
|
+
// Eintrag → minimal-no-region + warning="no-profile-selected".
|
|
18
|
+
// Caller (z.B. user-data-rights in Sprint 2) sieht das warning und
|
|
19
|
+
// kann Onboarding-Banner triggern.
|
|
20
|
+
export const forTenantQuery = defineQueryHandler({
|
|
21
|
+
name: "for-tenant",
|
|
22
|
+
schema: z.object({}),
|
|
23
|
+
access: { openToAll: true },
|
|
24
|
+
handler: async (query, ctx): Promise<EffectiveComplianceProfile> => {
|
|
25
|
+
const row = (await fetchOne(
|
|
26
|
+
ctx.db,
|
|
27
|
+
tenantComplianceProfileTable,
|
|
28
|
+
eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
|
|
29
|
+
)) as { profileKey: string; override: string | null } | null;
|
|
30
|
+
|
|
31
|
+
if (!row) {
|
|
32
|
+
return resolveComplianceProfile({});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const override = parseOverride(row.override, query.user.tenantId);
|
|
36
|
+
return resolveComplianceProfile({
|
|
37
|
+
selection: row.profileKey as ComplianceProfileKey,
|
|
38
|
+
override,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function parseOverride(
|
|
44
|
+
raw: string | null,
|
|
45
|
+
tenantId: string,
|
|
46
|
+
): ComplianceProfileOverride | undefined {
|
|
47
|
+
if (!raw || raw.trim() === "") return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(raw) as ComplianceProfileOverride;
|
|
50
|
+
return parsed;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// Defensiv: ungültiges JSON wird als "kein Override" behandelt. Der
|
|
53
|
+
// set-profile-Handler validiert Zod das Override schon — invalides
|
|
54
|
+
// JSON in der DB ist also nur möglich bei manueller DB-Manipulation
|
|
55
|
+
// oder Migration-Bug. Resolver-Caller darf trotzdem nicht crashen.
|
|
56
|
+
// Operator-Sichtbarkeit via console.warn — Telemetry-Hook spaeter.
|
|
57
|
+
// biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
|
|
58
|
+
console.warn(
|
|
59
|
+
`[compliance-profiles:for-tenant] tenant ${tenantId}: stored override is not valid JSON, falling back to base profile. Reason: ${(e as Error).message}`,
|
|
60
|
+
);
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMPLIANCE_PROFILES,
|
|
3
|
+
type ComplianceProfile,
|
|
4
|
+
type ComplianceProfileKey,
|
|
5
|
+
SELECTABLE_PROFILE_KEYS,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
7
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// Liefert alle waehlbaren Compliance-Profile fuer das Tenant-Onboarding.
|
|
11
|
+
// Pure In-Memory-Read der Constants — keine DB-Abfrage. Kein Caching
|
|
12
|
+
// noetig (modulo Pre-Boot bereits aufgeloest).
|
|
13
|
+
//
|
|
14
|
+
// Filtert minimal-no-region raus — das ist Default-Fallback, nicht
|
|
15
|
+
// auswählbar (Production soll explizite Wahl treffen).
|
|
16
|
+
export const listProfilesQuery = defineQueryHandler({
|
|
17
|
+
name: "list-profiles",
|
|
18
|
+
schema: z.object({}),
|
|
19
|
+
access: { openToAll: true },
|
|
20
|
+
handler: async (): Promise<{ profiles: readonly ComplianceProfileSummary[] }> => {
|
|
21
|
+
return {
|
|
22
|
+
profiles: SELECTABLE_PROFILE_KEYS.map(toSummary),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
interface ComplianceProfileSummary {
|
|
28
|
+
readonly key: ComplianceProfileKey;
|
|
29
|
+
readonly region: string;
|
|
30
|
+
readonly label: string;
|
|
31
|
+
readonly authorityContact: string;
|
|
32
|
+
readonly languages: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toSummary(key: ComplianceProfileKey): ComplianceProfileSummary {
|
|
36
|
+
const p: ComplianceProfile = COMPLIANCE_PROFILES[key];
|
|
37
|
+
return {
|
|
38
|
+
key: p.key,
|
|
39
|
+
region: p.region,
|
|
40
|
+
label: p.label,
|
|
41
|
+
authorityContact: p.breach.authorityContact,
|
|
42
|
+
languages: p.notifications.languages,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
|
+
import type { ComplianceProfileKey } from "@cosmicdrift/kumiko-framework/compliance";
|
|
3
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { tenantComplianceProfileTable } from "../schema/profile-selection";
|
|
8
|
+
|
|
9
|
+
// Onboarding-Banner-Trigger fuer Tenant-Admin.
|
|
10
|
+
//
|
|
11
|
+
// Sprint 1.5 — minimaler API-Endpoint, UI-Banner kommt in einem
|
|
12
|
+
// spaeteren UI-Sprint. Reine Read-Query: gibt es einen Eintrag in
|
|
13
|
+
// tenantComplianceProfile fuer den aktuellen Tenant?
|
|
14
|
+
//
|
|
15
|
+
// Wenn nein → Tenant-Admin muss Profile waehlen (Pflicht beim
|
|
16
|
+
// Onboarding). Bis zur Wahl laeuft minimal-no-region mit warning,
|
|
17
|
+
// das in der Tenant-Dashboard-Banner sichtbar gemacht werden soll.
|
|
18
|
+
//
|
|
19
|
+
// Access: TenantAdmin only — der Banner ist nur fuer Tenant-Admins
|
|
20
|
+
// relevant, nicht fuer normale Member.
|
|
21
|
+
export const needsProfileQuery = defineQueryHandler({
|
|
22
|
+
name: "needs-profile",
|
|
23
|
+
schema: z.object({}),
|
|
24
|
+
access: { roles: [ROLES.TenantAdmin] },
|
|
25
|
+
handler: async (query, ctx): Promise<NeedsProfileResponse> => {
|
|
26
|
+
const row = (await fetchOne(
|
|
27
|
+
ctx.db,
|
|
28
|
+
tenantComplianceProfileTable,
|
|
29
|
+
eq(tenantComplianceProfileTable["tenantId"], query.user.tenantId),
|
|
30
|
+
)) as { profileKey: ComplianceProfileKey } | null;
|
|
31
|
+
|
|
32
|
+
if (!row) {
|
|
33
|
+
return {
|
|
34
|
+
needsSelection: true,
|
|
35
|
+
currentProfile: null,
|
|
36
|
+
reason: "no_profile_selected",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// S1.7 X1: minimal-no-region ist via set-profile (Zod) nicht mehr
|
|
41
|
+
// setzbar. Wenn Sprint 2 einen seedComplianceProfile-Helper liefert
|
|
42
|
+
// der den Migration-Edge-Case einführt, kommt hier wieder ein
|
|
43
|
+
// defensiver Pfad rein — bis dahin: jeder existierende Eintrag ist
|
|
44
|
+
// ein bewusst gewähltes Production-Profile.
|
|
45
|
+
return {
|
|
46
|
+
needsSelection: false,
|
|
47
|
+
currentProfile: row.profileKey,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
interface NeedsProfileResponse {
|
|
53
|
+
readonly needsSelection: boolean;
|
|
54
|
+
readonly currentProfile: ComplianceProfileKey | null;
|
|
55
|
+
readonly reason?: "no_profile_selected";
|
|
56
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
2
|
+
import {
|
|
3
|
+
type ComplianceProfileKey,
|
|
4
|
+
complianceProfileOverrideSchema,
|
|
5
|
+
SELECTABLE_PROFILE_KEYS,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
7
|
+
import { createEventStoreExecutor, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
AccessDeniedError,
|
|
11
|
+
UnprocessableError,
|
|
12
|
+
validationErrorFromZod,
|
|
13
|
+
writeFailure,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import {
|
|
18
|
+
tenantComplianceProfileEntity,
|
|
19
|
+
tenantComplianceProfileTable,
|
|
20
|
+
} from "../schema/profile-selection";
|
|
21
|
+
|
|
22
|
+
const crud = createEventStoreExecutor(tenantComplianceProfileTable, tenantComplianceProfileEntity, {
|
|
23
|
+
entityName: "tenant-compliance-profile",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Schema engt sich auf die 3 oeffentlich waehlbaren Profile (Sprint 1.7
|
|
27
|
+
// X1) — minimal-no-region ist Default-Fallback fuer "noch keine Wahl",
|
|
28
|
+
// nicht eine waehlbare Production-Option. Symmetrisch zu
|
|
29
|
+
// SELECTABLE_PROFILE_KEYS aus der framework/compliance-Liste.
|
|
30
|
+
const profileKeySchema = z.enum(
|
|
31
|
+
SELECTABLE_PROFILE_KEYS as readonly [ComplianceProfileKey, ...ComplianceProfileKey[]],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Tenant-Admin setzt Profile-Key + optional Override-JSON.
|
|
35
|
+
//
|
|
36
|
+
// Upsert-Verhalten: erste Wahl insert, weitere update. Idempotent —
|
|
37
|
+
// wer mit gleichen Werten zweimal aufruft, kriegt das gleiche Ergebnis
|
|
38
|
+
// (modulo aktualisierte Audit-Events im Event-Store).
|
|
39
|
+
//
|
|
40
|
+
// Cross-Tenant-Pfad: SystemAdmin kann via `tenantIdOverride` fuer einen
|
|
41
|
+
// anderen Tenant schreiben (Plattform-Operator-Setup, Customer-
|
|
42
|
+
// Onboarding-Migrationen). TenantAdmin's Override-Versuch → 403.
|
|
43
|
+
// executorUser.tenantId muss = ziel-tenant sein damit der event-store-
|
|
44
|
+
// Stream-Lookup nicht miss → version_conflict gibt (Memory:
|
|
45
|
+
// feedback_event_store_tenant_consistency).
|
|
46
|
+
//
|
|
47
|
+
// Validation:
|
|
48
|
+
// - profileKey muss in SELECTABLE_PROFILE_KEYS sein (Zod-checked)
|
|
49
|
+
// - override (optional) muss valides JSON-Object sein
|
|
50
|
+
// - override Top-Level-Keys muessen in ALLOWED_OVERRIDE_KEYS sein
|
|
51
|
+
// — verhindert Tippfehler die deepMerge stillschweigend ignoriert
|
|
52
|
+
export const setProfileWrite = defineWriteHandler({
|
|
53
|
+
name: "set-profile",
|
|
54
|
+
schema: z.object({
|
|
55
|
+
profileKey: profileKeySchema,
|
|
56
|
+
override: z.string().nullable().optional(),
|
|
57
|
+
tenantIdOverride: z.string().min(1).optional(),
|
|
58
|
+
}),
|
|
59
|
+
// SystemAdmin kann Profile fuer Customer-Setup setzen (Plattform-
|
|
60
|
+
// Operator-Pfad). TenantAdmin nur fuer eigenen Tenant.
|
|
61
|
+
access: { roles: [ROLES.TenantAdmin, ROLES.SystemAdmin] },
|
|
62
|
+
handler: async (event, ctx) => {
|
|
63
|
+
const tenantOverride = event.payload.tenantIdOverride;
|
|
64
|
+
if (tenantOverride !== undefined && !event.user.roles.includes(ROLES.SystemAdmin)) {
|
|
65
|
+
return writeFailure(
|
|
66
|
+
new AccessDeniedError({
|
|
67
|
+
i18nKey: "complianceProfiles.errors.tenantOverrideRequiresSystemAdmin",
|
|
68
|
+
details: { reason: "tenant_override_requires_system_admin" },
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const tenantId = (tenantOverride ?? event.user.tenantId) as TenantId;
|
|
73
|
+
const executorUser = tenantOverride !== undefined ? { ...event.user, tenantId } : event.user;
|
|
74
|
+
|
|
75
|
+
// Override-Validation: muss parseables JSON-Object sein UND dem
|
|
76
|
+
// ComplianceProfileOverride-Schema entsprechen (S1.9 Z3 — strict-Zod
|
|
77
|
+
// mit Top-Level + Sub-Level-Whitelist via .strict()). Tippfehler
|
|
78
|
+
// wie `{ userRights: { weeks: 3 } }` werden hier rejected statt vom
|
|
79
|
+
// deepMerge silent ins Profile gespliced.
|
|
80
|
+
//
|
|
81
|
+
// Errors via writeFailure + Kumiko-Error-Klassen (S1.10 M3) statt
|
|
82
|
+
// throw — landen so mit Path-Detail im response-body statt als
|
|
83
|
+
// generic internal_error.
|
|
84
|
+
if (event.payload.override) {
|
|
85
|
+
let parsed: unknown;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(event.payload.override);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return writeFailure(
|
|
90
|
+
new UnprocessableError("compliance_override_invalid_json", {
|
|
91
|
+
details: {
|
|
92
|
+
reason: "compliance_override_invalid_json",
|
|
93
|
+
parseError: (e as Error).message,
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const validation = complianceProfileOverrideSchema.safeParse(parsed);
|
|
99
|
+
if (!validation.success) {
|
|
100
|
+
return writeFailure(validationErrorFromZod(validation.error));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Upsert: existierenden Eintrag suchen
|
|
105
|
+
const existing = (await fetchOne(
|
|
106
|
+
ctx.db,
|
|
107
|
+
tenantComplianceProfileTable,
|
|
108
|
+
eq(tenantComplianceProfileTable["tenantId"], tenantId),
|
|
109
|
+
)) as { id: string; version: number } | null;
|
|
110
|
+
|
|
111
|
+
if (existing) {
|
|
112
|
+
const result = await crud.update(
|
|
113
|
+
{
|
|
114
|
+
id: existing.id,
|
|
115
|
+
version: existing.version,
|
|
116
|
+
changes: {
|
|
117
|
+
profileKey: event.payload.profileKey,
|
|
118
|
+
override: event.payload.override ?? null,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
executorUser,
|
|
122
|
+
ctx.db,
|
|
123
|
+
);
|
|
124
|
+
if (!result.isSuccess) return result;
|
|
125
|
+
return {
|
|
126
|
+
isSuccess: true as const,
|
|
127
|
+
data: { profileKey: event.payload.profileKey, isNew: false },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = await crud.create(
|
|
132
|
+
{
|
|
133
|
+
profileKey: event.payload.profileKey,
|
|
134
|
+
override: event.payload.override ?? null,
|
|
135
|
+
tenantId,
|
|
136
|
+
},
|
|
137
|
+
executorUser,
|
|
138
|
+
ctx.db,
|
|
139
|
+
);
|
|
140
|
+
if (!result.isSuccess) return result;
|
|
141
|
+
return {
|
|
142
|
+
isSuccess: true as const,
|
|
143
|
+
data: { profileKey: event.payload.profileKey, isNew: true },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getActiveSubProcessors,
|
|
3
|
+
getPlannedSubProcessors,
|
|
4
|
+
KUMIKO_SUB_PROCESSORS,
|
|
5
|
+
type SubProcessor,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
7
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// Public Sub-Processor-Liste — anonymous accessible (Memory:
|
|
12
|
+
// project_anonymous_access). Matched die DSGVO Art. 28(2) Pflicht
|
|
13
|
+
// dass die Liste der Auftragsverarbeiter oeffentlich einsehbar ist
|
|
14
|
+
// (typisch verlinkt aus Datenschutzerklaerung + AVV).
|
|
15
|
+
//
|
|
16
|
+
// Format: JSON mit getrennten active/planned-Sektionen. Tenant-Admins
|
|
17
|
+
// kriegen Notification ueber Aenderungen via Cron-Job (Sprint 1.5+
|
|
18
|
+
// oder S9 compliance-as-product). RSS-Feed kommt in S9.
|
|
19
|
+
//
|
|
20
|
+
// Zwei Auflistungen statt einer flachen Liste:
|
|
21
|
+
// - `active`: aktuell eingesetzte Sub-Processors
|
|
22
|
+
// - `planned`: bekannte zukünftige Sub-Processors (Tenant-Admin-
|
|
23
|
+
// Lead-Time bevor sie aktiv werden — typisch bei AI/Stripe)
|
|
24
|
+
export const subProcessorsQuery = defineQueryHandler({
|
|
25
|
+
name: "sub-processors",
|
|
26
|
+
schema: z.object({}),
|
|
27
|
+
access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
|
|
28
|
+
handler: async (): Promise<SubProcessorListResponse> => {
|
|
29
|
+
return {
|
|
30
|
+
active: [...getActiveSubProcessors()],
|
|
31
|
+
planned: [...getPlannedSubProcessors()],
|
|
32
|
+
generatedAt: getTemporal().Now.instant().toString(),
|
|
33
|
+
total: KUMIKO_SUB_PROCESSORS.length,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
interface SubProcessorListResponse {
|
|
39
|
+
readonly active: readonly SubProcessor[];
|
|
40
|
+
readonly planned: readonly SubProcessor[];
|
|
41
|
+
readonly generatedAt: string;
|
|
42
|
+
readonly total: number;
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Direkter Resolver-Helper fuer Bulk-Iteration ohne dispatcher-Roundtrip.
|
|
2
|
+
//
|
|
3
|
+
// `for-tenant`-Query (handlers/for-tenant.query.ts) ist die Cross-Feature-
|
|
4
|
+
// API fuer Handler-Pfade. Worker (S2.U3 Atom 3b) lebt im JobContext
|
|
5
|
+
// ohne `queryAs` — braucht direkten DB-Lookup + resolveComplianceProfile.
|
|
6
|
+
//
|
|
7
|
+
// Pattern matched data-retention's `resolveRetentionPolicyForTenant`.
|
|
8
|
+
// Beide Pfade nutzen `resolveComplianceProfile` aus framework/compliance,
|
|
9
|
+
// also kein Drift zwischen Query-API und Worker-Pfad.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type ComplianceProfileKey,
|
|
13
|
+
type ComplianceProfileOverride,
|
|
14
|
+
type EffectiveComplianceProfile,
|
|
15
|
+
resolveComplianceProfile,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/compliance";
|
|
17
|
+
import { type DbRunner, fetchOne } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { eq } from "drizzle-orm";
|
|
20
|
+
import { tenantComplianceProfileTable } from "./schema/profile-selection";
|
|
21
|
+
|
|
22
|
+
export interface ResolveProfileForTenantArgs {
|
|
23
|
+
readonly db: DbRunner;
|
|
24
|
+
readonly tenantId: TenantId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function resolveProfileForTenant(
|
|
28
|
+
args: ResolveProfileForTenantArgs,
|
|
29
|
+
): Promise<EffectiveComplianceProfile> {
|
|
30
|
+
const row = (await fetchOne(
|
|
31
|
+
args.db,
|
|
32
|
+
tenantComplianceProfileTable,
|
|
33
|
+
eq(tenantComplianceProfileTable["tenantId"], args.tenantId),
|
|
34
|
+
)) as { profileKey: string; override: string | null } | null;
|
|
35
|
+
|
|
36
|
+
if (!row) {
|
|
37
|
+
return resolveComplianceProfile({});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const override = parseOverride(row.override, args.tenantId);
|
|
41
|
+
return resolveComplianceProfile({
|
|
42
|
+
selection: row.profileKey as ComplianceProfileKey,
|
|
43
|
+
override,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseOverride(
|
|
48
|
+
raw: string | null,
|
|
49
|
+
tenantId: string,
|
|
50
|
+
): ComplianceProfileOverride | undefined {
|
|
51
|
+
if (!raw || raw.trim() === "") return undefined;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(raw) as ComplianceProfileOverride;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
|
|
56
|
+
console.warn(
|
|
57
|
+
`[compliance-profiles:resolve-for-tenant] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${(e as Error).message}`,
|
|
58
|
+
);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createLongTextField,
|
|
5
|
+
createSelectField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// Tenant-1-zu-1: Pro Tenant genau eine Profile-Wahl.
|
|
9
|
+
//
|
|
10
|
+
// Architektur-Entscheidung (2026-05-06): Profile-Selection lebt als
|
|
11
|
+
// separate Entity im compliance-profiles-Feature, NICHT als config-key
|
|
12
|
+
// im tenant-Feature. Begruendung:
|
|
13
|
+
// (a) override ist strukturiertes JSON, config-key-Pattern (timezone/
|
|
14
|
+
// locale) ist key-value-flach
|
|
15
|
+
// (b) Profile-Wechsel ist audit-relevant — Event-Store gibt das
|
|
16
|
+
// automatisch fuer Entity-Writes
|
|
17
|
+
// (c) Plan-Files in docs/plans/datenschutz/compliance-profiles.md
|
|
18
|
+
// nennen sie explizit als tenantComplianceProfile-Entity
|
|
19
|
+
//
|
|
20
|
+
// Wer in 6 Monaten zweifelt warum nicht config-key: siehe oben.
|
|
21
|
+
//
|
|
22
|
+
// override als JSON-String in longText: kein dedizierter jsonField-Typ
|
|
23
|
+
// im Framework; embedded hat festes Schema, das hier dynamisch ist.
|
|
24
|
+
// Zod-Validation beim set-profile-Handler stellt Schema-Konformitaet
|
|
25
|
+
// sicher.
|
|
26
|
+
export const tenantComplianceProfileEntity = createEntity({
|
|
27
|
+
table: "read_tenant_compliance_profiles",
|
|
28
|
+
fields: {
|
|
29
|
+
profileKey: createSelectField({
|
|
30
|
+
required: true,
|
|
31
|
+
options: ["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb", "minimal-no-region"] as const,
|
|
32
|
+
}),
|
|
33
|
+
// override: JSON-String mit Partial-ComplianceProfile. NULL/leer
|
|
34
|
+
// bedeutet "Default-Profile, keine Override". Validiert beim
|
|
35
|
+
// set-profile-Handler via Zod.
|
|
36
|
+
override: createLongTextField({
|
|
37
|
+
allowPlaintext: "is-business-data",
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
indexes: [
|
|
41
|
+
// Pro Tenant nur EIN Profile-Datensatz. Boot-Validator-Comment in
|
|
42
|
+
// EntityIndexDef warnt vor single-column-tenantId-Index als redundant
|
|
43
|
+
// — UNIQUE-Constraint ist hier aber semantisch noetig (1:1-Relation)
|
|
44
|
+
// und nicht nur Performance-Hint, daher explizit deklariert.
|
|
45
|
+
{ unique: true, columns: ["tenantId"] },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const tenantComplianceProfileTable = buildDrizzleTable(
|
|
50
|
+
"tenantComplianceProfile",
|
|
51
|
+
tenantComplianceProfileEntity,
|
|
52
|
+
);
|