@cosmicdrift/kumiko-bundled-features 0.197.1 → 0.199.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 +7 -7
- package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +272 -0
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +31 -1
- package/src/auth-email-password/feature.ts +9 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +108 -35
- package/src/auth-email-password/handlers/login.write.ts +10 -5
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +5 -5
- package/src/auth-mfa/feature.ts +3 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.test.ts +87 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +14 -3
- package/src/compliance-profiles/feature.ts +18 -5
- package/src/compliance-profiles/handlers/needs-profile.query.ts +52 -32
- package/src/compliance-profiles/index.ts +1 -0
- package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +185 -0
- package/src/config/__tests__/require-system-db.test.ts +46 -0
- package/src/config/feature.ts +28 -0
- package/src/config/handlers/cascade.query.ts +2 -2
- package/src/config/handlers/readiness.query.ts +19 -6
- package/src/config/handlers/reencrypt.job.ts +79 -21
- package/src/config/handlers/values.query.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +22 -3
- package/src/delivery/__tests__/delivery-security.integration.test.ts +44 -1
- package/src/delivery/__tests__/delivery.integration.test.ts +96 -3
- package/src/delivery/feature.ts +13 -3
- package/src/delivery/handlers/log.query.ts +10 -4
- package/src/delivery/handlers/preferences.query.ts +14 -1
- package/src/delivery/handlers/set-preference.write.ts +12 -1
- package/src/delivery/index.ts +1 -1
- package/src/delivery/jobs.ts +20 -10
- package/src/derivatives-sharp/__tests__/render.test.ts +48 -0
- package/src/derivatives-sharp/render.ts +55 -15
- package/src/feature-toggles/__tests__/acknowledge-cross-tenant.test.ts +23 -0
- package/src/feature-toggles/constants.ts +6 -0
- package/src/feature-toggles/handlers/list.query.ts +12 -1
- package/src/feature-toggles/handlers/registered.query.ts +12 -1
- package/src/feature-toggles/handlers/set.write.ts +18 -3
- package/src/file-derivatives/__tests__/public-variant-query-gating.integration.test.ts +7 -1
- package/src/file-derivatives/feature.ts +12 -13
- package/src/file-derivatives/handlers/public-variant.query.ts +5 -1
- package/src/form-draft/__tests__/cleanup.integration.test.ts +22 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +17 -0
- package/src/form-draft/__tests__/limits.integration.test.ts +112 -0
- package/src/form-draft/constants.ts +13 -0
- package/src/form-draft/db/queries/cleanup.ts +4 -1
- package/src/form-draft/db/queries/draft-count.ts +20 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +20 -3
- package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +1 -0
- package/src/form-draft/handlers/cleanup.job.ts +6 -0
- package/src/form-draft/handlers/discard.write.ts +6 -0
- package/src/form-draft/handlers/save.write.ts +15 -2
- package/src/form-draft/schemas.ts +16 -2
- package/src/jobs/__tests__/jobs-feature.integration.test.ts +57 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +12 -3
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +47 -2
- package/src/jobs/handlers/detail.query.ts +7 -1
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +6 -3
- package/src/jobs/handlers/reindex-entity.job.ts +14 -10
- package/src/jobs/handlers/retry.write.ts +7 -1
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +14 -1
- package/src/personal-access-tokens/feature.ts +4 -1
- package/src/personal-access-tokens/roles.ts +11 -3
- package/src/readiness/handlers/status.query.ts +2 -1
- package/src/sessions/__tests__/membership-revoke.integration.test.ts +195 -0
- package/src/sessions/feature.ts +18 -10
- package/src/sessions/handlers/revoke-all-for-user.write.ts +12 -5
- package/src/sessions/index.ts +1 -1
- package/src/sessions/session-callbacks.ts +7 -1
- package/src/tenant/__tests__/update-cross-tenant.integration.test.ts +83 -0
- package/src/tenant/handlers/remove-member.write.ts +23 -1
- package/src/tenant/handlers/update-member-roles.write.ts +23 -1
- package/src/tenant/handlers/update.write.ts +35 -2
- package/src/tenant/invitation-table.ts +8 -1
- package/src/user/__tests__/user.integration.test.ts +21 -0
- package/src/user/handlers/create.write.ts +10 -3
- package/src/user/handlers/find-for-auth.query.ts +9 -1
- package/src/user/handlers/me.query.ts +10 -1
- package/src/user/handlers/update.write.ts +12 -2
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +6 -6
- package/src/user-data-rights/feature.ts +10 -7
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +35 -28
|
@@ -71,7 +71,14 @@ export const tenantInvitationEntity = createEntity({
|
|
|
71
71
|
}),
|
|
72
72
|
// userId des einladenden Admins (für Audit-Trail "wer hat eingeladen").
|
|
73
73
|
// Self-referencing ownerField: the field's own value IS the owner userId.
|
|
74
|
-
|
|
74
|
+
// lookupable: the delete-hook filters "invitations sent by this user"
|
|
75
|
+
// via the generated blind-index column instead of a full-tenant
|
|
76
|
+
// decrypt-and-scan (see user-data-rights-defaults' invitation hook).
|
|
77
|
+
invitedBy: createTextField({
|
|
78
|
+
required: true,
|
|
79
|
+
userOwned: { ownerField: "invitedBy" },
|
|
80
|
+
lookupable: true,
|
|
81
|
+
}),
|
|
75
82
|
// UI-Anzeige — Wahrheit liegt in Redis-TTL.
|
|
76
83
|
expiresAt: createTimestampField({ required: true }),
|
|
77
84
|
},
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import {
|
|
4
5
|
createTestUser,
|
|
5
6
|
setupTestStack,
|
|
6
7
|
type TestStack,
|
|
7
8
|
TestUsers,
|
|
9
|
+
testTenantId,
|
|
8
10
|
unsafeCreateEntityTable,
|
|
9
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
12
|
import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
@@ -151,6 +153,25 @@ describe("scenario 2b: find-for-auth is system-only", () => {
|
|
|
151
153
|
expect(body).toContain("access_denied");
|
|
152
154
|
expect(body).not.toContain("must-never-leave-the-server");
|
|
153
155
|
});
|
|
156
|
+
|
|
157
|
+
// The "system" role reaches find-for-auth from any tenant — user rows are
|
|
158
|
+
// tenant-agnostic identity records, not scoped to the caller's own tenant.
|
|
159
|
+
test("system caller from a different tenant still resolves the user", async () => {
|
|
160
|
+
const created = await seedUser({
|
|
161
|
+
email: "cross-tenant@example.com",
|
|
162
|
+
displayName: "CrossTenant",
|
|
163
|
+
passwordHash: "cross-tenant-hash",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const foreignSystemCaller = createSystemUser(testTenantId(99));
|
|
167
|
+
const result = await stack.http.queryOk<{ id: number } | null>(
|
|
168
|
+
UserQueries.findForAuth,
|
|
169
|
+
{ email: "cross-tenant@example.com" },
|
|
170
|
+
foreignSystemCaller,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(result).toMatchObject({ id: created.id });
|
|
174
|
+
});
|
|
154
175
|
});
|
|
155
176
|
|
|
156
177
|
// --- Scenario 3: user edits own profile, email/passwordHash are system-locked ---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
-
import { ConflictError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { ConflictError, InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { UserErrors } from "../constants";
|
|
@@ -35,7 +35,14 @@ export const createWrite = defineWriteHandler({
|
|
|
35
35
|
}),
|
|
36
36
|
access: { roles: ["system", "SystemAdmin"] },
|
|
37
37
|
handler: async (event, ctx) => {
|
|
38
|
-
|
|
38
|
+
if (!ctx.systemDb) {
|
|
39
|
+
throw new InternalError({ message: "user:create requires r.systemScope()" });
|
|
40
|
+
}
|
|
41
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
42
|
+
"user rows are tenant-agnostic identity records; uniqueness check and create need no tenant filter",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const existing = await fetchOne<{ id: string }>(db, userTable, {
|
|
39
46
|
email: event.payload.email,
|
|
40
47
|
});
|
|
41
48
|
|
|
@@ -49,6 +56,6 @@ export const createWrite = defineWriteHandler({
|
|
|
49
56
|
);
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
return crud.create(event.payload, event.user,
|
|
59
|
+
return crud.create(event.payload, event.user, db);
|
|
53
60
|
},
|
|
54
61
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { userTable } from "../schema/user";
|
|
5
6
|
|
|
@@ -33,6 +34,13 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
33
34
|
? { email: query.payload.email }
|
|
34
35
|
: { id: query.payload.id as string }; // @cast-boundary engine-payload
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
if (!ctx.systemDb) {
|
|
38
|
+
throw new InternalError({ message: "user:find-for-auth requires r.systemScope()" });
|
|
39
|
+
}
|
|
40
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
41
|
+
"identity resolution for auth is intentionally cross-tenant",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return (await fetchOne(db, userTable, where)) ?? null;
|
|
37
45
|
},
|
|
38
46
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { userEntity, userTable } from "../schema/user";
|
|
5
6
|
|
|
@@ -11,5 +12,13 @@ export const meQuery = defineQueryHandler({
|
|
|
11
12
|
name: "user:me",
|
|
12
13
|
schema: z.object({}),
|
|
13
14
|
access: { openToAll: true },
|
|
14
|
-
handler: async (query, ctx) =>
|
|
15
|
+
handler: async (query, ctx) => {
|
|
16
|
+
if (!ctx.systemDb) {
|
|
17
|
+
throw new InternalError({ message: "user:me requires r.systemScope()" });
|
|
18
|
+
}
|
|
19
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
20
|
+
"user rows are tenant-agnostic identity records; self-lookup by id needs no tenant filter",
|
|
21
|
+
);
|
|
22
|
+
return crud.detail({ id: query.user.id }, query.user, db); // @wrapper-known semantic-alias
|
|
23
|
+
},
|
|
15
24
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
InternalError,
|
|
6
|
+
writeFailure,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
4
8
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
5
9
|
import { z } from "zod";
|
|
6
10
|
import { UserErrors } from "../constants";
|
|
@@ -51,6 +55,12 @@ export const updateWrite = defineWriteHandler({
|
|
|
51
55
|
}),
|
|
52
56
|
);
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
if (!ctx.systemDb) {
|
|
59
|
+
throw new InternalError({ message: "user:update requires r.systemScope()" });
|
|
60
|
+
}
|
|
61
|
+
const db = ctx.systemDb.acknowledgeCrossTenant(
|
|
62
|
+
"user rows are tenant-agnostic identity records; self/admin update needs no tenant filter",
|
|
63
|
+
);
|
|
64
|
+
return crud.update(event.payload, event.user, db);
|
|
55
65
|
},
|
|
56
66
|
});
|
|
@@ -489,11 +489,10 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
489
489
|
);
|
|
490
490
|
expect(foreignTenantResult.isSuccess).toBe(true);
|
|
491
491
|
|
|
492
|
-
// Pins the hook boundary
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
// though the runUserExport() assertion below would stay green either way.
|
|
492
|
+
// Pins the hook boundary: invitation `email` is subject-KMS PII, not a
|
|
493
|
+
// field-encrypted column, so decryptSnippetFields would leave ciphertext
|
|
494
|
+
// in the GDPR bundle. The hook decrypts locally (same as invitations
|
|
495
|
+
// query) — assert plaintext here so a regression to raw ciphertext fails.
|
|
497
496
|
const hookCtx: UserDataHookCtx = {
|
|
498
497
|
db: stack.db,
|
|
499
498
|
registry: stack.registry,
|
|
@@ -501,7 +500,8 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
501
500
|
userId: ALICE_ID,
|
|
502
501
|
};
|
|
503
502
|
const rawSnippet = await tenantInvitationExportHook(hookCtx);
|
|
504
|
-
expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(
|
|
503
|
+
expect(isPiiCiphertext(rawSnippet?.rows[0]?.["email"])).toBe(false);
|
|
504
|
+
expect(rawSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
|
|
505
505
|
|
|
506
506
|
const bundle = await runUserExport({
|
|
507
507
|
db: stack.db,
|
|
@@ -281,18 +281,21 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
281
281
|
|
|
282
282
|
r.translations({ keys: USER_DATA_RIGHTS_I18N });
|
|
283
283
|
|
|
284
|
-
// Dormant
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
284
|
+
// Dormant self-service screen (Art. 15/17/18/20): export, activity log,
|
|
285
|
+
// restriction, deletion in one screen. No r.nav — the app places it in
|
|
286
|
+
// its logged-in area. The React component comes from userDataRightsClient()
|
|
287
|
+
// (web/) client-side. access is openToAll because no app role name is
|
|
288
|
+
// portable; the per-user handlers enforce auth server-side, and the
|
|
289
|
+
// screen is invisible without r.nav until the app actively links it in
|
|
290
|
+
// its authed area. dormant: true so createKumikoApp's boot diagnostic
|
|
291
|
+
// (#2025) doesn't flag apps that haven't (yet) navved the screen as
|
|
292
|
+
// having a missing client plugin (#2034).
|
|
291
293
|
r.screen({
|
|
292
294
|
id: PRIVACY_CENTER_SCREEN_ID,
|
|
293
295
|
type: "custom",
|
|
294
296
|
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
295
297
|
access: { openToAll: true },
|
|
298
|
+
dormant: true,
|
|
296
299
|
});
|
|
297
300
|
|
|
298
301
|
// Magic-link path (anonymous): the email link carries the token as a
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type UserDataExportHook,
|
|
7
7
|
type UserDataHookCtx,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
-
import { decryptStoredPii } from "../../shared";
|
|
9
|
+
import { decryptStoredPii, mapWithConcurrency } from "../../shared";
|
|
10
10
|
import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
|
|
11
11
|
import { userTable } from "../../user";
|
|
12
12
|
import { featureMounted } from "./feature-mounted";
|
|
@@ -34,6 +34,11 @@ const crud = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEn
|
|
|
34
34
|
|
|
35
35
|
const INVITED_BY_ANONYMIZED = "anonymized";
|
|
36
36
|
|
|
37
|
+
// Bounded, not Promise.all — each decrypt hits the KMS adapter's own small
|
|
38
|
+
// dedicated pool (PgKmsAdapter default max: 4). Same reasoning as
|
|
39
|
+
// tenant/handlers/invitations.query.ts's KMS_POOL_CONCURRENCY.
|
|
40
|
+
const KMS_POOL_CONCURRENCY = 4;
|
|
41
|
+
|
|
37
42
|
async function resolveUserEmail(ctx: UserDataHookCtx): Promise<string | null> {
|
|
38
43
|
if (ctx.userEmailBeforeDelete) return ctx.userEmailBeforeDelete.toLowerCase();
|
|
39
44
|
const row = (await fetchOne(ctx.db, userTable, { id: ctx.userId })) as {
|
|
@@ -59,12 +64,23 @@ export const tenantInvitationExportHook: UserDataExportHook = async (ctx) => {
|
|
|
59
64
|
if (rows.length === 0) return null;
|
|
60
65
|
return {
|
|
61
66
|
entity: "tenant-invitation",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Stored `email` is subject-KMS ciphertext (pii:true, not field-encrypted).
|
|
68
|
+
// decryptSnippetFields only sweeps collectEncryptedFieldNames — it would
|
|
69
|
+
// leave this as a base64 blob in the GDPR export. Decrypt here, same as
|
|
70
|
+
// invitations.query.ts and resolveUserEmail above.
|
|
71
|
+
rows: await mapWithConcurrency(rows, KMS_POOL_CONCURRENCY, async (r) => {
|
|
72
|
+
const rawEmail = r["email"];
|
|
73
|
+
const decryptedEmail =
|
|
74
|
+
typeof rawEmail === "string"
|
|
75
|
+
? await decryptStoredPii(rawEmail, "email", "user-data-rights:invitation-hook")
|
|
76
|
+
: rawEmail;
|
|
77
|
+
return {
|
|
78
|
+
email: decryptedEmail,
|
|
79
|
+
role: r["role"],
|
|
80
|
+
status: r["status"],
|
|
81
|
+
expiresAt: String(r["expiresAt"] ?? ""),
|
|
82
|
+
};
|
|
83
|
+
}),
|
|
68
84
|
};
|
|
69
85
|
};
|
|
70
86
|
|
|
@@ -98,28 +114,19 @@ export const tenantInvitationDeleteHook: UserDataDeleteHook = async (ctx, strate
|
|
|
98
114
|
}
|
|
99
115
|
}
|
|
100
116
|
|
|
101
|
-
// `invitedBy`
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
ctx.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
);
|
|
113
|
-
for (const row of tenantInvitations) {
|
|
117
|
+
// `invitedBy` is `lookupable` — the query compiler matches the plaintext
|
|
118
|
+
// filter value against the generated blind-index column
|
|
119
|
+
// (`invitedByBidx`), so this selects only this user's own invites-sent
|
|
120
|
+
// without loading + decrypting every invitation in the tenant. See
|
|
121
|
+
// tenant/handlers/invitations.query.ts for the same lookupable-filter
|
|
122
|
+
// pattern on `email`.
|
|
123
|
+
const inviterRows = await selectMany<Record<string, unknown>>(ctx.db, tenantInvitationsTable, {
|
|
124
|
+
tenantId: ctx.tenantId,
|
|
125
|
+
invitedBy: ctx.userId,
|
|
126
|
+
});
|
|
127
|
+
for (const row of inviterRows) {
|
|
114
128
|
const id = row["id"]; // @cast-boundary db-row
|
|
115
|
-
|
|
116
|
-
if (typeof id !== "string" || typeof invitedByRaw !== "string") continue;
|
|
117
|
-
const invitedBy = await decryptStoredPii(
|
|
118
|
-
invitedByRaw,
|
|
119
|
-
"invitedBy",
|
|
120
|
-
"user-data-rights:invitation-hook",
|
|
121
|
-
);
|
|
122
|
-
if (invitedBy !== ctx.userId) continue;
|
|
129
|
+
if (typeof id !== "string") continue;
|
|
123
130
|
await crud.update({ id, changes: { invitedBy: INVITED_BY_ANONYMIZED } }, systemUser, tdb, {
|
|
124
131
|
skipOptimisticLock: true,
|
|
125
132
|
});
|