@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.280.0
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 +9 -9
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
- package/src/auth-mfa-user-data/hooks.ts +4 -6
- package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
- package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
- package/src/cap-counter/book-cap-usage.ts +158 -0
- package/src/cap-counter/changes.json +16 -1
- package/src/cap-counter/enforce-cap.ts +5 -37
- package/src/cap-counter/handlers/increment.write.ts +7 -47
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
- package/src/cap-counter/index.ts +8 -0
- package/src/cap-counter/stock-cap-guard.ts +14 -19
- package/src/cap-counter/with-cap-enforcement.ts +9 -11
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/wire-user-data-rights.ts +40 -24
- package/src/data-retention/resolve-for-tenant.ts +18 -6
- package/src/delivery/db/queries/preferences.ts +1 -0
- package/src/files-tenant-data/hooks.ts +3 -5
- package/src/folders/__tests__/folders.integration.test.ts +4 -3
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
- package/src/folders-user-data/hooks.ts +5 -10
- package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
- package/src/form-draft-user-data/hooks.ts +3 -6
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
- package/src/notes-history-user-data/hooks.ts +3 -4
- package/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/src/template-resolver-user-data/hooks.ts +1 -2
- package/src/tenant-lifecycle/feature.ts +2 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
- package/src/tenant-lifecycle/stages.ts +26 -2
- package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
- package/src/tier-engine/changes.json +7 -0
- package/src/tier-engine/tier-resolver.ts +7 -6
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
- package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
- package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
- package/src/user-data-rights/feature.ts +4 -0
- package/src/user-data-rights/run-export-jobs.ts +16 -1
- package/src/user-data-rights/run-forget-cleanup.ts +44 -2
- package/src/user-data-rights/run-user-export.ts +43 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// fw#2914 — proves the escape-hatch audit trail is wired end-to-end on the
|
|
2
|
+
// REAL production path: the registered "run-forget-cleanup" cron job (not
|
|
3
|
+
// runForgetCleanup called directly, not a hand-fed sink) forwards its
|
|
4
|
+
// JobContext's `_escapeHatchAuditSink` + `systemUser.id` down through
|
|
5
|
+
// runForgetCleanup → the per-entity `createEscapeHatchReporter` →
|
|
6
|
+
// ctx.db.unsafeRaw(reason) inside a declared-escapeHatch hook, so a
|
|
7
|
+
// declared raw-SQL use is actually attributed and audited instead of
|
|
8
|
+
// landing on the anonymous "<unattributed>" fallback.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
12
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import {
|
|
15
|
+
createSystemUser,
|
|
16
|
+
defineFeature,
|
|
17
|
+
type EscapeHatchUseEvent,
|
|
18
|
+
EXT_USER_DATA,
|
|
19
|
+
type JobContext,
|
|
20
|
+
type UserDataDeleteHook,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
23
|
+
import {
|
|
24
|
+
setupTestStack,
|
|
25
|
+
type TestStack,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
29
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
30
|
+
import { createSessionsFeature } from "../../sessions";
|
|
31
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
32
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
33
|
+
import { createForgetSeeders, READ_TENANT_MEMBERSHIPS_DDL } from "./forget-test-helpers";
|
|
34
|
+
|
|
35
|
+
const FORGET_USER = "ffffffff-ffff-4fff-8fff-000000000001";
|
|
36
|
+
const TENANT_A = "00000000-0000-4000-8000-0000000000f9";
|
|
37
|
+
|
|
38
|
+
const AUDITED_REASON =
|
|
39
|
+
"fw#2914 test: declared escapeHatch on the real run-forget-cleanup job path — must be audited, not <unattributed>";
|
|
40
|
+
|
|
41
|
+
const auditedHook: UserDataDeleteHook = async (ctx) => {
|
|
42
|
+
await asRawClient(ctx.db.unsafeRaw(AUDITED_REASON)).unsafe(
|
|
43
|
+
`DELETE FROM test_audited_entity WHERE tenant_id = $1`,
|
|
44
|
+
[ctx.tenantId],
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const auditedFeature = defineFeature("test-audited-entity", (r) => {
|
|
49
|
+
r.useExtension(EXT_USER_DATA, "audited-entity", {
|
|
50
|
+
export: async () => null,
|
|
51
|
+
delete: auditedHook,
|
|
52
|
+
escapeHatch: { reason: AUDITED_REASON },
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const seed = (db: unknown) =>
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
|
|
58
|
+
createForgetSeeders(db as any, { write: async () => {} });
|
|
59
|
+
|
|
60
|
+
describe("escape-hatch audit :: real run-forget-cleanup job forwards _escapeHatchAuditSink + systemUser.id", () => {
|
|
61
|
+
let stack: TestStack;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
stack = await setupTestStack({
|
|
65
|
+
features: [
|
|
66
|
+
createUserFeature(),
|
|
67
|
+
createDataRetentionFeature(),
|
|
68
|
+
createComplianceProfilesFeature(),
|
|
69
|
+
authFoundationFeature,
|
|
70
|
+
createSessionsFeature(),
|
|
71
|
+
createUserDataRightsFeature(),
|
|
72
|
+
auditedFeature,
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
77
|
+
await createEventsTable(stack.db);
|
|
78
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
79
|
+
await asRawClient(stack.db).unsafe(`
|
|
80
|
+
CREATE TABLE IF NOT EXISTS test_audited_entity (
|
|
81
|
+
id UUID PRIMARY KEY,
|
|
82
|
+
tenant_id UUID NOT NULL
|
|
83
|
+
)
|
|
84
|
+
`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await stack.cleanup();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("declared hook's unsafeRaw use is reported with a qualified handler + the job's systemUser as actor", async () => {
|
|
92
|
+
const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
|
|
93
|
+
expect(job).toBeTruthy();
|
|
94
|
+
|
|
95
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
96
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
|
|
97
|
+
|
|
98
|
+
const events: EscapeHatchUseEvent[] = [];
|
|
99
|
+
const systemUser = createSystemUser(TENANT_A);
|
|
100
|
+
const noopLog: JobContext["log"] = {
|
|
101
|
+
info() {},
|
|
102
|
+
warn() {},
|
|
103
|
+
error() {},
|
|
104
|
+
debug() {},
|
|
105
|
+
child(): JobContext["log"] {
|
|
106
|
+
return this;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await job?.handler({}, {
|
|
111
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
|
|
112
|
+
unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
|
|
113
|
+
}),
|
|
114
|
+
registry: stack.registry,
|
|
115
|
+
systemUser,
|
|
116
|
+
log: noopLog,
|
|
117
|
+
_escapeHatchAuditSink: async (event: EscapeHatchUseEvent) => {
|
|
118
|
+
events.push(event);
|
|
119
|
+
},
|
|
120
|
+
} as unknown as JobContext);
|
|
121
|
+
|
|
122
|
+
const hit = events.find((e) => e.reason === AUDITED_REASON);
|
|
123
|
+
expect(hit).toBeDefined();
|
|
124
|
+
expect(hit?.handler).toBe("userData:audited-entity");
|
|
125
|
+
expect(hit?.kind).toBe("unsafe-raw");
|
|
126
|
+
expect(hit?.tenantId).toBe(TENANT_A);
|
|
127
|
+
// The job's OWN systemUser.id, not "system"/"<unattributed>" — proves
|
|
128
|
+
// ctx.systemUser.id really travels from the JobContext down to the
|
|
129
|
+
// per-hook reporter instead of a hardcoded/default actor.
|
|
130
|
+
expect(hit?.actor).toBe(systemUser.id);
|
|
131
|
+
expect(hit?.actor).not.toBe("<unattributed>");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// fw#2914 — proves the escapeHatch declaration gate, not just its type.
|
|
2
|
+
//
|
|
3
|
+
// TenantDataHookCtx/UserDataHookCtx.db is a tenant-filtered TenantDb. A hook
|
|
4
|
+
// that declares `escapeHatch: { reason }` on its `r.useExtension(...)`
|
|
5
|
+
// registration can call `ctx.db.unsafeRaw(reason)` to get a REAL unfiltered
|
|
6
|
+
// DbRunner — the two tests below use it with a deliberately filter-less
|
|
7
|
+
// `DELETE FROM <table>` (no WHERE at all) to prove the grant is genuinely
|
|
8
|
+
// unfiltered, not merely present. A sibling hook that forgot to declare
|
|
9
|
+
// escapeHatch gets `AccessDeniedError` from that same call instead, which
|
|
10
|
+
// `runForgetCleanup` catches into `result.errors` — the row is left
|
|
11
|
+
// untouched rather than silently wiped/leaked across tenants.
|
|
12
|
+
//
|
|
13
|
+
// Pre-fix (TenantDataHookCtx/UserDataHookCtx.db was a raw, unfiltered
|
|
14
|
+
// DbRunner) BOTH hooks below — declared or not — could run this exact
|
|
15
|
+
// unfiltered DELETE with no gate at all. That's the regression this test
|
|
16
|
+
// pins shut.
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
19
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
20
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
|
+
import {
|
|
22
|
+
defineFeature,
|
|
23
|
+
EXT_USER_DATA,
|
|
24
|
+
type UserDataDeleteHook,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
27
|
+
import {
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
33
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
34
|
+
import { createSessionsFeature } from "../../sessions";
|
|
35
|
+
import { createUserFeature, userEntity } from "../../user";
|
|
36
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
37
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
38
|
+
import {
|
|
39
|
+
createForgetSeeders,
|
|
40
|
+
nowInstant,
|
|
41
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
42
|
+
} from "./forget-test-helpers";
|
|
43
|
+
|
|
44
|
+
const FORGET_USER = "eeeeeeee-eeee-4eee-8eee-000000000001";
|
|
45
|
+
const TENANT_A = "00000000-0000-4000-8000-0000000000e1";
|
|
46
|
+
const TENANT_B_FOREIGN = "00000000-0000-4000-8000-0000000000e2";
|
|
47
|
+
|
|
48
|
+
const seed = (db: unknown) =>
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
|
|
50
|
+
createForgetSeeders(db as any, { write: async () => {} });
|
|
51
|
+
|
|
52
|
+
function baseFeatures(): NonNullable<Parameters<typeof setupTestStack>[0]["features"]> {
|
|
53
|
+
return [
|
|
54
|
+
createUserFeature(),
|
|
55
|
+
createDataRetentionFeature(),
|
|
56
|
+
createComplianceProfilesFeature(),
|
|
57
|
+
authFoundationFeature,
|
|
58
|
+
createSessionsFeature(),
|
|
59
|
+
createUserDataRightsFeature(),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function bootBaseTables(stack: TestStack): Promise<void> {
|
|
64
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
65
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
66
|
+
await createEventsTable(stack.db);
|
|
67
|
+
await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("escape-hatch declaration :: declared hook gets a real unfiltered DbRunner", () => {
|
|
71
|
+
const DECLARED_REASON =
|
|
72
|
+
"fw#2914 test: escape-hatch declared on r.useExtension — ctx.db.unsafeRaw(reason) must grant a real unfiltered DbRunner";
|
|
73
|
+
|
|
74
|
+
const declaredLeakHook: UserDataDeleteHook = async (ctx) => {
|
|
75
|
+
// No WHERE clause at all — only possible with a genuinely unfiltered runner.
|
|
76
|
+
await asRawClient(ctx.db.unsafeRaw(DECLARED_REASON)).unsafe(`DELETE FROM test_leaky_declared`);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const declaredFeature = defineFeature("test-leaky-declared", (r) => {
|
|
80
|
+
r.useExtension(EXT_USER_DATA, "leaky-declared", {
|
|
81
|
+
export: async () => null,
|
|
82
|
+
delete: declaredLeakHook,
|
|
83
|
+
escapeHatch: { reason: DECLARED_REASON },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let stack: TestStack;
|
|
88
|
+
|
|
89
|
+
beforeAll(async () => {
|
|
90
|
+
stack = await setupTestStack({ features: [...baseFeatures(), declaredFeature] });
|
|
91
|
+
await bootBaseTables(stack);
|
|
92
|
+
await asRawClient(stack.db).unsafe(`
|
|
93
|
+
CREATE TABLE IF NOT EXISTS test_leaky_declared (
|
|
94
|
+
id UUID PRIMARY KEY,
|
|
95
|
+
tenant_id UUID NOT NULL,
|
|
96
|
+
secret TEXT NOT NULL
|
|
97
|
+
)
|
|
98
|
+
`);
|
|
99
|
+
await asRawClient(stack.db).unsafe(
|
|
100
|
+
`INSERT INTO test_leaky_declared (id, tenant_id, secret) VALUES
|
|
101
|
+
('11111111-1111-4111-8111-000000000001', $1, 'tenant-a-secret'),
|
|
102
|
+
('11111111-1111-4111-8111-000000000002', $2, 'tenant-b-secret')`,
|
|
103
|
+
[TENANT_A, TENANT_B_FOREIGN],
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await stack.cleanup();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("declared unsafeRaw wipes rows across every tenant, not just the forgotten user's own", async () => {
|
|
112
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
113
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
|
|
114
|
+
|
|
115
|
+
const result = await runForgetCleanup({
|
|
116
|
+
db: stack.db,
|
|
117
|
+
registry: stack.registry,
|
|
118
|
+
now: nowInstant(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.errors).toHaveLength(0);
|
|
122
|
+
expect(result.processedUserIds).toContain(FORGET_USER);
|
|
123
|
+
|
|
124
|
+
const remaining = await asRawClient(stack.db).unsafe(`SELECT id FROM test_leaky_declared`);
|
|
125
|
+
// Both Tenant A's own row AND the foreign Tenant B's row are gone —
|
|
126
|
+
// proves ctx.db.unsafeRaw(reason) really is unfiltered, not just granted.
|
|
127
|
+
expect(remaining).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("escape-hatch declaration :: undeclared hook is denied, not silently unfiltered", () => {
|
|
132
|
+
const UNDECLARED_REASON =
|
|
133
|
+
"fw#2914 test: escape-hatch NOT declared on r.useExtension — ctx.db.unsafeRaw(reason) must be denied";
|
|
134
|
+
|
|
135
|
+
const undeclaredLeakHook: UserDataDeleteHook = async (ctx) => {
|
|
136
|
+
await asRawClient(ctx.db.unsafeRaw(UNDECLARED_REASON)).unsafe(
|
|
137
|
+
`DELETE FROM test_leaky_undeclared`,
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const undeclaredFeature = defineFeature("test-leaky-undeclared", (r) => {
|
|
142
|
+
r.useExtension(EXT_USER_DATA, "leaky-undeclared", {
|
|
143
|
+
export: async () => null,
|
|
144
|
+
delete: undeclaredLeakHook,
|
|
145
|
+
// fw#2914 — deliberately NOT declared: this is the case under test.
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let stack: TestStack;
|
|
150
|
+
|
|
151
|
+
beforeAll(async () => {
|
|
152
|
+
stack = await setupTestStack({ features: [...baseFeatures(), undeclaredFeature] });
|
|
153
|
+
await bootBaseTables(stack);
|
|
154
|
+
await asRawClient(stack.db).unsafe(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS test_leaky_undeclared (
|
|
156
|
+
id UUID PRIMARY KEY,
|
|
157
|
+
tenant_id UUID NOT NULL,
|
|
158
|
+
secret TEXT NOT NULL
|
|
159
|
+
)
|
|
160
|
+
`);
|
|
161
|
+
await asRawClient(stack.db).unsafe(
|
|
162
|
+
`INSERT INTO test_leaky_undeclared (id, tenant_id, secret) VALUES
|
|
163
|
+
('22222222-2222-4222-8222-000000000001', $1, 'tenant-a-secret'),
|
|
164
|
+
('22222222-2222-4222-8222-000000000002', $2, 'tenant-b-secret')`,
|
|
165
|
+
[TENANT_A, TENANT_B_FOREIGN],
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterAll(async () => {
|
|
170
|
+
await stack.cleanup();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("undeclared unsafeRaw is denied and surfaces via result.errors, rows untouched", async () => {
|
|
174
|
+
await seed(stack.db).seedForgetUser(FORGET_USER);
|
|
175
|
+
await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
|
|
176
|
+
|
|
177
|
+
const result = await runForgetCleanup({
|
|
178
|
+
db: stack.db,
|
|
179
|
+
registry: stack.registry,
|
|
180
|
+
now: nowInstant(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// The hook throws AccessDeniedError inside processUser's sub-tx — that
|
|
184
|
+
// rolls the sub-tx back, so this user is NOT flipped to Deleted.
|
|
185
|
+
expect(result.processedUserIds).not.toContain(FORGET_USER);
|
|
186
|
+
expect(result.errors).toHaveLength(1);
|
|
187
|
+
expect(result.errors[0]?.entityName).toBe("leaky-undeclared");
|
|
188
|
+
expect(result.errors[0]?.userId).toBe(FORGET_USER);
|
|
189
|
+
expect(result.errors[0]?.message).toContain("escapeHatch");
|
|
190
|
+
|
|
191
|
+
const remaining = await asRawClient(stack.db).unsafe(
|
|
192
|
+
`SELECT tenant_id FROM test_leaky_undeclared ORDER BY tenant_id`,
|
|
193
|
+
);
|
|
194
|
+
// Both rows survive — the denial happened before any SQL executed, no
|
|
195
|
+
// cross-tenant leak (pre-fix, this exact hook body would have wiped both).
|
|
196
|
+
expect(remaining).toHaveLength(2);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -48,18 +48,26 @@ import {
|
|
|
48
48
|
|
|
49
49
|
const TENANT = "00000000-0000-4000-8000-0000000000aa";
|
|
50
50
|
|
|
51
|
+
// fw#2914 — both host entities are managed r.entity tables (EXECUTOR_ONLY-
|
|
52
|
+
// branded), so TenantDb's typed write methods reject them; these hooks need
|
|
53
|
+
// raw SQL, still filtered by inserted_by_id + tenant_id.
|
|
54
|
+
const HOST_DELETE_REASON =
|
|
55
|
+
"fw#2914 test fixture: owner-nulling delete/anonymize against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
|
|
56
|
+
const REDACT_REASON =
|
|
57
|
+
"fw#2914 test fixture: jsonb key removal (custom_fields - 'ssn') is a raw SQL expression TenantDb's typed updateMany can't express; filtered by inserted_by_id + tenant_id";
|
|
58
|
+
|
|
51
59
|
// Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
|
|
52
60
|
// clears the owner" pattern — same shape as file-ref/user host hooks).
|
|
53
61
|
function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
|
|
54
62
|
return async (ctx, strategy) => {
|
|
55
63
|
if (strategy === "delete") {
|
|
56
|
-
await asRawClient(ctx.db).unsafe(
|
|
64
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
|
|
57
65
|
`DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
58
66
|
[ctx.userId, ctx.tenantId],
|
|
59
67
|
);
|
|
60
68
|
return;
|
|
61
69
|
}
|
|
62
|
-
await asRawClient(ctx.db).unsafe(
|
|
70
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
|
|
63
71
|
`UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
64
72
|
[ctx.userId, ctx.tenantId],
|
|
65
73
|
);
|
|
@@ -72,7 +80,7 @@ function makeRedactHook(tableName: string): UserDataDeleteHook {
|
|
|
72
80
|
return async (ctx, strategy) => {
|
|
73
81
|
// skip: delete strategy removes rows wholesale — redaction N/A.
|
|
74
82
|
if (strategy === "delete") return;
|
|
75
|
-
await asRawClient(ctx.db).unsafe(
|
|
83
|
+
await asRawClient(ctx.db.unsafeRaw(REDACT_REASON)).unsafe(
|
|
76
84
|
`UPDATE ${tableName} SET custom_fields = custom_fields - 'ssn'
|
|
77
85
|
WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
78
86
|
[ctx.userId, ctx.tenantId],
|
|
@@ -119,10 +127,12 @@ const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
|
|
|
119
127
|
export: async () => null,
|
|
120
128
|
delete: makeRedactHook(CF_FIRST.tableName),
|
|
121
129
|
order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
|
|
130
|
+
escapeHatch: { reason: REDACT_REASON },
|
|
122
131
|
});
|
|
123
132
|
r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
|
|
124
133
|
export: async () => null,
|
|
125
134
|
delete: makeHostDeleteHook(CF_FIRST.tableName),
|
|
135
|
+
escapeHatch: { reason: HOST_DELETE_REASON },
|
|
126
136
|
});
|
|
127
137
|
});
|
|
128
138
|
|
|
@@ -131,11 +141,13 @@ const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
|
|
|
131
141
|
r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
|
|
132
142
|
export: async () => null,
|
|
133
143
|
delete: makeHostDeleteHook(HOST_FIRST.tableName),
|
|
144
|
+
escapeHatch: { reason: HOST_DELETE_REASON },
|
|
134
145
|
});
|
|
135
146
|
r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
|
|
136
147
|
export: async () => null,
|
|
137
148
|
delete: makeRedactHook(HOST_FIRST.tableName),
|
|
138
149
|
order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
|
|
150
|
+
escapeHatch: { reason: REDACT_REASON },
|
|
139
151
|
});
|
|
140
152
|
});
|
|
141
153
|
|
package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
createTextField,
|
|
16
16
|
defineFeature,
|
|
17
17
|
EXT_USER_DATA,
|
|
18
|
+
SYSTEM_TENANT_ID,
|
|
18
19
|
type UserDataDeleteHook,
|
|
19
20
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
21
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -32,7 +33,7 @@ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../use
|
|
|
32
33
|
import { createUserDataRightsFeature } from "../feature";
|
|
33
34
|
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
34
35
|
|
|
35
|
-
const TENANT_SYSTEM =
|
|
36
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
36
37
|
const TENANT_A = "00000000-0000-4000-8000-0000000000f1";
|
|
37
38
|
const ALICE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f1";
|
|
38
39
|
const NOTE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-0000000000f1";
|
|
@@ -53,9 +54,15 @@ const noteEntity = createEntity({
|
|
|
53
54
|
});
|
|
54
55
|
const noteTable = buildEntityTable("note", noteEntity);
|
|
55
56
|
|
|
57
|
+
// fw#2914 — "note" is a managed r.entity table (EXECUTOR_ONLY-branded), so
|
|
58
|
+
// TenantDb's typed write methods reject it; this delete is raw SQL, still
|
|
59
|
+
// filtered by author_id + tenant_id.
|
|
60
|
+
const HARD_DELETE_NOTE_REASON =
|
|
61
|
+
"fw#2914 test fixture: hard delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by author_id + tenant_id";
|
|
62
|
+
|
|
56
63
|
const hardDeleteNoteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
57
64
|
if (strategy !== "delete") return;
|
|
58
|
-
await asRawClient(ctx.db).unsafe(
|
|
65
|
+
await asRawClient(ctx.db.unsafeRaw(HARD_DELETE_NOTE_REASON)).unsafe(
|
|
59
66
|
`DELETE FROM read_forget_purge_notes WHERE author_id = $1 AND tenant_id = $2`,
|
|
60
67
|
[ctx.userId, ctx.tenantId],
|
|
61
68
|
);
|
|
@@ -66,6 +73,7 @@ const noteFeature = defineFeature("forget-purge-notes", (r) => {
|
|
|
66
73
|
r.useExtension(EXT_USER_DATA, "note", {
|
|
67
74
|
export: async () => null,
|
|
68
75
|
delete: hardDeleteNoteHook,
|
|
76
|
+
escapeHatch: { reason: HARD_DELETE_NOTE_REASON },
|
|
69
77
|
});
|
|
70
78
|
});
|
|
71
79
|
|
|
@@ -13,6 +13,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
13
13
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
14
14
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
17
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
17
18
|
import {
|
|
18
19
|
setupTestStack,
|
|
@@ -35,7 +36,7 @@ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defa
|
|
|
35
36
|
import { createUserDataRightsFeature } from "../feature";
|
|
36
37
|
|
|
37
38
|
const TENANT_A = "00000000-0000-4000-8000-0000000006a1";
|
|
38
|
-
const TENANT_SYSTEM =
|
|
39
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
39
40
|
const USER_ID = "00000000-0000-4000-8000-0000000006b1";
|
|
40
41
|
const ORIGINAL_EMAIL = "bridge-delete@example.test";
|
|
41
42
|
const FORGET_JOB = "user-data-rights:job:run-forget-cleanup";
|
|
@@ -133,6 +134,7 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
|
|
|
133
134
|
}),
|
|
134
135
|
registry: stack.registry,
|
|
135
136
|
configResolver,
|
|
137
|
+
systemUser: createSystemUser(TENANT_A),
|
|
136
138
|
log: {
|
|
137
139
|
info() {},
|
|
138
140
|
warn() {},
|
|
@@ -13,7 +13,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
|
|
|
13
13
|
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
14
14
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
15
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createSystemUser,
|
|
18
|
+
type JobContext,
|
|
19
|
+
SYSTEM_USER_ID,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
21
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
22
|
import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
|
|
19
23
|
import {
|
|
@@ -153,12 +157,13 @@ describe("run-export-jobs cron-context", () => {
|
|
|
153
157
|
registry: stack.registry,
|
|
154
158
|
configResolver,
|
|
155
159
|
_userId: SYSTEM_USER_ID,
|
|
160
|
+
systemUser: createSystemUser(TENANT),
|
|
156
161
|
now: getTemporal().Now.instant(),
|
|
157
162
|
};
|
|
158
163
|
// Vor dem Fix wirft der Wrapper hier "ctx.config is missing".
|
|
159
164
|
// jobCtx is a deliberately minimal prod-context replica (missing
|
|
160
|
-
//
|
|
161
|
-
//
|
|
165
|
+
// log/triggeredBy) — double-cast at this test boundary since the
|
|
166
|
+
// handler under test never touches those fields.
|
|
162
167
|
await job?.handler({}, jobCtx as unknown as JobContext);
|
|
163
168
|
|
|
164
169
|
const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
|
|
@@ -20,7 +20,11 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
|
|
|
20
20
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
21
21
|
import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
22
22
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
23
|
-
import
|
|
23
|
+
import {
|
|
24
|
+
createSystemUser,
|
|
25
|
+
type JobContext,
|
|
26
|
+
SYSTEM_TENANT_ID,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
28
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
25
29
|
import {
|
|
26
30
|
setupTestStack,
|
|
@@ -50,7 +54,7 @@ let stack: TestStack;
|
|
|
50
54
|
|
|
51
55
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
52
56
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
53
|
-
const TENANT_SYSTEM =
|
|
57
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
54
58
|
|
|
55
59
|
// Deterministische UUIDs fuer Tests — gleiche Helper wie in
|
|
56
60
|
// user-data-rights-defaults.
|
|
@@ -318,6 +322,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
318
322
|
}),
|
|
319
323
|
registry: stack.registry,
|
|
320
324
|
log,
|
|
325
|
+
systemUser: createSystemUser(TENANT_A),
|
|
321
326
|
};
|
|
322
327
|
await job?.handler({}, jobCtx as unknown as JobContext);
|
|
323
328
|
|
|
@@ -359,6 +364,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
|
|
|
359
364
|
unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
|
|
360
365
|
}),
|
|
361
366
|
registry: stack.registry,
|
|
367
|
+
systemUser: createSystemUser(TENANT_A),
|
|
362
368
|
log,
|
|
363
369
|
} as unknown as JobContext);
|
|
364
370
|
} finally {
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
isPiiCiphertext,
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
27
27
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createSystemUser,
|
|
30
|
+
SYSTEM_TENANT_ID,
|
|
31
|
+
type UserDataHookCtx,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
29
33
|
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
30
34
|
import {
|
|
31
35
|
setupTestStack,
|
|
@@ -58,7 +62,7 @@ let stack: TestStack;
|
|
|
58
62
|
|
|
59
63
|
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
60
64
|
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
61
|
-
const TENANT_SYSTEM =
|
|
65
|
+
const TENANT_SYSTEM = SYSTEM_TENANT_ID;
|
|
62
66
|
|
|
63
67
|
function uuid(suffix: number): string {
|
|
64
68
|
return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
|
|
@@ -498,7 +502,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
498
502
|
// in the GDPR bundle. The hook decrypts locally (same as invitations
|
|
499
503
|
// query) — assert plaintext here so a regression to raw ciphertext fails.
|
|
500
504
|
const hookCtx: UserDataHookCtx = {
|
|
501
|
-
db: stack.db,
|
|
505
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
502
506
|
registry: stack.registry,
|
|
503
507
|
tenantId: TENANT_A,
|
|
504
508
|
userId: ALICE_ID,
|
|
@@ -571,7 +575,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
|
|
|
571
575
|
// assertion would catch it even though the runUserExport() assertions
|
|
572
576
|
// below would stay green either way.
|
|
573
577
|
const hookCtx: UserDataHookCtx = {
|
|
574
|
-
db: stack.db,
|
|
578
|
+
db: createTenantDb(stack.db, TENANT_A, "tenant"),
|
|
575
579
|
registry: stack.registry,
|
|
576
580
|
tenantId: TENANT_A,
|
|
577
581
|
userId: ALICE_ID,
|
|
@@ -65,11 +65,6 @@ const TABLE = "read_dsgvo_tenant_scoped";
|
|
|
65
65
|
|
|
66
66
|
// Tenant-scoped contributor with NO per-user column — deletes by tenant only,
|
|
67
67
|
// and ONLY when this tenant is effectively single-user (mirrors credit).
|
|
68
|
-
const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
69
|
-
if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
|
|
70
|
-
await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
68
|
const scopedEntity = createEntity({
|
|
74
69
|
table: TABLE,
|
|
75
70
|
fields: {
|
|
@@ -77,11 +72,26 @@ const scopedEntity = createEntity({
|
|
|
77
72
|
},
|
|
78
73
|
});
|
|
79
74
|
|
|
75
|
+
// fw#2914 — "tenant-scoped" is a managed r.entity table (EXECUTOR_ONLY-branded),
|
|
76
|
+
// so TenantDb's typed write methods reject it; this delete is raw SQL, still
|
|
77
|
+
// filtered by tenant_id.
|
|
78
|
+
const TENANT_SCOPED_DELETE_REASON =
|
|
79
|
+
"fw#2914 test fixture: tenant-scoped erasure against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by tenant_id";
|
|
80
|
+
|
|
81
|
+
const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
82
|
+
if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
|
|
83
|
+
await asRawClient(ctx.db.unsafeRaw(TENANT_SCOPED_DELETE_REASON)).unsafe(
|
|
84
|
+
`DELETE FROM ${TABLE} WHERE tenant_id = $1`,
|
|
85
|
+
[ctx.tenantId],
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
80
89
|
const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
|
|
81
90
|
r.entity("tenant-scoped", scopedEntity);
|
|
82
91
|
r.useExtension(EXT_USER_DATA, "tenant-scoped", {
|
|
83
92
|
export: async () => null,
|
|
84
93
|
delete: tenantScopedDeleteHook,
|
|
94
|
+
escapeHatch: { reason: TENANT_SCOPED_DELETE_REASON },
|
|
85
95
|
});
|
|
86
96
|
});
|
|
87
97
|
|
|
@@ -610,6 +610,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
610
610
|
...(opts.appExportDownloadUrl !== undefined && {
|
|
611
611
|
appExportDownloadUrl: opts.appExportDownloadUrl,
|
|
612
612
|
}),
|
|
613
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
614
|
+
actor: ctx.systemUser.id,
|
|
613
615
|
});
|
|
614
616
|
},
|
|
615
617
|
});
|
|
@@ -677,6 +679,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
677
679
|
handlerName: "user-data-rights:run-forget-cleanup",
|
|
678
680
|
}),
|
|
679
681
|
...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
|
|
682
|
+
escapeHatchAuditSink: ctx._escapeHatchAuditSink,
|
|
683
|
+
actor: ctx.systemUser.id,
|
|
680
684
|
});
|
|
681
685
|
|
|
682
686
|
// Persist each incomplete report on the job-run log (#1572). The write
|