@cosmicdrift/kumiko-bundled-features 0.80.0 → 0.81.1
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/package.json +7 -6
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +39 -0
- package/src/file-provider-s3-env/feature.ts +58 -0
- package/src/file-provider-s3-env/index.ts +3 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +54 -5
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +53 -5
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +16 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +29 -0
- package/src/user-data-rights/feature.ts +52 -26
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +17 -2
- package/src/user-data-rights/lib/storage-provider-resolver.ts +48 -0
- package/src/user-data-rights/run-forget-cleanup.ts +16 -3
- package/src/user-data-rights-defaults/feature.ts +7 -16
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +88 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"./mail-transport-inmemory": "./src/mail-transport-inmemory/index.ts",
|
|
42
42
|
"./file-foundation": "./src/file-foundation/index.ts",
|
|
43
43
|
"./file-provider-s3": "./src/file-provider-s3/index.ts",
|
|
44
|
+
"./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
|
|
44
45
|
"./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
|
|
45
46
|
"./files": "./src/files/index.ts",
|
|
46
47
|
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
@@ -84,11 +85,11 @@
|
|
|
84
85
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
86
|
},
|
|
86
87
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
88
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.81.1",
|
|
89
|
+
"@cosmicdrift/kumiko-framework": "0.81.1",
|
|
90
|
+
"@cosmicdrift/kumiko-headless": "0.81.1",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer": "0.81.1",
|
|
92
|
+
"@cosmicdrift/kumiko-renderer-web": "0.81.1",
|
|
92
93
|
"@mollie/api-client": "^4.5.0",
|
|
93
94
|
"@node-rs/argon2": "^2.0.2",
|
|
94
95
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -27,6 +27,7 @@ import { createConfigAccessorFactory } from "../../config/feature";
|
|
|
27
27
|
import { type ConfigResolver, createConfigResolver } from "../../config/resolver";
|
|
28
28
|
import { configValuesTable } from "../../config/table";
|
|
29
29
|
import { fileProviderS3Feature, S3_SECRET_ACCESS_KEY } from "../../file-provider-s3";
|
|
30
|
+
import { fileProviderS3EnvFeature } from "../../file-provider-s3-env";
|
|
30
31
|
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
31
32
|
import { createTenantFeature } from "../../tenant/feature";
|
|
32
33
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
@@ -90,6 +91,7 @@ beforeAll(async () => {
|
|
|
90
91
|
createSecretsFeature(),
|
|
91
92
|
fileFoundationFeature,
|
|
92
93
|
fileProviderS3Feature,
|
|
94
|
+
fileProviderS3EnvFeature,
|
|
93
95
|
testProbeFeature,
|
|
94
96
|
],
|
|
95
97
|
masterKeyProvider: providerRef,
|
|
@@ -243,3 +245,40 @@ describe("scenario 3: tenant isolation", () => {
|
|
|
243
245
|
expect(b["hasWrite"]).toBe(true);
|
|
244
246
|
});
|
|
245
247
|
});
|
|
248
|
+
|
|
249
|
+
// --- Scenario 4: s3-env provider builds from env, NO per-tenant secret ---
|
|
250
|
+
//
|
|
251
|
+
// The "wire-into-any-app" proof for file-provider-s3-env: select the
|
|
252
|
+
// "s3-env" provider, set the S3_* env vars, and the factory builds a working
|
|
253
|
+
// provider WITHOUT any secrets:write:set call. This is the single-bucket /
|
|
254
|
+
// Hetzner deploy path — no admin seeding, no secrets store.
|
|
255
|
+
|
|
256
|
+
const S3_ENV_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
257
|
+
|
|
258
|
+
describe("scenario 4: s3-env provider (app-wide env, no secrets)", () => {
|
|
259
|
+
test("env vars set + provider=s3-env → factory builds provider without a per-tenant secret", async () => {
|
|
260
|
+
const admin = adminFor(504);
|
|
261
|
+
const saved = S3_ENV_KEYS.map((k) => [k, process.env[k]] as const);
|
|
262
|
+
Object.assign(process.env, {
|
|
263
|
+
S3_BUCKET: "shared-bucket",
|
|
264
|
+
S3_REGION: "fsn1",
|
|
265
|
+
S3_ACCESS_KEY: "AKIAENV",
|
|
266
|
+
S3_SECRET_KEY: "env-secret-not-real",
|
|
267
|
+
});
|
|
268
|
+
try {
|
|
269
|
+
await setConfig(admin, "file-foundation:config:provider", "s3-env");
|
|
270
|
+
const result = (await stack.http.writeOk(TEST_HANDLER_QN, {}, admin)) as Record<
|
|
271
|
+
string,
|
|
272
|
+
unknown
|
|
273
|
+
>;
|
|
274
|
+
expect(result["hasWrite"]).toBe(true);
|
|
275
|
+
expect(result["hasRead"]).toBe(true);
|
|
276
|
+
expect(result["hasDelete"]).toBe(true);
|
|
277
|
+
} finally {
|
|
278
|
+
for (const [k, v] of saved) {
|
|
279
|
+
if (v === undefined) delete process.env[k];
|
|
280
|
+
else process.env[k] = v;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// file-provider-s3-env — S3 file provider configured entirely from
|
|
4
|
+
// process.env. ONE shared bucket for ALL tenants: wire it by setting the
|
|
5
|
+
// `S3_*` env vars + mounting — no per-tenant admin seeding and no
|
|
6
|
+
// `secrets`-feature dependency.
|
|
7
|
+
//
|
|
8
|
+
// **vs file-provider-s3:** the config-based `"s3"` provider owns per-tenant
|
|
9
|
+
// config keys + an encrypted `s3.secretAccessKey` secret (per-tenant
|
|
10
|
+
// buckets, admin-seeded). This `"s3-env"` provider reads one credential set
|
|
11
|
+
// from env and serves every tenant from one bucket — the
|
|
12
|
+
// Hetzner-Object-Storage / single-bucket deploy case. Tenant isolation
|
|
13
|
+
// still holds: file keys are tenant-prefixed (export ZIPs:
|
|
14
|
+
// `<tenantId>/exports/<jobId>.zip`) or globally-unique UUIDs (fileRefs).
|
|
15
|
+
//
|
|
16
|
+
// **Pattern-Vorbild:** mirrors file-provider-inmemory (zero admin seeding,
|
|
17
|
+
// only the file-foundation plugin-point required).
|
|
18
|
+
//
|
|
19
|
+
// **Env vars** (read by createS3ProviderFromEnv, prefix `S3_`):
|
|
20
|
+
// required — S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY
|
|
21
|
+
// optional — S3_ENDPOINT (S3-compat stores incl. Hetzner Object Storage),
|
|
22
|
+
// S3_FORCE_PATH_STYLE
|
|
23
|
+
// Missing required vars throw on the FIRST file op (inside the export cron).
|
|
24
|
+
// The user-data-rights boot guard surfaces that at boot instead.
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
FileProviderContext,
|
|
28
|
+
FileProviderPlugin,
|
|
29
|
+
} from "@cosmicdrift/kumiko-bundled-features/file-foundation";
|
|
30
|
+
import { createS3ProviderFromEnv } from "@cosmicdrift/kumiko-bundled-features/files-provider-s3";
|
|
31
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
33
|
+
|
|
34
|
+
const FEATURE_NAME = "file-provider-s3-env";
|
|
35
|
+
|
|
36
|
+
export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
37
|
+
r.describe(
|
|
38
|
+
'Registers an `"s3-env"` provider for `file-foundation` that reads one S3 credential set from `process.env` (`S3_BUCKET`/`S3_REGION`/`S3_ACCESS_KEY`/`S3_SECRET_KEY`, optional `S3_ENDPOINT`/`S3_FORCE_PATH_STYLE`) and serves every tenant from one shared bucket — no per-tenant config or secret seeding. Use this for single-bucket S3-compatible deploys (e.g. Hetzner Object Storage); use `file-provider-s3` instead when each tenant needs its own bucket/credentials.',
|
|
39
|
+
);
|
|
40
|
+
r.uiHints({
|
|
41
|
+
displayLabel: "File Provider · S3 (env)",
|
|
42
|
+
category: "storage",
|
|
43
|
+
recommended: false,
|
|
44
|
+
});
|
|
45
|
+
// No r.requires("config") / r.requires("secrets") — credentials come from
|
|
46
|
+
// env, not the per-tenant config + secrets store. Only the file-foundation
|
|
47
|
+
// plugin-extension-point must be mounted.
|
|
48
|
+
r.requires("file-foundation");
|
|
49
|
+
|
|
50
|
+
const plugin: FileProviderPlugin = {
|
|
51
|
+
// tenantId ignored: one shared bucket serves all tenants (isolation via
|
|
52
|
+
// tenant-prefixed / UUID keys). Built fresh per call like file-provider-s3;
|
|
53
|
+
// the S3 client opens no connection at construction.
|
|
54
|
+
build: async (_ctx: FileProviderContext, _tenantId: string): Promise<FileStorageProvider> =>
|
|
55
|
+
createS3ProviderFromEnv(), // @wrapper-known semantic-alias
|
|
56
|
+
};
|
|
57
|
+
r.useExtension("fileProvider", "s3-env", plugin);
|
|
58
|
+
});
|
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
12
|
import {
|
|
12
13
|
createInMemoryFileProvider,
|
|
14
|
+
type FileStorageProvider,
|
|
13
15
|
fileRefsTable,
|
|
14
16
|
type InMemoryFileProvider,
|
|
15
17
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
@@ -21,24 +23,36 @@ import {
|
|
|
21
23
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
24
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
23
25
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
26
|
+
import { createConfigFeature } from "../../config";
|
|
27
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
24
30
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
31
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
25
32
|
import { createFilesFeature } from "../../files";
|
|
26
33
|
import { createSessionsFeature } from "../../sessions";
|
|
27
34
|
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
28
35
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
29
36
|
import { createUserDataRightsFeature } from "../feature";
|
|
37
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
30
38
|
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
31
39
|
import {
|
|
32
40
|
createForgetSeeders,
|
|
41
|
+
createTestFileProviderFeature,
|
|
33
42
|
type ForgetSeeders,
|
|
34
43
|
nowInstant,
|
|
35
44
|
READ_TENANT_MEMBERSHIPS_DDL,
|
|
36
45
|
} from "./forget-test-helpers";
|
|
37
46
|
|
|
47
|
+
const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
|
|
48
|
+
|
|
38
49
|
let stack: TestStack;
|
|
39
50
|
let db: DbConnection;
|
|
40
51
|
let provider: InMemoryFileProvider;
|
|
41
52
|
let seed: ForgetSeeders;
|
|
53
|
+
// Per-tenant resolver the forget pipeline uses — built from the stack's
|
|
54
|
+
// configResolver, resolves "test" → the test provider plugin → `provider`.
|
|
55
|
+
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
42
56
|
|
|
43
57
|
const TENANT = "00000000-0000-4000-8000-00000000000c";
|
|
44
58
|
|
|
@@ -48,24 +62,44 @@ function uuid(suffix: number): string {
|
|
|
48
62
|
|
|
49
63
|
beforeAll(async () => {
|
|
50
64
|
provider = createInMemoryFileProvider();
|
|
65
|
+
// Forget resolves the binary store through file-foundation (the production
|
|
66
|
+
// path) — mount it + a test plugin returning THIS provider, selected app-wide
|
|
67
|
+
// via a config app-override (no admin write needed).
|
|
68
|
+
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
69
|
+
const resolver = createConfigResolver({ appOverrides });
|
|
51
70
|
stack = await setupTestStack({
|
|
52
71
|
features: [
|
|
72
|
+
createConfigFeature(),
|
|
53
73
|
createUserFeature(),
|
|
54
74
|
createFilesFeature(),
|
|
75
|
+
fileFoundationFeature,
|
|
76
|
+
createTestFileProviderFeature(provider, "test"),
|
|
55
77
|
createDataRetentionFeature(),
|
|
56
78
|
createComplianceProfilesFeature(),
|
|
57
79
|
createSessionsFeature(),
|
|
58
80
|
createUserDataRightsFeature(),
|
|
59
|
-
createUserDataRightsDefaultsFeature(
|
|
81
|
+
createUserDataRightsDefaultsFeature(),
|
|
60
82
|
],
|
|
61
83
|
files: { storageProvider: provider },
|
|
84
|
+
extraContext: ({ registry }) => ({
|
|
85
|
+
configResolver: resolver,
|
|
86
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
87
|
+
}),
|
|
62
88
|
});
|
|
63
89
|
db = stack.db;
|
|
64
90
|
seed = createForgetSeeders(db, provider);
|
|
91
|
+
buildStorageProvider = makeTenantStorageProviderResolver({
|
|
92
|
+
registry: stack.registry,
|
|
93
|
+
configResolver: resolver,
|
|
94
|
+
secrets: undefined,
|
|
95
|
+
db,
|
|
96
|
+
userId: SYSTEM_USER_ID,
|
|
97
|
+
handlerName: "test-forget-cleanup",
|
|
98
|
+
});
|
|
65
99
|
|
|
66
100
|
await unsafeCreateEntityTable(db, userEntity);
|
|
67
101
|
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
68
|
-
await unsafePushTables(db, { fileRefsTable });
|
|
102
|
+
await unsafePushTables(db, { fileRefsTable, configValuesTable });
|
|
69
103
|
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
70
104
|
});
|
|
71
105
|
|
|
@@ -86,7 +120,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
86
120
|
const key = await seed.seedFile(uuid(101), TENANT, userId);
|
|
87
121
|
expect(await provider.exists(key)).toBe(true);
|
|
88
122
|
|
|
89
|
-
const result = await runForgetCleanup({
|
|
123
|
+
const result = await runForgetCleanup({
|
|
124
|
+
db,
|
|
125
|
+
registry: stack.registry,
|
|
126
|
+
now: nowInstant(),
|
|
127
|
+
buildStorageProvider,
|
|
128
|
+
});
|
|
90
129
|
|
|
91
130
|
expect(result.processedUserIds).toContain(userId);
|
|
92
131
|
expect(await provider.exists(key)).toBe(false);
|
|
@@ -104,7 +143,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
104
143
|
]);
|
|
105
144
|
expect(provider.keys()).toHaveLength(3);
|
|
106
145
|
|
|
107
|
-
await runForgetCleanup({
|
|
146
|
+
await runForgetCleanup({
|
|
147
|
+
db,
|
|
148
|
+
registry: stack.registry,
|
|
149
|
+
now: nowInstant(),
|
|
150
|
+
buildStorageProvider,
|
|
151
|
+
});
|
|
108
152
|
|
|
109
153
|
for (const key of keys) {
|
|
110
154
|
expect(await provider.exists(key)).toBe(false);
|
|
@@ -122,7 +166,12 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
|
|
|
122
166
|
// The other-tenant file is owned by a different user; the forget run for
|
|
123
167
|
// userId must NOT touch it.
|
|
124
168
|
|
|
125
|
-
await runForgetCleanup({
|
|
169
|
+
await runForgetCleanup({
|
|
170
|
+
db,
|
|
171
|
+
registry: stack.registry,
|
|
172
|
+
now: nowInstant(),
|
|
173
|
+
buildStorageProvider,
|
|
174
|
+
});
|
|
126
175
|
|
|
127
176
|
expect(await provider.exists(myKey)).toBe(false);
|
|
128
177
|
expect(await provider.exists(otherKey)).toBe(true);
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
13
|
import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
14
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
16
|
import {
|
|
16
17
|
createInMemoryFileProvider,
|
|
18
|
+
type FileStorageProvider,
|
|
17
19
|
fileRefsTable,
|
|
18
20
|
type InMemoryFileProvider,
|
|
19
21
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
@@ -25,24 +27,36 @@ import {
|
|
|
25
27
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
28
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
29
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
30
|
+
import { createConfigFeature } from "../../config";
|
|
31
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
32
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
33
|
+
import { configValuesTable } from "../../config/table";
|
|
28
34
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
35
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
29
36
|
import { createFilesFeature } from "../../files";
|
|
30
37
|
import { createSessionsFeature } from "../../sessions";
|
|
31
38
|
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
32
39
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
33
40
|
import { createUserDataRightsFeature } from "../feature";
|
|
41
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
34
42
|
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
35
43
|
import {
|
|
36
44
|
createForgetSeeders,
|
|
45
|
+
createTestFileProviderFeature,
|
|
37
46
|
type ForgetSeeders,
|
|
38
47
|
nowInstant,
|
|
39
48
|
READ_TENANT_MEMBERSHIPS_DDL,
|
|
40
49
|
} from "./forget-test-helpers";
|
|
41
50
|
|
|
51
|
+
const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
|
|
52
|
+
|
|
42
53
|
let stack: TestStack;
|
|
43
54
|
let db: DbConnection;
|
|
44
55
|
let base: InMemoryFileProvider;
|
|
45
56
|
let seed: ForgetSeeders;
|
|
57
|
+
// Per-tenant resolver for the direct runForgetCleanup calls — resolves through
|
|
58
|
+
// file-foundation to the flaky provider, same path the dispatcher handler uses.
|
|
59
|
+
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
46
60
|
// `delete` throws while set; flip off to simulate storage recovery on retry.
|
|
47
61
|
let failDeletes = true;
|
|
48
62
|
|
|
@@ -59,26 +73,45 @@ beforeAll(async () => {
|
|
|
59
73
|
return base.delete(key);
|
|
60
74
|
},
|
|
61
75
|
};
|
|
76
|
+
// Forget resolves the binary store through file-foundation (production path).
|
|
77
|
+
// The test plugin returns the flaky provider; selected app-wide via override.
|
|
78
|
+
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
79
|
+
const resolver = createConfigResolver({ appOverrides });
|
|
62
80
|
stack = await setupTestStack({
|
|
63
81
|
features: [
|
|
82
|
+
createConfigFeature(),
|
|
64
83
|
createUserFeature(),
|
|
65
84
|
createFilesFeature(),
|
|
85
|
+
fileFoundationFeature,
|
|
86
|
+
createTestFileProviderFeature(flakyProvider, "test"),
|
|
66
87
|
createDataRetentionFeature(),
|
|
67
88
|
createComplianceProfilesFeature(),
|
|
68
89
|
createSessionsFeature(),
|
|
69
90
|
createUserDataRightsFeature(),
|
|
70
|
-
createUserDataRightsDefaultsFeature(
|
|
91
|
+
createUserDataRightsDefaultsFeature(),
|
|
71
92
|
],
|
|
72
93
|
files: { storageProvider: flakyProvider },
|
|
94
|
+
extraContext: ({ registry }) => ({
|
|
95
|
+
configResolver: resolver,
|
|
96
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
97
|
+
}),
|
|
73
98
|
});
|
|
74
99
|
db = stack.db;
|
|
75
100
|
// Seeders write binaries through the real store (`base`), not the flaky
|
|
76
101
|
// wrapper — the wrapper's `delete` failure is what the test exercises.
|
|
77
102
|
seed = createForgetSeeders(db, base);
|
|
103
|
+
buildStorageProvider = makeTenantStorageProviderResolver({
|
|
104
|
+
registry: stack.registry,
|
|
105
|
+
configResolver: resolver,
|
|
106
|
+
secrets: undefined,
|
|
107
|
+
db,
|
|
108
|
+
userId: SYSTEM_USER_ID,
|
|
109
|
+
handlerName: "test-forget-failure",
|
|
110
|
+
});
|
|
78
111
|
|
|
79
112
|
await unsafeCreateEntityTable(db, userEntity);
|
|
80
113
|
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
81
|
-
await unsafePushTables(db, { fileRefsTable });
|
|
114
|
+
await unsafePushTables(db, { fileRefsTable, configValuesTable });
|
|
82
115
|
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
83
116
|
});
|
|
84
117
|
|
|
@@ -107,7 +140,12 @@ describe("forget fail-closed :: storage.delete failure aborts the row hard-delet
|
|
|
107
140
|
await seed.seedMembership(userId, TENANT);
|
|
108
141
|
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000001", TENANT, userId);
|
|
109
142
|
|
|
110
|
-
const result = await runForgetCleanup({
|
|
143
|
+
const result = await runForgetCleanup({
|
|
144
|
+
db,
|
|
145
|
+
registry: stack.registry,
|
|
146
|
+
now: nowInstant(),
|
|
147
|
+
buildStorageProvider,
|
|
148
|
+
});
|
|
111
149
|
|
|
112
150
|
// NOT flipped to Deleted — the sub-tx rolled back.
|
|
113
151
|
expect(result.processedUserIds).not.toContain(userId);
|
|
@@ -128,13 +166,23 @@ describe("forget fail-closed :: storage.delete failure aborts the row hard-delet
|
|
|
128
166
|
const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000002", TENANT, userId);
|
|
129
167
|
|
|
130
168
|
// First run: storage down → abort.
|
|
131
|
-
const first = await runForgetCleanup({
|
|
169
|
+
const first = await runForgetCleanup({
|
|
170
|
+
db,
|
|
171
|
+
registry: stack.registry,
|
|
172
|
+
now: nowInstant(),
|
|
173
|
+
buildStorageProvider,
|
|
174
|
+
});
|
|
132
175
|
expect(first.processedUserIds).not.toContain(userId);
|
|
133
176
|
expect(await base.exists(key)).toBe(true);
|
|
134
177
|
|
|
135
178
|
// Storage recovers; retry converges.
|
|
136
179
|
failDeletes = false;
|
|
137
|
-
const second = await runForgetCleanup({
|
|
180
|
+
const second = await runForgetCleanup({
|
|
181
|
+
db,
|
|
182
|
+
registry: stack.registry,
|
|
183
|
+
now: nowInstant(),
|
|
184
|
+
buildStorageProvider,
|
|
185
|
+
});
|
|
138
186
|
|
|
139
187
|
expect(second.processedUserIds).toContain(userId);
|
|
140
188
|
expect(await base.exists(key)).toBe(false);
|
|
@@ -6,11 +6,27 @@
|
|
|
6
6
|
|
|
7
7
|
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
8
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
9
11
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
10
12
|
import { USER_STATUS, userTable } from "../../user";
|
|
11
13
|
|
|
12
14
|
export const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
13
15
|
|
|
16
|
+
// Test file-provider plugin: registers `provider` under the file-foundation
|
|
17
|
+
// `fileProvider` extension point so the forget pipeline resolves THIS instance
|
|
18
|
+
// through createFileProviderForTenant — the same path production uses. Set
|
|
19
|
+
// `file-foundation:config:provider` to `name` to select it.
|
|
20
|
+
export function createTestFileProviderFeature(
|
|
21
|
+
provider: FileStorageProvider,
|
|
22
|
+
name = "test",
|
|
23
|
+
): FeatureDefinition {
|
|
24
|
+
return defineFeature(`test-file-provider-${name}`, (r) => {
|
|
25
|
+
r.requires("file-foundation");
|
|
26
|
+
r.useExtension("fileProvider", name, { build: async () => provider });
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
15
31
|
export const nowInstant = (): Instant => getTemporal().Now.instant();
|
|
16
32
|
export const pastInstant = (): Instant =>
|
|
@@ -269,6 +269,35 @@ describe("runForgetCleanup :: happy path (Cross-Tenant Account-Deletion)", () =>
|
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
273
|
+
// Beweist C1: der Forget-Cleanup ist als autonomer Cron registriert, nicht
|
|
274
|
+
// nur als manueller runForget-API. Ohne die r.job-Registrierung liefert
|
|
275
|
+
// getJob undefined und die Loeschung laeuft nach Grace-Ablauf NIE. Wir
|
|
276
|
+
// treiben den ECHTEN registrierten Job durch den echten JobContext (db +
|
|
277
|
+
// registry, kein config) — nicht den inneren runForgetCleanup-Helper.
|
|
278
|
+
test("registered job exists + erases through the real JobContext", async () => {
|
|
279
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
280
|
+
expect(job).toBeTruthy();
|
|
281
|
+
|
|
282
|
+
const CRON_USER = uuid(7);
|
|
283
|
+
await seedUser(CRON_USER, {
|
|
284
|
+
status: USER_STATUS.DeletionRequested,
|
|
285
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
286
|
+
email: "cron-delete@example.com",
|
|
287
|
+
displayName: "CronDelete",
|
|
288
|
+
});
|
|
289
|
+
await seedMembership(CRON_USER, TENANT_A);
|
|
290
|
+
|
|
291
|
+
const jobCtx = { db: stack.db, registry: stack.registry };
|
|
292
|
+
await job?.handler({}, jobCtx as never);
|
|
293
|
+
|
|
294
|
+
const row = await fetchUser(CRON_USER);
|
|
295
|
+
expect(row?.status).toBe(USER_STATUS.Deleted);
|
|
296
|
+
expect(row?.email).not.toContain("cron-delete@example.com");
|
|
297
|
+
expect(row?.email).toContain("anonymized.invalid");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
272
301
|
describe("runForgetCleanup :: time-window guards", () => {
|
|
273
302
|
test("Future-grace User wird NICHT bearbeitet", async () => {
|
|
274
303
|
await seedUser(FUTURE_USER_ID, {
|
|
@@ -4,8 +4,6 @@ import {
|
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
SYSTEM_USER_ID,
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
import { createConfigAccessor } from "../config";
|
|
8
|
-
import { createFileProviderForTenant } from "../file-foundation";
|
|
9
7
|
import { PRIVACY_CENTER_SCREEN_ID } from "./constants";
|
|
10
8
|
import { cancelDeletionWrite } from "./handlers/cancel-deletion.write";
|
|
11
9
|
import { createConfirmDeletionByTokenHandler } from "./handlers/confirm-deletion-by-token.write";
|
|
@@ -26,12 +24,13 @@ import {
|
|
|
26
24
|
import { requestExportWrite } from "./handlers/request-export.write";
|
|
27
25
|
import { restrictAccountWrite } from "./handlers/restrict-account.write";
|
|
28
26
|
import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
|
|
27
|
+
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
|
|
29
28
|
import {
|
|
30
29
|
runExportJobs,
|
|
31
30
|
type SendExportFailedEmailFn,
|
|
32
31
|
type SendExportReadyEmailFn,
|
|
33
32
|
} from "./run-export-jobs";
|
|
34
|
-
import type
|
|
33
|
+
import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
|
|
35
34
|
import { downloadAttemptEntity } from "./schema/download-attempt";
|
|
36
35
|
import { exportDownloadTokenEntity } from "./schema/download-token";
|
|
37
36
|
import { exportJobEntity } from "./schema/export-job";
|
|
@@ -276,29 +275,18 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
276
275
|
await runExportJobs({
|
|
277
276
|
db: exportDb,
|
|
278
277
|
registry: exportRegistry,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
tenantId as Parameters<typeof createConfigAccessor>[2],
|
|
292
|
-
exportUserId,
|
|
293
|
-
exportDb,
|
|
294
|
-
)
|
|
295
|
-
: undefined);
|
|
296
|
-
return createFileProviderForTenant(
|
|
297
|
-
{ config, registry: exportRegistry, secrets: ctx.secrets, _userId: exportUserId },
|
|
298
|
-
tenantId,
|
|
299
|
-
"user-data-rights:run-export-jobs",
|
|
300
|
-
);
|
|
301
|
-
},
|
|
278
|
+
// Per-tenant provider from the mounted file-foundation. The cron
|
|
279
|
+
// context carries configResolver (not the per-request ConfigAccessor),
|
|
280
|
+
// so the resolver builds a per-tenant accessor from it — otherwise
|
|
281
|
+
// createFileProviderForTenant throws and every export lands on failed.
|
|
282
|
+
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
283
|
+
registry: exportRegistry,
|
|
284
|
+
configResolver: ctx.configResolver,
|
|
285
|
+
secrets: ctx.secrets,
|
|
286
|
+
db: exportDb,
|
|
287
|
+
userId: exportUserId,
|
|
288
|
+
handlerName: "user-data-rights:run-export-jobs",
|
|
289
|
+
}),
|
|
302
290
|
now: T.Now.instant(),
|
|
303
291
|
// Atom 5 — App-Author-Callbacks fuer Email-Notification.
|
|
304
292
|
// Optional: wenn nicht gesetzt, kein Email; User pollt
|
|
@@ -315,6 +303,44 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
315
303
|
});
|
|
316
304
|
},
|
|
317
305
|
);
|
|
306
|
+
|
|
307
|
+
// Autonomer Art.17-Forget-Cron. Spiegelt run-export-jobs: nach Ablauf der
|
|
308
|
+
// Grace-Period laeuft runForgetCleanup unbeaufsichtigt (der manuelle
|
|
309
|
+
// userDataRights.runForget-API bleibt fuer Operator-Runs). Ohne diesen
|
|
310
|
+
// Cron bleibt jeder Loesch-Antrag fuer immer in DeletionRequested haengen
|
|
311
|
+
// — Art.17 wuerde nie ausgefuehrt.
|
|
312
|
+
r.job(
|
|
313
|
+
"run-forget-cleanup",
|
|
314
|
+
{ trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
|
|
315
|
+
async (_payload, ctx) => {
|
|
316
|
+
if (!ctx.db || !ctx.registry) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
"run-forget-cleanup: ctx.db + ctx.registry required (JobContext incomplete)",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
|
|
322
|
+
const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
|
|
323
|
+
const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
|
|
324
|
+
await runForgetCleanup({
|
|
325
|
+
db: forgetDb,
|
|
326
|
+
registry: ctx.registry,
|
|
327
|
+
now: T.Now.instant(),
|
|
328
|
+
// Same per-tenant provider resolution as the export cron — forget
|
|
329
|
+
// deletes binaries from the store upload + export use.
|
|
330
|
+
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
331
|
+
registry: ctx.registry,
|
|
332
|
+
configResolver: ctx.configResolver,
|
|
333
|
+
secrets: ctx.secrets,
|
|
334
|
+
db: forgetDb,
|
|
335
|
+
userId: forgetUserId,
|
|
336
|
+
handlerName: "user-data-rights:run-forget-cleanup",
|
|
337
|
+
}),
|
|
338
|
+
...(opts.sendDeletionExecutedEmail && {
|
|
339
|
+
sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
|
|
340
|
+
}),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
);
|
|
318
344
|
});
|
|
319
345
|
}
|
|
320
346
|
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Rueckgabe: Stats fuer Operator-Monitoring (processed-count, error-list).
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import { access, defineWriteHandler, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
15
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
15
16
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
16
17
|
import { z } from "zod";
|
|
18
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
17
19
|
import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "../run-forget-cleanup";
|
|
18
20
|
|
|
19
21
|
export type RunForgetCleanupOptions = {
|
|
@@ -36,12 +38,25 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
36
38
|
|
|
37
39
|
// ctx.db.raw ist DbRunner. runForgetCleanup oeffnet pro User eine
|
|
38
40
|
// Sub-Tx (SAVEPOINT wenn Outer-Dispatcher-Tx aktiv) — siehe
|
|
39
|
-
// run-forget-cleanup.ts Header.
|
|
41
|
+
// run-forget-cleanup.ts Header.
|
|
40
42
|
const T = getTemporal();
|
|
43
|
+
// Operator-triggered forget must also erase binaries, not just rows —
|
|
44
|
+
// it flips users to Deleted, after which the cron never re-processes
|
|
45
|
+
// them, so a row-only delete here would permanently leak the binaries.
|
|
46
|
+
// Resolve through the same file-foundation path the cron uses.
|
|
47
|
+
const forgetDb = ctx.db.raw as DbConnection; // @cast-boundary db-operator: config reads tolerate the outer tx
|
|
41
48
|
const result = await runForgetCleanup({
|
|
42
49
|
db: ctx.db.raw,
|
|
43
50
|
registry: ctx.registry,
|
|
44
51
|
now: T.Now.instant(),
|
|
52
|
+
buildStorageProvider: makeTenantStorageProviderResolver({
|
|
53
|
+
registry: ctx.registry,
|
|
54
|
+
configResolver: ctx.configResolver,
|
|
55
|
+
secrets: ctx.secrets,
|
|
56
|
+
db: forgetDb,
|
|
57
|
+
userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
58
|
+
handlerName: "user-data-rights:run-forget-cleanup",
|
|
59
|
+
}),
|
|
45
60
|
...(opts.sendDeletionExecutedEmail && {
|
|
46
61
|
sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
|
|
47
62
|
}),
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Builds a per-tenant file-storage-provider resolver from a job/handler
|
|
2
|
+
// context, so the export pipeline and the forget pipeline resolve binaries
|
|
3
|
+
// through the SAME mounted file-foundation (delete-target == upload-target by
|
|
4
|
+
// construction). Extracted from the export cron so both crons + the manual
|
|
5
|
+
// forget handler share one construction site instead of inlining it three times.
|
|
6
|
+
|
|
7
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
11
|
+
import { createConfigAccessor } from "../../config";
|
|
12
|
+
import { createFileProviderForTenant } from "../../file-foundation";
|
|
13
|
+
|
|
14
|
+
export interface TenantStorageResolverCtx {
|
|
15
|
+
readonly registry: Registry;
|
|
16
|
+
// Job-context carries configResolver (per-request ConfigAccessor exists only
|
|
17
|
+
// in the HTTP dispatcher); the resolver builds a per-tenant accessor from it.
|
|
18
|
+
// Undefined → the returned resolver throws (callers decide fail-loud vs skip).
|
|
19
|
+
readonly configResolver: ConfigResolver | undefined;
|
|
20
|
+
readonly secrets: SecretsContext | undefined;
|
|
21
|
+
readonly db: DbConnection;
|
|
22
|
+
readonly userId: string;
|
|
23
|
+
readonly handlerName: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeTenantStorageProviderResolver(
|
|
27
|
+
ctx: TenantStorageResolverCtx,
|
|
28
|
+
): (tenantId: TenantId) => Promise<FileStorageProvider> {
|
|
29
|
+
return async (tenantId) => {
|
|
30
|
+
if (!ctx.configResolver) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${ctx.handlerName}: ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const config = createConfigAccessor(
|
|
36
|
+
ctx.registry,
|
|
37
|
+
ctx.configResolver,
|
|
38
|
+
tenantId as Parameters<typeof createConfigAccessor>[2], // @cast-boundary engine-payload: TenantId brand
|
|
39
|
+
ctx.userId,
|
|
40
|
+
ctx.db,
|
|
41
|
+
);
|
|
42
|
+
return createFileProviderForTenant(
|
|
43
|
+
{ config, registry: ctx.registry, secrets: ctx.secrets, _userId: ctx.userId },
|
|
44
|
+
tenantId,
|
|
45
|
+
ctx.handlerName,
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type TenantId,
|
|
42
42
|
type UserDataDeleteHook,
|
|
43
43
|
type UserDataDeleteStrategy,
|
|
44
|
+
type UserDataStorageProvider,
|
|
44
45
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
45
46
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
46
47
|
import { resolveRetentionPolicyForTenant } from "../data-retention";
|
|
@@ -85,6 +86,16 @@ export interface RunForgetCleanupArgs {
|
|
|
85
86
|
* ohne Callback laeuft Worker still (User hatte schon
|
|
86
87
|
* request-deletion-Email + grace-period-Erinnerung). */
|
|
87
88
|
readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Per-tenant file-storage-provider resolver (the forget cron builds it from
|
|
92
|
+
* the mounted file-foundation, mirroring the export cron). Threaded into
|
|
93
|
+
* every delete-hook's ctx so file-aware hooks erase binaries from the same
|
|
94
|
+
* store the upload/export path uses. Omitted → hooks skip binary cleanup.
|
|
95
|
+
*/
|
|
96
|
+
readonly buildStorageProvider?: (
|
|
97
|
+
tenantId: TenantId,
|
|
98
|
+
) => Promise<UserDataStorageProvider | undefined>;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
export interface ForgetCleanupError {
|
|
@@ -119,7 +130,7 @@ const HOOK_ORDER_DEFAULT = EXT_USER_DATA_ORDER.DEFAULT;
|
|
|
119
130
|
export async function runForgetCleanup(
|
|
120
131
|
args: RunForgetCleanupArgs,
|
|
121
132
|
): Promise<RunForgetCleanupResult> {
|
|
122
|
-
const { db, registry, now, sendDeletionExecutedEmail } = args;
|
|
133
|
+
const { db, registry, now, sendDeletionExecutedEmail, buildStorageProvider } = args;
|
|
123
134
|
|
|
124
135
|
// Step 1: Find users with expired grace period.
|
|
125
136
|
const dueUsers = await selectUsersDueForForgetCleanup(
|
|
@@ -161,6 +172,7 @@ export async function runForgetCleanup(
|
|
|
161
172
|
registry,
|
|
162
173
|
userId: user.id,
|
|
163
174
|
hookEntries,
|
|
175
|
+
buildStorageProvider,
|
|
164
176
|
});
|
|
165
177
|
hookCallsAttempted += userResult.hookCallsAttempted;
|
|
166
178
|
errors.push(...userResult.errors);
|
|
@@ -216,8 +228,9 @@ async function processUser(args: {
|
|
|
216
228
|
registry: Registry;
|
|
217
229
|
userId: string;
|
|
218
230
|
hookEntries: readonly HookEntry[];
|
|
231
|
+
buildStorageProvider?: (tenantId: TenantId) => Promise<UserDataStorageProvider | undefined>;
|
|
219
232
|
}): Promise<ProcessUserResult> {
|
|
220
|
-
const { db, registry, userId, hookEntries } = args;
|
|
233
|
+
const { db, registry, userId, hookEntries, buildStorageProvider } = args;
|
|
221
234
|
const errors: ForgetCleanupError[] = [];
|
|
222
235
|
let hookCallsAttempted = 0;
|
|
223
236
|
|
|
@@ -274,7 +287,7 @@ async function processUser(args: {
|
|
|
274
287
|
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
275
288
|
|
|
276
289
|
hookCallsAttempted++;
|
|
277
|
-
await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
|
|
290
|
+
await entry.deleteHook({ db: tx, tenantId, userId, buildStorageProvider }, strategy);
|
|
278
291
|
}
|
|
279
292
|
}
|
|
280
293
|
|
|
@@ -3,20 +3,9 @@ import {
|
|
|
3
3
|
EXT_USER_DATA,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import
|
|
7
|
-
import { createFileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
6
|
+
import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
|
|
8
7
|
import { userDeleteHook, userExportHook } from "./hooks/user.userdata-hook";
|
|
9
8
|
|
|
10
|
-
export interface UserDataRightsDefaultsOptions {
|
|
11
|
-
/**
|
|
12
|
-
* Wired into the fileRef delete-hook: on strategy="delete" the hook
|
|
13
|
-
* calls `storageProvider.delete(key)` per row before hard-deleting
|
|
14
|
-
* the row. Without it, file binaries leak on forget (Art. 17) — the
|
|
15
|
-
* hook logs a one-shot warning so misconfiguration stays visible.
|
|
16
|
-
*/
|
|
17
|
-
readonly storageProvider?: FileStorageProvider;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
9
|
// user-data-rights-defaults — Default-Hooks für die Core-Entities
|
|
21
10
|
// `user` (S2.H1) und `fileRef` (S2.H2).
|
|
22
11
|
//
|
|
@@ -34,10 +23,12 @@ export interface UserDataRightsDefaultsOptions {
|
|
|
34
23
|
// Pattern matched file-foundation + file-provider-s3 (separate Plugin-
|
|
35
24
|
// Feature), nicht user/files schreiben ihre eigenen Hooks selbst weil
|
|
36
25
|
// das circular-requires waere.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
|
|
26
|
+
// Binary storage for the fileRef delete-hook is resolved at run time from the
|
|
27
|
+
// mounted file-foundation via ctx.buildStorageProvider (injected by the forget
|
|
28
|
+
// orchestrator) — no provider is captured here, so a single app-wide store and
|
|
29
|
+
// per-tenant stores both work, and forget deletes from the same store upload +
|
|
30
|
+
// export use. See hooks/file-ref.userdata-hook.ts.
|
|
31
|
+
export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
|
|
41
32
|
return defineFeature("user-data-rights-defaults", (r) => {
|
|
42
33
|
r.describe(
|
|
43
34
|
"Registers ready-made `EXT_USER_DATA` export and delete hooks for the two core entities: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`) and `fileRef` (delete removes both the DB row and the storage binary). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type {
|
|
3
|
+
UserDataDeleteHook,
|
|
4
|
+
UserDataExportHook,
|
|
5
|
+
UserDataHookCtx,
|
|
6
|
+
UserDataStorageProvider,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
4
9
|
|
|
5
10
|
// userData-Hook fuer fileRef-entity (S2.H2).
|
|
6
11
|
//
|
|
@@ -9,29 +14,32 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
|
|
|
9
14
|
// NICHT direkt — sie werden via signed-Download-URLs separat ins ZIP
|
|
10
15
|
// gepackt (S2.U3 Export-Job-Pipeline orchestriert das).
|
|
11
16
|
//
|
|
12
|
-
// Delete-Hook entfernt FileRef-Zeile
|
|
13
|
-
// `createFileRefDeleteHook(storageProvider)`:
|
|
17
|
+
// Delete-Hook entfernt FileRef-Zeile + Binary:
|
|
14
18
|
// "delete": storageProvider.delete() pro File + Row hard-delete
|
|
15
19
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
16
20
|
// koennen weiter zeigen; Personenbezug raus)
|
|
17
21
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// die Row trotzdem hard-zu-loeschen wuerde Art.-17-Erasure als "done" markieren
|
|
24
|
-
// waehrend die Bytes auf Disk bleiben — eine falsche Compliance-Aussage. Das
|
|
25
|
-
// "KEIN globaler Rollback" der Sprint-2-Atomicity-Decision bleibt gewahrt: nur
|
|
26
|
-
// DIESE User-Sub-Tx rollt zurueck (= der Retry-Mechanismus), andere User des
|
|
27
|
-
// Laufs committen. Der anonymize-Pfad behaelt Row+binary bewusst, hat also
|
|
28
|
-
// nichts zu schlucken.
|
|
22
|
+
// **Provider-Resolution:** der Provider kommt zur Lauf-Zeit aus
|
|
23
|
+
// `ctx.buildStorageProvider(ctx.tenantId)` — der Forget-Orchestrator
|
|
24
|
+
// (run-forget-cleanup) baut ihn aus dem gemounteten file-foundation, also aus
|
|
25
|
+
// DEMSELBEN Store den Upload + Export nutzen (delete-target == upload-target by
|
|
26
|
+
// construction). Kein bei-Mount captured Provider mehr.
|
|
29
27
|
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
28
|
+
// **Zwei Fehlerklassen, bewusst verschieden behandelt:**
|
|
29
|
+
// 1. Resolution schlaegt fehl (kein Provider konfiguriert / configResolver
|
|
30
|
+
// fehlt) → NICHT fail-closed: EIN Warn pro Process + row-only-delete. Ein
|
|
31
|
+
// fehlkonfigurierter Store darf die Art.-17-Loeschung nicht DAUERHAFT
|
|
32
|
+
// blockieren (sonst haengt jeder User fuer immer in DeletionRequested);
|
|
33
|
+
// der Boot-Guard macht die Fehlkonfiguration sichtbar, Binaries werden
|
|
34
|
+
// nachgeholt sobald ein Provider existiert.
|
|
35
|
+
// 2. Binary-DELETE schlaegt fehl, OBWOHL ein Provider da ist → FAIL-CLOSED:
|
|
36
|
+
// der Hook wirft NACH dem Loop, die per-User-Sub-Tx von runForgetCleanup
|
|
37
|
+
// rollt zurueck, der User bleibt DeletionRequested, der naechste Run
|
|
38
|
+
// retried (delete ist idempotent → konvergiert). Den Fehler zu schlucken +
|
|
39
|
+
// die Row trotzdem zu loeschen wuerde Erasure als "done" markieren waehrend
|
|
40
|
+
// die Bytes liegen bleiben — falsche Compliance-Aussage. Das "KEIN globaler
|
|
41
|
+
// Rollback" der Sprint-2-Atomicity bleibt gewahrt: nur DIESE Sub-Tx rollt
|
|
42
|
+
// zurueck. Der anonymize-Pfad behaelt Row+binary, hat nichts zu schlucken.
|
|
35
43
|
//
|
|
36
44
|
// Caveat: hard-delete via deleteMany emittiert KEIN fileRef.deleted —
|
|
37
45
|
// die storage-tracking-MSP dekrementiert nicht. Wenn die zu loeschenden
|
|
@@ -88,61 +96,69 @@ export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
|
88
96
|
|
|
89
97
|
let missingStorageWarned = false;
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
99
|
+
// Resolve the per-tenant provider the forget orchestrator injected. A
|
|
100
|
+
// resolution failure (no provider configured / configResolver absent) collapses
|
|
101
|
+
// to `undefined` so the hook degrades to a row-only delete instead of throwing —
|
|
102
|
+
// see error-class 1 in the header. A working-provider binary-delete failure is
|
|
103
|
+
// handled separately (fail-closed) below.
|
|
104
|
+
async function resolveProvider(ctx: UserDataHookCtx): Promise<UserDataStorageProvider | undefined> {
|
|
105
|
+
if (!ctx.buildStorageProvider) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
return await ctx.buildStorageProvider(ctx.tenantId);
|
|
108
|
+
} catch {
|
|
109
|
+
// skip: provider unresolvable (not configured) → fall through to row-only
|
|
110
|
+
// delete; warn-once below gives operator visibility, boot guard catches it.
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const fileRefDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
116
|
+
if (strategy === "delete") {
|
|
117
|
+
const storageProvider = await resolveProvider(ctx);
|
|
118
|
+
if (storageProvider) {
|
|
119
|
+
const rows = await selectMany(ctx.db, fileRefsTable, {
|
|
120
|
+
tenantId: ctx.tenantId,
|
|
121
|
+
insertedById: ctx.userId,
|
|
122
|
+
});
|
|
123
|
+
const failedKeys: string[] = [];
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
126
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
127
|
+
try {
|
|
128
|
+
await storageProvider.delete(key);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
131
|
+
console.warn(
|
|
132
|
+
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
120
133
|
);
|
|
134
|
+
failedKeys.push(key);
|
|
121
135
|
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
}
|
|
137
|
+
// Fail-closed: abort before the row hard-delete so the sub-tx rolls back
|
|
138
|
+
// and the next forget run retries (delete is idempotent → converges).
|
|
139
|
+
if (failedKeys.length > 0) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
127
142
|
);
|
|
128
143
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// sichtbar.
|
|
135
|
-
await updateMany(
|
|
136
|
-
ctx.db,
|
|
137
|
-
fileRefsTable,
|
|
138
|
-
{ insertedById: null },
|
|
139
|
-
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
144
|
+
} else if (!missingStorageWarned) {
|
|
145
|
+
missingStorageWarned = true;
|
|
146
|
+
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|
|
147
|
+
console.warn(
|
|
148
|
+
"[user-data-rights-defaults:fileRef] no file provider resolvable from ctx.buildStorageProvider — file binaries are NOT deleted on forget (row-only delete). Mount file-foundation + a file-provider-* feature and set the provider config so erasure can reach the binaries.",
|
|
140
149
|
);
|
|
141
150
|
}
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
151
|
+
await deleteMany(ctx.db, fileRefsTable, { tenantId: ctx.tenantId, insertedById: ctx.userId });
|
|
152
|
+
} else {
|
|
153
|
+
// anonymize: insertedById=null, FileRef + binary bleiben.
|
|
154
|
+
// Use-case: shared chat-Attachment in einem Multi-User-Channel —
|
|
155
|
+
// Author-Identifikation raus, Datei bleibt fuer andere User
|
|
156
|
+
// sichtbar.
|
|
157
|
+
await updateMany(
|
|
158
|
+
ctx.db,
|
|
159
|
+
fileRefsTable,
|
|
160
|
+
{ insertedById: null },
|
|
161
|
+
{ tenantId: ctx.tenantId, insertedById: ctx.userId },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
};
|