@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.281.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-email-password/handlers/confirm-token-flow.ts +9 -0
- package/src/auth-email-password/handlers/login.write.ts +12 -17
- package/src/auth-mfa/mfa-code-verifier.ts +9 -1
- package/src/auth-mfa/mfa-status-checker.ts +5 -0
- 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/crypto-shredding/handlers/forget-subject.write.ts +24 -23
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +5 -10
- package/src/custom-fields/db/queries/field-access.ts +3 -5
- package/src/custom-fields/db/queries/quota.ts +6 -5
- package/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/lib/field-access.ts +5 -5
- 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/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/handlers/confirm-deletion-by-token.write.ts +19 -2
- package/src/user-data-rights/handlers/deletion-grace-period.ts +19 -27
- package/src/user-data-rights/handlers/request-deletion.write.ts +15 -2
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.281.0",
|
|
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>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.281.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.281.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.281.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.281.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.281.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.281.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.281.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.281.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
InMemoryKmsAdapter,
|
|
15
15
|
isPiiCiphertext,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import {
|
|
19
20
|
setupTestStack,
|
|
@@ -319,7 +320,7 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
319
320
|
expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
|
|
320
321
|
|
|
321
322
|
const result = await tenantInvitationExportHook({
|
|
322
|
-
db: stack.db,
|
|
323
|
+
db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
|
|
323
324
|
registry: stack.registry,
|
|
324
325
|
tenantId: TENANT_A_ID,
|
|
325
326
|
userId: bobId,
|
|
@@ -347,7 +348,12 @@ describe("auth flows with active KMS + blind index", () => {
|
|
|
347
348
|
expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
|
|
348
349
|
|
|
349
350
|
await tenantInvitationDeleteHook(
|
|
350
|
-
{
|
|
351
|
+
{
|
|
352
|
+
db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
|
|
353
|
+
registry: stack.registry,
|
|
354
|
+
tenantId: TENANT_A_ID,
|
|
355
|
+
userId: aliceId,
|
|
356
|
+
},
|
|
351
357
|
"delete",
|
|
352
358
|
);
|
|
353
359
|
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
26
|
createSystemUser,
|
|
27
|
+
declareEscapeHatch,
|
|
27
28
|
type HandlerContext,
|
|
28
29
|
type SessionUser,
|
|
29
30
|
SYSTEM_TENANT_ID,
|
|
@@ -69,6 +70,10 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
69
70
|
expiresAtMs: number,
|
|
70
71
|
spec: ConfirmTokenFlowSpec<TSuccessData>,
|
|
71
72
|
): Promise<WriteResult<TSuccessData>> {
|
|
73
|
+
declareEscapeHatch({
|
|
74
|
+
reason:
|
|
75
|
+
"writes the confirming user's row via UserHandlers.update on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
|
|
76
|
+
});
|
|
72
77
|
if (!ctx.redis) {
|
|
73
78
|
return writeFailure(new InternalError({ message: spec.redisRequiredMessage }));
|
|
74
79
|
}
|
|
@@ -131,6 +136,10 @@ async function loadValidatedUser(
|
|
|
131
136
|
systemUser: SessionUser,
|
|
132
137
|
userId: string,
|
|
133
138
|
): Promise<(AuthUserRow & { version: number }) | null> {
|
|
139
|
+
declareEscapeHatch({
|
|
140
|
+
reason:
|
|
141
|
+
"reads the confirming user's row via UserQueries.findForAuth on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
|
|
142
|
+
});
|
|
134
143
|
const me = parseAuthUserRow(
|
|
135
144
|
await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
|
|
136
145
|
);
|
|
@@ -95,12 +95,9 @@ type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
|
|
|
95
95
|
|
|
96
96
|
/** Uniform response on any credential miss — burns argon2 cost (#774). */
|
|
97
97
|
export async function gateResolveAuthUser(
|
|
98
|
-
|
|
99
|
-
systemUser: SessionUser,
|
|
100
|
-
email: string,
|
|
98
|
+
found: AuthUserRow | null,
|
|
101
99
|
password: string,
|
|
102
100
|
): Promise<GateOutcome<AuthenticatableUserRow>> {
|
|
103
|
-
const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
|
|
104
101
|
if (!found?.passwordHash || found.isDeleted) {
|
|
105
102
|
await verifyDummyPassword(password);
|
|
106
103
|
return reject(invalidCredentials());
|
|
@@ -176,15 +173,9 @@ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefi
|
|
|
176
173
|
/** Pick membership (last-active preferred); merge global + tenant roles. */
|
|
177
174
|
export async function gateResolveMembership(
|
|
178
175
|
ctx: HandlerContext,
|
|
179
|
-
systemUser: SessionUser,
|
|
180
176
|
found: AuthUserRow,
|
|
177
|
+
memberships: ReadonlyArray<Membership>,
|
|
181
178
|
): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
|
|
182
|
-
// Still needed for candidate ORDER (preferred tenant first) — the actual
|
|
183
|
-
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
184
|
-
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
185
|
-
userId: found.id,
|
|
186
|
-
})) as Array<Membership>; // @cast-boundary db-runner
|
|
187
|
-
|
|
188
179
|
if (memberships.length === 0) {
|
|
189
180
|
return reject(noMembership());
|
|
190
181
|
}
|
|
@@ -296,12 +287,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
296
287
|
handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
|
|
297
288
|
const systemUser = createSystemUser(SYSTEM_USER_ID);
|
|
298
289
|
|
|
299
|
-
const
|
|
300
|
-
ctx,
|
|
301
|
-
systemUser,
|
|
302
|
-
event.payload.email,
|
|
303
|
-
event.payload.password,
|
|
290
|
+
const foundRow = parseAuthUserRow(
|
|
291
|
+
await ctx.queryAs(systemUser, UserQueries.findForAuth, { email: event.payload.email }),
|
|
304
292
|
);
|
|
293
|
+
const userGate = await gateResolveAuthUser(foundRow, event.payload.password);
|
|
305
294
|
if (!userGate.ok) return userGate.result;
|
|
306
295
|
const found = userGate.value;
|
|
307
296
|
|
|
@@ -323,7 +312,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
323
312
|
const statusGate = gateEnforceAccountStatus(found);
|
|
324
313
|
if (!statusGate.ok) return statusGate.result;
|
|
325
314
|
|
|
326
|
-
|
|
315
|
+
// Still needed for candidate ORDER (preferred tenant first) — the actual
|
|
316
|
+
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
317
|
+
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
318
|
+
userId: found.id,
|
|
319
|
+
})) as Array<Membership>; // @cast-boundary db-runner
|
|
320
|
+
|
|
321
|
+
const membershipGate = await gateResolveMembership(ctx, found, memberships);
|
|
327
322
|
if (!membershipGate.ok) return membershipGate.result;
|
|
328
323
|
const { chosen, mergedRoles } = membershipGate.value;
|
|
329
324
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
declareEscapeHatch,
|
|
4
|
+
type HandlerContext,
|
|
5
|
+
type TenantId,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import { findUserMfaRow } from "./db/queries";
|
|
4
8
|
import { verifyMfaFactor } from "./verify-factor";
|
|
5
9
|
|
|
@@ -21,6 +25,10 @@ export type MfaCodeVerifier = (
|
|
|
21
25
|
// codes exist for.
|
|
22
26
|
export function createMfaCodeVerifier(): MfaCodeVerifier {
|
|
23
27
|
return async (ctx, userId, tenantId, code) => {
|
|
28
|
+
declareEscapeHatch({
|
|
29
|
+
reason:
|
|
30
|
+
"reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
|
|
31
|
+
});
|
|
24
32
|
const scopedDb = createTenantDb(
|
|
25
33
|
ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
|
|
26
34
|
tenantId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
|
+
declareEscapeHatch,
|
|
4
5
|
type HandlerContext,
|
|
5
6
|
type SessionUser,
|
|
6
7
|
type TenantId,
|
|
@@ -38,6 +39,10 @@ export function createMfaStatusChecker(opts: {
|
|
|
38
39
|
readonly challengeTokenSecret: string;
|
|
39
40
|
}): MfaStatusChecker {
|
|
40
41
|
return async (ctx, userId, tenantId, roles) => {
|
|
42
|
+
declareEscapeHatch({
|
|
43
|
+
reason:
|
|
44
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
|
|
45
|
+
});
|
|
41
46
|
const scopedDb = createTenantDb(
|
|
42
47
|
ctx.db.unsafeRaw(
|
|
43
48
|
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (not just leave it dangling for a future projection rebuild to resurrect).
|
|
4
4
|
|
|
5
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
-
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import { configureEntityFieldEncryption, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
7
7
|
import {
|
|
8
8
|
createTestUser,
|
|
9
9
|
setupTestStack,
|
|
@@ -77,7 +77,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
77
77
|
const user = await enrollUser("export-1");
|
|
78
78
|
|
|
79
79
|
const snippet = await userMfaExportHook({
|
|
80
|
-
db: stack.db,
|
|
80
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
81
81
|
registry: stack.registry,
|
|
82
82
|
tenantId: user.tenantId,
|
|
83
83
|
userId: user.id,
|
|
@@ -94,7 +94,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
94
94
|
test("export returns null for a user who never enrolled", async () => {
|
|
95
95
|
const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
|
|
96
96
|
const snippet = await userMfaExportHook({
|
|
97
|
-
db: stack.db,
|
|
97
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
98
98
|
registry: stack.registry,
|
|
99
99
|
tenantId: user.tenantId,
|
|
100
100
|
userId: user.id,
|
|
@@ -106,7 +106,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
106
106
|
const user = await enrollUser("delete-1");
|
|
107
107
|
|
|
108
108
|
const before = await userMfaExportHook({
|
|
109
|
-
db: stack.db,
|
|
109
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
110
110
|
registry: stack.registry,
|
|
111
111
|
tenantId: user.tenantId,
|
|
112
112
|
userId: user.id,
|
|
@@ -115,7 +115,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
115
115
|
|
|
116
116
|
await userMfaDeleteHook(
|
|
117
117
|
{
|
|
118
|
-
db: stack.db,
|
|
118
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
119
119
|
registry: stack.registry,
|
|
120
120
|
tenantId: user.tenantId,
|
|
121
121
|
userId: user.id,
|
|
@@ -124,7 +124,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
|
|
|
124
124
|
);
|
|
125
125
|
|
|
126
126
|
const after = await userMfaExportHook({
|
|
127
|
-
db: stack.db,
|
|
127
|
+
db: createTenantDb(stack.db, user.tenantId, "tenant"),
|
|
128
128
|
registry: stack.registry,
|
|
129
129
|
tenantId: user.tenantId,
|
|
130
130
|
userId: user.id,
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// replays via the event, a raw DELETE would be resurrected by a
|
|
8
8
|
// projection rebuild).
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
12
11
|
import {
|
|
13
12
|
createSystemUser,
|
|
14
13
|
type UserDataDeleteHook,
|
|
@@ -26,7 +25,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
26
25
|
// tokenHash exclusion in user-data-rights-defaults). Just confirms
|
|
27
26
|
// enrollment + when, which is what a data subject actually needs to see.
|
|
28
27
|
export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
29
|
-
const rows = await selectMany<{ id: string; enabledAt: unknown }>(
|
|
28
|
+
const rows = await ctx.db.selectMany<{ id: string; enabledAt: unknown }>(userMfaTable, {
|
|
30
29
|
userId: ctx.userId,
|
|
31
30
|
tenantId: ctx.tenantId,
|
|
32
31
|
});
|
|
@@ -41,15 +40,14 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
|
|
|
41
40
|
// secret can't be anonymized, and `personal: { of: "userId" }` already
|
|
42
41
|
// means this row belongs to exactly one user (never shared tenant data).
|
|
43
42
|
export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
|
|
44
|
-
const rows = await selectMany<{ id: string }>(
|
|
43
|
+
const rows = await ctx.db.selectMany<{ id: string }>(userMfaTable, {
|
|
45
44
|
userId: ctx.userId,
|
|
46
45
|
tenantId: ctx.tenantId,
|
|
47
46
|
});
|
|
48
47
|
// skip: nothing enrolled for this user — no row to erase
|
|
49
48
|
if (rows.length === 0) return;
|
|
50
49
|
const systemUser = createSystemUser(ctx.tenantId);
|
|
51
|
-
const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
52
50
|
for (const row of rows) {
|
|
53
|
-
assertErased(await executor.forget({ id: row.id }, systemUser,
|
|
51
|
+
assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
|
|
54
52
|
}
|
|
55
53
|
};
|
|
@@ -192,9 +192,9 @@ async function appendDenialAuditEvent(
|
|
|
192
192
|
subjectKey: string,
|
|
193
193
|
subjectKind: SubjectIdInput["kind"],
|
|
194
194
|
denialCode: string,
|
|
195
|
+
denialAuditRunner: DbRunner | undefined,
|
|
195
196
|
): Promise<WriteFailure | null> {
|
|
196
|
-
|
|
197
|
-
if (!outsideTx) {
|
|
197
|
+
if (!denialAuditRunner) {
|
|
198
198
|
return writeFailure(
|
|
199
199
|
new InternalError({
|
|
200
200
|
message:
|
|
@@ -230,28 +230,23 @@ async function appendDenialAuditEvent(
|
|
|
230
230
|
// This event is the only proof the denial happened. Skipping
|
|
231
231
|
// runProjectionsForEvent here is fine — nothing projects
|
|
232
232
|
// crypto-shredding:event:forget-denied.
|
|
233
|
-
await append(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
userId: event.user.id,
|
|
249
|
-
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
250
|
-
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
251
|
-
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
252
|
-
},
|
|
233
|
+
await append(denialAuditRunner, {
|
|
234
|
+
aggregateId: generateId(),
|
|
235
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
236
|
+
// MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
|
|
237
|
+
// bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
|
|
238
|
+
tenantId: event.user.tenantId,
|
|
239
|
+
expectedVersion: 0,
|
|
240
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
241
|
+
eventVersion: eventDef.version,
|
|
242
|
+
payload,
|
|
243
|
+
metadata: {
|
|
244
|
+
userId: event.user.id,
|
|
245
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
246
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
247
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
253
248
|
},
|
|
254
|
-
);
|
|
249
|
+
});
|
|
255
250
|
return null;
|
|
256
251
|
}
|
|
257
252
|
|
|
@@ -316,6 +311,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
316
311
|
subjectKey,
|
|
317
312
|
raw.kind,
|
|
318
313
|
tenantScopeDenial.error.code,
|
|
314
|
+
ctx.dbOutsideTransaction?.unsafeRaw(
|
|
315
|
+
"denial audit append: names the prober's own tenant stream on the outside-transaction db",
|
|
316
|
+
),
|
|
319
317
|
);
|
|
320
318
|
return auditFailure ?? tenantScopeDenial;
|
|
321
319
|
}
|
|
@@ -335,6 +333,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
335
333
|
subjectKey,
|
|
336
334
|
raw.kind,
|
|
337
335
|
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
336
|
+
ctx.dbOutsideTransaction?.unsafeRaw(
|
|
337
|
+
"denial audit append: names the prober's own tenant stream on the outside-transaction db",
|
|
338
|
+
),
|
|
338
339
|
);
|
|
339
340
|
return auditFailure ?? retentionDenial;
|
|
340
341
|
}
|
|
@@ -71,8 +71,14 @@ const propertyTable = buildEntityTable("property", propertyEntity);
|
|
|
71
71
|
// Host entity gets its own EXT_USER_DATA-registration too — that's the
|
|
72
72
|
// canonical setup. The host's anonymize hook NULLS inserted_by_id; the
|
|
73
73
|
// custom-fields wiring contributes export only (#972).
|
|
74
|
+
// fw#2914 — "property" is a managed r.entity table (EXECUTOR_ONLY-branded), so
|
|
75
|
+
// TenantDb's typed write methods reject it; these host hooks need raw SQL,
|
|
76
|
+
// still filtered by inserted_by_id + tenant_id.
|
|
77
|
+
const HOST_HOOK_REASON =
|
|
78
|
+
"fw#2914 test fixture: host export/delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
|
|
79
|
+
|
|
74
80
|
const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
75
|
-
const rows = await asRawClient(ctx.db).unsafe(
|
|
81
|
+
const rows = await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
76
82
|
`SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
77
83
|
[ctx.userId, ctx.tenantId],
|
|
78
84
|
);
|
|
@@ -86,13 +92,13 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
|
|
|
86
92
|
|
|
87
93
|
const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
88
94
|
if (strategy === "delete") {
|
|
89
|
-
await asRawClient(ctx.db).unsafe(
|
|
95
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
90
96
|
`DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
91
97
|
[ctx.userId, ctx.tenantId],
|
|
92
98
|
);
|
|
93
99
|
} else {
|
|
94
100
|
// anonymize: clear owner, keep row + customFields.
|
|
95
|
-
await asRawClient(ctx.db).unsafe(
|
|
101
|
+
await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
|
|
96
102
|
`UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
|
|
97
103
|
[ctx.userId, ctx.tenantId],
|
|
98
104
|
);
|
|
@@ -111,6 +117,7 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
|
|
|
111
117
|
r.useExtension(EXT_USER_DATA, "property", {
|
|
112
118
|
export: hostExportHook,
|
|
113
119
|
delete: hostDeleteHook,
|
|
120
|
+
escapeHatch: { reason: HOST_HOOK_REASON },
|
|
114
121
|
});
|
|
115
122
|
|
|
116
123
|
const { executor } = createEntityExecutor("property", propertyEntity);
|
|
@@ -43,19 +43,14 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
43
43
|
describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
44
44
|
test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
|
|
45
45
|
const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
|
|
46
|
-
const result = await selectSerializedFieldDefinition(
|
|
47
|
-
{ unsafeRaw: () => raw } as never,
|
|
48
|
-
"t1",
|
|
49
|
-
"entity",
|
|
50
|
-
"field",
|
|
51
|
-
);
|
|
46
|
+
const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
|
|
52
47
|
expect(result).toBe("sf1");
|
|
53
48
|
expect(raw.calls).toBe(2);
|
|
54
49
|
});
|
|
55
50
|
|
|
56
51
|
test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
|
|
57
52
|
const raw = fakeClient([closedConnectionError()], { n: 3 });
|
|
58
|
-
const result = await countTenantFieldDefinitions(
|
|
53
|
+
const result = await countTenantFieldDefinitions(raw, "t1");
|
|
59
54
|
expect(result).toBe(3);
|
|
60
55
|
expect(raw.calls).toBe(2);
|
|
61
56
|
});
|
|
@@ -90,9 +85,9 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
90
85
|
const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
|
|
91
86
|
serialized_field: "sf1",
|
|
92
87
|
});
|
|
93
|
-
await expect(
|
|
94
|
-
|
|
95
|
-
)
|
|
88
|
+
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toThrow(
|
|
89
|
+
"connection was closed",
|
|
90
|
+
);
|
|
96
91
|
expect(raw.calls).toBe(2);
|
|
97
92
|
});
|
|
98
93
|
});
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
4
|
export async function selectSerializedFieldDefinition(
|
|
5
|
-
|
|
5
|
+
runner: DbRunner,
|
|
6
6
|
tenantId: string,
|
|
7
7
|
entityName: string,
|
|
8
8
|
fieldKey: string,
|
|
9
9
|
): Promise<unknown | null> {
|
|
10
10
|
const rows = await unsafeReadRetrying(
|
|
11
|
-
|
|
12
|
-
"reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
13
|
-
),
|
|
11
|
+
runner,
|
|
14
12
|
"SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
|
|
15
13
|
[entityName, fieldKey, tenantId],
|
|
16
14
|
);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
|
-
export async function countTenantFieldDefinitions(
|
|
4
|
+
export async function countTenantFieldDefinitions(
|
|
5
|
+
runner: DbRunner,
|
|
6
|
+
tenantId: string,
|
|
7
|
+
): Promise<number> {
|
|
5
8
|
// Active definitions only — delete soft-deletes (the deterministic stream is
|
|
6
9
|
// kept so a re-define can restore it), so isDeleted rows must not consume quota.
|
|
7
10
|
const rowsResult = await unsafeReadRetrying(
|
|
8
|
-
|
|
9
|
-
"counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
10
|
-
),
|
|
11
|
+
runner,
|
|
11
12
|
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
12
13
|
[tenantId],
|
|
13
14
|
);
|
|
@@ -39,7 +39,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
39
39
|
const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
|
|
40
40
|
|
|
41
41
|
const accessCheck = await checkFieldAccessForWrite(
|
|
42
|
-
ctx.db,
|
|
42
|
+
ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
|
|
43
43
|
event.user.tenantId,
|
|
44
44
|
payload.entityName,
|
|
45
45
|
payload.fieldKey,
|
|
@@ -73,7 +73,10 @@ export function createDefineTenantFieldHandler(
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
if (limit !== undefined) {
|
|
76
|
-
const current = await countTenantFieldDefinitions(
|
|
76
|
+
const current = await countTenantFieldDefinitions(
|
|
77
|
+
ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
|
|
78
|
+
tenantId,
|
|
79
|
+
);
|
|
77
80
|
if (current >= limit) {
|
|
78
81
|
return failUnprocessable("cap_exceeded", {
|
|
79
82
|
capName: "customFields.fieldDefinition.count",
|
|
@@ -62,7 +62,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
62
62
|
const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
|
|
63
63
|
|
|
64
64
|
const loaded = await loadFieldDefinition(
|
|
65
|
-
ctx.db,
|
|
65
|
+
ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
|
|
66
66
|
event.user.tenantId,
|
|
67
67
|
payload.entityName,
|
|
68
68
|
payload.fieldKey,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Plus single-fetch loader so set-custom-field can run access-check AND
|
|
3
3
|
// value-validation off one DB read (no double fetch).
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
6
6
|
import { selectSerializedFieldDefinition } from "../db/queries/field-access";
|
|
7
7
|
import { parseSerializedField, type SerializedFieldShape } from "./parse-serialized-field";
|
|
8
8
|
|
|
@@ -22,12 +22,12 @@ export type LoadedFieldDefinition =
|
|
|
22
22
|
| { found: true; field: SerializedFieldShape | null };
|
|
23
23
|
|
|
24
24
|
export async function loadFieldDefinition(
|
|
25
|
-
|
|
25
|
+
runner: DbRunner,
|
|
26
26
|
tenantId: string,
|
|
27
27
|
entityName: string,
|
|
28
28
|
fieldKey: string,
|
|
29
29
|
): Promise<LoadedFieldDefinition> {
|
|
30
|
-
const serialized = await selectSerializedFieldDefinition(
|
|
30
|
+
const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
|
|
31
31
|
if (serialized === null) return { found: false };
|
|
32
32
|
// parseSerializedField throws on a #972 legacy `sensitive` definition —
|
|
33
33
|
// that's a corrupt/unprocessable row from this write-gate's perspective,
|
|
@@ -57,13 +57,13 @@ export function fieldWriteAccessDeniedRoles(
|
|
|
57
57
|
// Convenience wrapper retained for clear-custom-field (no value-validation
|
|
58
58
|
// needed there) — does the load + access-check in one call.
|
|
59
59
|
export async function checkFieldAccessForWrite(
|
|
60
|
-
|
|
60
|
+
runner: DbRunner,
|
|
61
61
|
tenantId: string,
|
|
62
62
|
entityName: string,
|
|
63
63
|
fieldKey: string,
|
|
64
64
|
userRoles: ReadonlyArray<string>,
|
|
65
65
|
): Promise<FieldAccessCheckResult> {
|
|
66
|
-
const loaded = await loadFieldDefinition(
|
|
66
|
+
const loaded = await loadFieldDefinition(runner, tenantId, entityName, fieldKey);
|
|
67
67
|
if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
|
|
68
68
|
if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
|
|
69
69
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// T1.5c — user-data-rights wiring for custom-fields.
|
|
2
2
|
|
|
3
|
-
import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
-
import type {
|
|
3
|
+
import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
5
|
import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
|
|
7
7
|
|
|
@@ -27,6 +27,41 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
|
|
|
27
27
|
return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
|
|
31
|
+
// table name (opts.entityTable varies per call site), so it can't go through
|
|
32
|
+
// TenantDb's typed method API (table param must be a known SchemaTable). The
|
|
33
|
+
// SQL text itself still filters `tenant_id = ctx.tenantId`, so this stays a
|
|
34
|
+
// single-tenant read — the escapeHatch is only for the raw-SQL mechanism, not
|
|
35
|
+
// for cross-tenant access.
|
|
36
|
+
const CUSTOM_FIELDS_EXPORT_REASON =
|
|
37
|
+
"custom-fields export reads a generic customFields column via raw SQL against a dynamic host table name, not expressible through TenantDb's typed method API; the query still filters tenant_id = ctx.tenantId";
|
|
38
|
+
|
|
39
|
+
async function exportCustomFields(
|
|
40
|
+
db: DbRunner,
|
|
41
|
+
ctx: UserDataHookCtx,
|
|
42
|
+
tableName: string,
|
|
43
|
+
opts: WireCustomFieldsUserDataRightsOptions,
|
|
44
|
+
): Promise<UserDataExportSnippet | null> {
|
|
45
|
+
const rows = await selectCustomFieldsHostRows(
|
|
46
|
+
db,
|
|
47
|
+
tableName,
|
|
48
|
+
opts.userIdColumn,
|
|
49
|
+
ctx.userId,
|
|
50
|
+
ctx.tenantId,
|
|
51
|
+
);
|
|
52
|
+
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
53
|
+
for (const raw of rows) {
|
|
54
|
+
const row = asCustomFieldsHostRow(raw);
|
|
55
|
+
if (!row) continue;
|
|
56
|
+
const customFields = row.customFields;
|
|
57
|
+
if (customFields && Object.keys(customFields).length > 0) {
|
|
58
|
+
snippetRows.push({ id: row.id, customFields });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (snippetRows.length === 0) return null;
|
|
62
|
+
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
63
|
+
}
|
|
64
|
+
|
|
30
65
|
// Export-only wiring: custom fields hold supplemental business data, not PII
|
|
31
66
|
// (#972) — there is nothing to redact on user-forget, only Art. 20 export.
|
|
32
67
|
export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
|
|
@@ -35,29 +70,10 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
|
|
|
35
70
|
): void {
|
|
36
71
|
const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
|
|
37
72
|
|
|
38
|
-
const exportHook: UserDataExportHook = async (ctx) => {
|
|
39
|
-
const rows = await selectCustomFieldsHostRows(
|
|
40
|
-
ctx.db,
|
|
41
|
-
tableName,
|
|
42
|
-
opts.userIdColumn,
|
|
43
|
-
ctx.userId,
|
|
44
|
-
ctx.tenantId,
|
|
45
|
-
);
|
|
46
|
-
const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
|
|
47
|
-
for (const raw of rows) {
|
|
48
|
-
const row = asCustomFieldsHostRow(raw);
|
|
49
|
-
if (!row) continue;
|
|
50
|
-
const customFields = row.customFields;
|
|
51
|
-
if (customFields && Object.keys(customFields).length > 0) {
|
|
52
|
-
snippetRows.push({ id: row.id, customFields });
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (snippetRows.length === 0) return null;
|
|
56
|
-
return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
|
|
57
|
-
};
|
|
58
|
-
|
|
59
73
|
// biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
|
|
60
74
|
r.useExtension(EXT_USER_DATA, opts.entityName, {
|
|
61
|
-
export:
|
|
75
|
+
export: async (ctx: UserDataHookCtx) =>
|
|
76
|
+
exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
|
|
77
|
+
escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
|
|
62
78
|
});
|
|
63
79
|
}
|