@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.151.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 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +6 -1
- package/src/auth-email-password/seeding.ts +1 -1
- package/src/auth-email-password/web/auth-gate.tsx +65 -20
- package/src/auth-email-password/web/index.ts +2 -4
- package/src/auth-email-password/web/login-screen.tsx +2 -0
- package/src/auth-mfa/__tests__/base32.test.ts +28 -0
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
- package/src/auth-mfa/__tests__/totp.test.ts +11 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
- package/src/auth-mfa/base32.ts +7 -1
- package/src/auth-mfa/constants.ts +14 -0
- package/src/auth-mfa/errors.ts +9 -16
- package/src/auth-mfa/feature.ts +5 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
- package/src/auth-mfa/handlers/verify.write.ts +26 -3
- package/src/auth-mfa/index.ts +0 -1
- package/src/auth-mfa/recovery-codes.ts +12 -2
- package/src/auth-mfa/schema/user-mfa.ts +3 -0
- package/src/auth-mfa/testing.ts +5 -0
- package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +44 -28
- package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
- package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
- package/src/config/__tests__/config.integration.test.ts +50 -2
- package/src/config/handlers/cascade.query.ts +2 -1
- package/src/config/handlers/values.query.ts +2 -1
- package/src/file-foundation/__tests__/feature.test.ts +4 -4
- package/src/file-foundation/feature.ts +10 -11
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
- package/src/legal-pages/feature.ts +2 -1
- package/src/mail-foundation/__tests__/feature.test.ts +4 -5
- package/src/mail-foundation/feature.ts +21 -22
- package/src/managed-pages/feature.ts +1 -6
- package/src/seo/feature.ts +1 -6
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
- package/src/sessions/__tests__/test-helpers.ts +1 -1
- package/src/sessions/constants.ts +5 -0
- package/src/sessions/feature.ts +67 -1
- package/src/sessions/handlers/detail.query.ts +36 -0
- package/src/sessions/i18n.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/system-query.ts +5 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
- package/src/user-data-rights/feature.ts +7 -8
- package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
- package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
- package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
- package/src/user-profile/handlers/change-email.write.ts +2 -2
- package/src/auth-email-password/password-hashing.ts +0 -4
|
@@ -17,6 +17,8 @@ export const SessionQueries = {
|
|
|
17
17
|
// Admin-scoped: all sessions in the caller's tenant (live + revoked).
|
|
18
18
|
// Tenant isolation comes from ctx.db; access-gate is admin-or-higher.
|
|
19
19
|
list: "sessions:query:user-session:list",
|
|
20
|
+
// Admin-scoped single-session inspector, backs the session-detail screen.
|
|
21
|
+
detail: "sessions:query:user-session:detail",
|
|
20
22
|
} as const;
|
|
21
23
|
|
|
22
24
|
export const SessionErrors = {
|
|
@@ -45,3 +47,6 @@ export const DEFAULT_SESSION_CACHE_TTL_MS = 60_000;
|
|
|
45
47
|
// longer so refresh can rotate the token without requiring a new password).
|
|
46
48
|
// MVP ships a single window; per-app overrides can come later.
|
|
47
49
|
export const DEFAULT_SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
export const SESSION_LIST_SCREEN_ID = "session-list" as const;
|
|
52
|
+
export const SESSION_DETAIL_SCREEN_ID = "session-detail" as const;
|
package/src/sessions/feature.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
defineFeature,
|
|
5
|
+
type FeatureDefinition,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { SESSION_DETAIL_SCREEN_ID, SESSION_LIST_SCREEN_ID, SessionQueries } from "./constants";
|
|
3
8
|
import { cleanupJob } from "./handlers/cleanup.job";
|
|
9
|
+
import { detailQuery } from "./handlers/detail.query";
|
|
4
10
|
import { listQuery } from "./handlers/list.query";
|
|
5
11
|
import { mineQuery } from "./handlers/mine.query";
|
|
6
12
|
import { revokeWrite } from "./handlers/revoke.write";
|
|
7
13
|
import { revokeAllForUserWrite } from "./handlers/revoke-all-for-user.write";
|
|
8
14
|
import { revokeAllOthersWrite } from "./handlers/revoke-all-others.write";
|
|
15
|
+
import { SESSIONS_I18N } from "./i18n";
|
|
9
16
|
import { userSessionEntity } from "./schema/user-session";
|
|
10
17
|
import type { SessionAllOthersRevoker, SessionMassRevoker } from "./session-callbacks";
|
|
11
18
|
|
|
@@ -105,6 +112,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
105
112
|
const queries = {
|
|
106
113
|
mine: r.queryHandler(mineQuery),
|
|
107
114
|
list: r.queryHandler(listQuery),
|
|
115
|
+
detail: r.queryHandler(detailQuery),
|
|
108
116
|
};
|
|
109
117
|
|
|
110
118
|
// Retention: chunked DELETE of expired/revoked rows. Manual trigger
|
|
@@ -143,6 +151,64 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
143
151
|
autoRevoke ??= revoker;
|
|
144
152
|
};
|
|
145
153
|
|
|
154
|
+
r.translations({ keys: SESSIONS_I18N });
|
|
155
|
+
|
|
156
|
+
const listAccess = { roles: access.admin };
|
|
157
|
+
|
|
158
|
+
r.screen({
|
|
159
|
+
id: SESSION_LIST_SCREEN_ID,
|
|
160
|
+
type: "projectionList",
|
|
161
|
+
query: SessionQueries.list,
|
|
162
|
+
columns: [
|
|
163
|
+
{ field: "id", label: "sessions.list.col.id" },
|
|
164
|
+
{ field: "userId", label: "sessions.list.col.userId" },
|
|
165
|
+
{ field: "createdAt", label: "sessions.list.col.createdAt" },
|
|
166
|
+
{ field: "expiresAt", label: "sessions.list.col.expiresAt" },
|
|
167
|
+
{ field: "revokedAt", label: "sessions.list.col.revokedAt" },
|
|
168
|
+
],
|
|
169
|
+
rowActions: [
|
|
170
|
+
{
|
|
171
|
+
kind: "navigate",
|
|
172
|
+
id: "open",
|
|
173
|
+
label: "sessions.list.action.open",
|
|
174
|
+
screen: SESSION_DETAIL_SCREEN_ID,
|
|
175
|
+
entityId: "id",
|
|
176
|
+
rowClick: true,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
access: listAccess,
|
|
180
|
+
});
|
|
181
|
+
r.screen({
|
|
182
|
+
id: SESSION_DETAIL_SCREEN_ID,
|
|
183
|
+
type: "projectionDetail",
|
|
184
|
+
query: SessionQueries.detail,
|
|
185
|
+
listScreenId: SESSION_LIST_SCREEN_ID,
|
|
186
|
+
layout: {
|
|
187
|
+
sections: [
|
|
188
|
+
{
|
|
189
|
+
fields: ["id", "userId", "createdAt", "expiresAt", "revokedAt", "ip", "userAgent"],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
fieldLabels: {
|
|
194
|
+
id: "sessions.detail.field.id",
|
|
195
|
+
userId: "sessions.detail.field.userId",
|
|
196
|
+
createdAt: "sessions.detail.field.createdAt",
|
|
197
|
+
expiresAt: "sessions.detail.field.expiresAt",
|
|
198
|
+
revokedAt: "sessions.detail.field.revokedAt",
|
|
199
|
+
ip: "sessions.detail.field.ip",
|
|
200
|
+
userAgent: "sessions.detail.field.userAgent",
|
|
201
|
+
},
|
|
202
|
+
access: listAccess,
|
|
203
|
+
});
|
|
204
|
+
r.nav({
|
|
205
|
+
id: "session-list",
|
|
206
|
+
label: "sessions:nav.sessionList",
|
|
207
|
+
icon: "list",
|
|
208
|
+
screen: "sessions:screen:session-list",
|
|
209
|
+
order: 10,
|
|
210
|
+
});
|
|
211
|
+
|
|
146
212
|
return { handlers, queries, bindAutoRevokeOnPasswordChange };
|
|
147
213
|
});
|
|
148
214
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { decryptStoredPii } from "../../shared";
|
|
5
|
+
import { userSessionTable } from "../schema/user-session";
|
|
6
|
+
|
|
7
|
+
// Admin single-session inspector — mirrors list.query's decrypt handling for
|
|
8
|
+
// the one-row case. ctx.db (TenantDb) applies tenant-scoping automatically.
|
|
9
|
+
export const detailQuery = defineQueryHandler({
|
|
10
|
+
name: "user-session:detail",
|
|
11
|
+
schema: z.object({ id: z.uuid() }),
|
|
12
|
+
access: { roles: access.admin },
|
|
13
|
+
handler: async (query, ctx) => {
|
|
14
|
+
const row = await fetchOne<{
|
|
15
|
+
id: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
createdAt: unknown;
|
|
18
|
+
expiresAt: unknown;
|
|
19
|
+
revokedAt: unknown;
|
|
20
|
+
ip: string | null;
|
|
21
|
+
userAgent: string | null;
|
|
22
|
+
}>(ctx.db, userSessionTable, { id: query.payload.id });
|
|
23
|
+
if (!row) return null;
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
userId: row.userId,
|
|
27
|
+
createdAt: row.createdAt,
|
|
28
|
+
expiresAt: row.expiresAt,
|
|
29
|
+
revokedAt: row.revokedAt,
|
|
30
|
+
ip: row.ip ? await decryptStoredPii(row.ip, "sessions:detail") : row.ip,
|
|
31
|
+
userAgent: row.userAgent
|
|
32
|
+
? await decryptStoredPii(row.userAgent, "sessions:detail")
|
|
33
|
+
: row.userAgent,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server + client i18n for the sessions operator screens.
|
|
3
|
+
|
|
4
|
+
type LocalizedString = { readonly de: string; readonly en: string };
|
|
5
|
+
|
|
6
|
+
export const SESSIONS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"screen:session-list.title": { de: "Sitzungen", en: "Sessions" },
|
|
8
|
+
"screen:session-detail.title": { de: "Sitzung", en: "Session" },
|
|
9
|
+
"sessions:nav.sessionList": { de: "Sitzungen", en: "Sessions" },
|
|
10
|
+
"sessions.list.col.id": { de: "ID", en: "ID" },
|
|
11
|
+
"sessions.list.col.userId": { de: "User-ID", en: "User ID" },
|
|
12
|
+
"sessions.list.col.createdAt": { de: "Erstellt", en: "Created" },
|
|
13
|
+
"sessions.list.col.expiresAt": { de: "Läuft ab", en: "Expires" },
|
|
14
|
+
"sessions.list.col.revokedAt": { de: "Widerrufen", en: "Revoked" },
|
|
15
|
+
"sessions.list.action.open": { de: "Details", en: "Details" },
|
|
16
|
+
"sessions.detail.field.id": { de: "ID", en: "ID" },
|
|
17
|
+
"sessions.detail.field.userId": { de: "User-ID", en: "User ID" },
|
|
18
|
+
"sessions.detail.field.createdAt": { de: "Erstellt", en: "Created" },
|
|
19
|
+
"sessions.detail.field.expiresAt": { de: "Läuft ab", en: "Expires" },
|
|
20
|
+
"sessions.detail.field.revokedAt": { de: "Widerrufen", en: "Revoked" },
|
|
21
|
+
"sessions.detail.field.ip": { de: "IP-Adresse", en: "IP address" },
|
|
22
|
+
"sessions.detail.field.userAgent": { de: "User-Agent", en: "User agent" },
|
|
23
|
+
};
|
package/src/shared/index.ts
CHANGED
|
@@ -9,4 +9,5 @@ export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
|
9
9
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
10
10
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
11
11
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
12
|
+
export type { SystemQueryFn } from "./system-query";
|
|
12
13
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Minimal shape of the httpRoute handler's `{ systemQuery }` dep — mirrors
|
|
2
|
+
// http-route.ts's signature. Not importing HttpRouteHandlerDeps itself: it
|
|
3
|
+
// isn't part of the engine's public surface (only the httpRoute-
|
|
4
|
+
// registration types are).
|
|
5
|
+
export type SystemQueryFn = (type: string, payload: unknown, tenantId: string) => Promise<unknown>;
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
import { expectErrorIncludes, rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
15
15
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
16
16
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
17
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
18
17
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
19
18
|
import { createConfigFeature } from "../../config";
|
|
20
19
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -23,6 +22,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
|
|
|
23
22
|
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
24
23
|
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
25
24
|
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
25
|
+
import { hashPassword } from "../../shared";
|
|
26
26
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
27
27
|
import { createUserFeature } from "../../user/feature";
|
|
28
28
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// #1057 regression: anonymous GDPR magic-link download under
|
|
2
|
+
// `resolverTrust: "authoritative"` WITHOUT `defaultTenantId` (publicstatus's
|
|
3
|
+
// real prod config — Host-Resolver-only, no single-tenant fallback).
|
|
4
|
+
//
|
|
5
|
+
// The tenantResolver derives the ambient tenant from the request Host — here
|
|
6
|
+
// it always resolves to `hostTenant`, DIFFERENT from the tenant the export
|
|
7
|
+
// job actually belongs to (`jobTenant`). Before the fix, `download-by-token`
|
|
8
|
+
// resolved the file-storage-provider *selection* via `ctx.config`, which is
|
|
9
|
+
// bound to the ambient (resolved) tenant — `hostTenant` has no provider
|
|
10
|
+
// configured at all here, so the download 500s even though the token/job
|
|
11
|
+
// themselves are perfectly valid. The fix resolves the provider explicitly
|
|
12
|
+
// for `jobRow.requestedFromTenantId` instead.
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { randomBytes } from "node:crypto";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createInMemoryFileProvider,
|
|
19
|
+
type FileStorageProvider,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
unsafePushTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
30
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
31
|
+
import {
|
|
32
|
+
createComplianceProfilesFeature,
|
|
33
|
+
tenantComplianceProfileEntity,
|
|
34
|
+
} from "../../compliance-profiles";
|
|
35
|
+
import { createConfigFeature } from "../../config";
|
|
36
|
+
import { ConfigHandlers } from "../../config/constants";
|
|
37
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
38
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
39
|
+
import { configValuesTable } from "../../config/table";
|
|
40
|
+
import { createDataRetentionFeature } from "../../data-retention";
|
|
41
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
42
|
+
import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
|
|
43
|
+
import { createSessionsFeature } from "../../sessions";
|
|
44
|
+
import { createUserFeature } from "../../user";
|
|
45
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
46
|
+
import { runExportJobs } from "../run-export-jobs";
|
|
47
|
+
import { exportDownloadTokenEntity } from "../schema/download-token";
|
|
48
|
+
import { exportJobEntity } from "../schema/export-job";
|
|
49
|
+
|
|
50
|
+
const jobTenant = testTenantId(1);
|
|
51
|
+
const hostTenant = testTenantId(2);
|
|
52
|
+
const jobTenantAdmin = createTestUser({ id: 99, tenantId: jobTenant, roles: ["TenantAdmin"] });
|
|
53
|
+
const aliceUser = createTestUser({ id: 42, tenantId: jobTenant, roles: ["Member"] });
|
|
54
|
+
|
|
55
|
+
const providerPerTenant = new Map<string, ReturnType<typeof createInMemoryFileProvider>>();
|
|
56
|
+
function buildProvider(tenantId: string): Promise<FileStorageProvider> {
|
|
57
|
+
let p = providerPerTenant.get(tenantId);
|
|
58
|
+
if (!p) {
|
|
59
|
+
p = createInMemoryFileProvider();
|
|
60
|
+
providerPerTenant.set(tenantId, p);
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve(p);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let stack: TestStack;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
69
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
70
|
+
|
|
71
|
+
stack = await setupTestStack({
|
|
72
|
+
features: [
|
|
73
|
+
createConfigFeature(),
|
|
74
|
+
createUserFeature(),
|
|
75
|
+
createDataRetentionFeature(),
|
|
76
|
+
createComplianceProfilesFeature(),
|
|
77
|
+
fileFoundationFeature,
|
|
78
|
+
fileProviderInMemoryFeature,
|
|
79
|
+
createSessionsFeature(),
|
|
80
|
+
createUserDataRightsFeature(),
|
|
81
|
+
],
|
|
82
|
+
extraContext: ({ registry }) => ({
|
|
83
|
+
configResolver: resolver,
|
|
84
|
+
configEncryption: encryption,
|
|
85
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
86
|
+
}),
|
|
87
|
+
// publicstatus prod: Host-Resolver-only, NO defaultTenantId. Resolver
|
|
88
|
+
// always answers `hostTenant` here — jobTenant never appears as the
|
|
89
|
+
// ambient/resolved tenant, only ever as jobRow.requestedFromTenantId.
|
|
90
|
+
anonymousAccess: {
|
|
91
|
+
tenantResolver: () => hostTenant,
|
|
92
|
+
resolverTrust: "authoritative",
|
|
93
|
+
tenantExists: async (id) => id === jobTenant || id === hostTenant,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await unsafeCreateEntityTable(stack.db, exportJobEntity);
|
|
97
|
+
await unsafeCreateEntityTable(stack.db, exportDownloadTokenEntity);
|
|
98
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
99
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
100
|
+
await createEventsTable(stack.db);
|
|
101
|
+
await asRawClient(stack.db).unsafe(`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
103
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
104
|
+
tenant_id UUID NOT NULL,
|
|
105
|
+
user_id TEXT NOT NULL,
|
|
106
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
108
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
109
|
+
inserted_by_id TEXT,
|
|
110
|
+
modified_by_id TEXT,
|
|
111
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
112
|
+
deleted_at TIMESTAMPTZ,
|
|
113
|
+
deleted_by_id TEXT,
|
|
114
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
115
|
+
UNIQUE(user_id, tenant_id)
|
|
116
|
+
)
|
|
117
|
+
`);
|
|
118
|
+
|
|
119
|
+
// Only jobTenant gets a provider configured — hostTenant deliberately has
|
|
120
|
+
// NONE, so a pre-fix ambient-tenant read would hard-fail with "no provider
|
|
121
|
+
// selected", not just pick the wrong one.
|
|
122
|
+
await stack.http.writeOk(
|
|
123
|
+
ConfigHandlers.set,
|
|
124
|
+
{ key: "file-foundation:config:provider", value: "inmemory" },
|
|
125
|
+
jobTenantAdmin,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterAll(async () => {
|
|
130
|
+
await stack.cleanup();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("download-by-token under resolverTrust: authoritative, no defaultTenantId", () => {
|
|
134
|
+
test("magic-link download succeeds via the job's own tenant, not the Host-resolved ambient tenant", async () => {
|
|
135
|
+
const requestRes = await stack.http.writeOk<{ jobId: string }>(
|
|
136
|
+
"user-data-rights:write:request-export",
|
|
137
|
+
{},
|
|
138
|
+
aliceUser,
|
|
139
|
+
);
|
|
140
|
+
const jobId = requestRes.jobId;
|
|
141
|
+
const provider = await buildProvider(jobTenant);
|
|
142
|
+
await provider.write(`${jobTenant}/exports/${jobId}.zip`, new Uint8Array([1, 2, 3]));
|
|
143
|
+
|
|
144
|
+
const result = await runExportJobs({
|
|
145
|
+
db: stack.db,
|
|
146
|
+
registry: stack.registry,
|
|
147
|
+
buildStorageProvider: buildProvider,
|
|
148
|
+
now: getTemporal().Now.instant(),
|
|
149
|
+
});
|
|
150
|
+
const plainToken = result.tokenByJobId.get(jobId);
|
|
151
|
+
if (!plainToken) {
|
|
152
|
+
throw new Error("token-create failed in worker run");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const res = await stack.app.fetch(
|
|
156
|
+
new Request(`http://test/user-export/by-token?token=${plainToken}`, { method: "GET" }),
|
|
157
|
+
);
|
|
158
|
+
expect(res.status).toBe(302);
|
|
159
|
+
const location = res.headers.get("location");
|
|
160
|
+
expect(location).toMatch(/^memory:\/\//);
|
|
161
|
+
expect(location).toContain(`${jobTenant}/exports/${jobId}.zip`);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -559,4 +559,43 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
|
559
559
|
);
|
|
560
560
|
expect(result.url).toMatch(/^memory:\/\//);
|
|
561
561
|
});
|
|
562
|
+
|
|
563
|
+
test("cross-tenant same-user: provider is resolved for the job's tenant, not the ambient session tenant", async () => {
|
|
564
|
+
// #1057 regression: createFileProviderForTenant used to read the
|
|
565
|
+
// provider *selection* from the ambient session's ctx.config — here
|
|
566
|
+
// Tenant B (Alice's session tenant) — instead of the job's own
|
|
567
|
+
// Tenant A. Give Tenant A a provider WITHOUT getSignedUrl while
|
|
568
|
+
// Tenant B keeps "inmemory" (default from beforeEach): if the bug
|
|
569
|
+
// resurfaces, the wrong (Tenant B) provider is picked and the
|
|
570
|
+
// request succeeds with 200 instead of 422.
|
|
571
|
+
const { jobId } = await seedDoneJobWithToken();
|
|
572
|
+
await stack.http.writeOk(
|
|
573
|
+
ConfigHandlers.set,
|
|
574
|
+
{ key: "file-foundation:config:provider", value: "no-signed-url" },
|
|
575
|
+
tenantAdmin, // Tenant A admin
|
|
576
|
+
);
|
|
577
|
+
const aliceFromTenantB = createTestUser({
|
|
578
|
+
id: 42,
|
|
579
|
+
tenantId: tenantB,
|
|
580
|
+
roles: ["Member"],
|
|
581
|
+
});
|
|
582
|
+
await asRawClient(stack.db).unsafe(
|
|
583
|
+
`
|
|
584
|
+
INSERT INTO read_tenant_memberships (tenant_id, user_id)
|
|
585
|
+
VALUES ($1, $2)
|
|
586
|
+
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
|
587
|
+
`,
|
|
588
|
+
[tenantB, String(aliceUser.id)],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const res = await stack.http.query(
|
|
592
|
+
"user-data-rights:query:download-by-job",
|
|
593
|
+
{ jobId },
|
|
594
|
+
aliceFromTenantB,
|
|
595
|
+
);
|
|
596
|
+
expect(res.status).toBe(422);
|
|
597
|
+
const body = (await res.json()) as { error: { code: string; i18nKey: string } };
|
|
598
|
+
expect(body.error.code).toBe("unprocessable");
|
|
599
|
+
expect(body.error.i18nKey).toBe("userDataRights.errors.download.signedUrlNotSupported");
|
|
600
|
+
});
|
|
562
601
|
});
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
47
47
|
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
48
48
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
49
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
50
49
|
import {
|
|
51
50
|
createComplianceProfilesFeature,
|
|
52
51
|
tenantComplianceProfileEntity,
|
|
@@ -60,6 +59,7 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
60
59
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
61
60
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
62
61
|
import { sessionCallbacksFromLateBound } from "../../sessions/testing";
|
|
62
|
+
import { hashPassword } from "../../shared";
|
|
63
63
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
64
64
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
65
65
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
32
32
|
import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
|
|
33
33
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
34
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
35
34
|
import {
|
|
36
35
|
createComplianceProfilesFeature,
|
|
37
36
|
tenantComplianceProfileEntity,
|
|
@@ -46,6 +45,7 @@ import { SessionHandlers } from "../../sessions/constants";
|
|
|
46
45
|
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
47
46
|
import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
|
|
48
47
|
import { sessionCallbacksFromLateBound } from "../../sessions/testing";
|
|
48
|
+
import { hashPassword } from "../../shared";
|
|
49
49
|
import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
|
|
50
50
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
51
51
|
import { seedTenantMembership } from "../../tenant/seeding";
|
|
@@ -161,14 +161,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
161
161
|
// "single-user" via appOverrides (TENANT_MODEL_CONFIG_KEY) so the forget
|
|
162
162
|
// pipeline may erase the tenant's data as that user's personal data — still
|
|
163
163
|
// gated by a runtime sole-member check in run-forget-cleanup.
|
|
164
|
-
r.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
});
|
|
164
|
+
r.configKey(
|
|
165
|
+
"tenantModel",
|
|
166
|
+
createSystemConfig("select", {
|
|
167
|
+
default: "multi-user",
|
|
168
|
+
options: ["single-user", "multi-user"],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
172
171
|
|
|
173
172
|
// S2.U3 Atom 1b — ExportJob-Lifecycle-Entity.
|
|
174
173
|
r.entity("export-job", exportJobEntity);
|
|
@@ -29,8 +29,8 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
29
29
|
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
31
31
|
import { z } from "zod";
|
|
32
|
-
import { createFileProviderForTenant } from "../../file-foundation";
|
|
33
32
|
import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
|
|
33
|
+
import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
|
|
34
34
|
import { exportDownloadTokensTable } from "../schema/download-token";
|
|
35
35
|
import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
|
|
36
36
|
|
|
@@ -127,7 +127,10 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Provider bound explicitly to the job's tenant — NOT the ambient
|
|
131
|
+
// session tenant (cross-tenant-same-user: job.requestedFromTenantId
|
|
132
|
+
// can differ from query.user.tenantId).
|
|
133
|
+
const provider = await resolveTenantFileProvider(
|
|
131
134
|
ctx,
|
|
132
135
|
jobRow.requestedFromTenantId,
|
|
133
136
|
"user-data-rights:query:download-by-job",
|
|
@@ -31,8 +31,8 @@ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
31
31
|
import { NotFoundError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
32
32
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
33
33
|
import { z } from "zod";
|
|
34
|
-
import { createFileProviderForTenant } from "../../file-foundation";
|
|
35
34
|
import { recordDownloadUse, recordInvalidAttempt } from "../audit-download";
|
|
35
|
+
import { resolveTenantFileProvider } from "../lib/tenant-file-provider";
|
|
36
36
|
import { exportDownloadTokensTable } from "../schema/download-token";
|
|
37
37
|
import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
|
|
38
38
|
import { hashDownloadToken } from "../token-helpers";
|
|
@@ -181,9 +181,10 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Step 5: signed-URL via provider
|
|
185
|
-
//
|
|
186
|
-
|
|
184
|
+
// Step 5: signed-URL via provider, explicitly bound to the job's
|
|
185
|
+
// tenant — NOT the ambient request tenant (anonymous magic-link path
|
|
186
|
+
// has none under resolverTrust: "authoritative").
|
|
187
|
+
const provider = await resolveTenantFileProvider(
|
|
187
188
|
ctx,
|
|
188
189
|
jobRow.requestedFromTenantId,
|
|
189
190
|
"user-data-rights:query:download-by-token",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Resolves the GDPR-download file-storage provider explicitly for
|
|
2
|
+
// `jobRow.requestedFromTenantId` instead of the ambient request tenant.
|
|
3
|
+
//
|
|
4
|
+
// Both download handlers (by-token, by-job) used to call
|
|
5
|
+
// `createFileProviderForTenant(ctx, jobRow.requestedFromTenantId, ...)`,
|
|
6
|
+
// which reads the PROVIDER SELECTION from `ctx.config` — bound to the
|
|
7
|
+
// caller's own ambient tenant, not the job's tenant:
|
|
8
|
+
// - by-token (anonymous magic-link, httpRoute): under
|
|
9
|
+
// `resolverTrust: "authoritative"` without `defaultTenantId` there is
|
|
10
|
+
// no ambient tenant at all — `ctx.config` throws, 500s the whole
|
|
11
|
+
// GDPR download flow.
|
|
12
|
+
// - by-job (session-authed): a user can own jobs across tenants
|
|
13
|
+
// (cross-tenant-same-user); the ambient session tenant silently
|
|
14
|
+
// picks the WRONG tenant's provider config for a job requested from
|
|
15
|
+
// a different tenant.
|
|
16
|
+
//
|
|
17
|
+
// Fix: build a fresh config accessor bound explicitly to the given
|
|
18
|
+
// tenantId (same construction as `makeTenantStorageProviderResolver`,
|
|
19
|
+
// which the export/forget crons already use), instead of the ambient
|
|
20
|
+
// `ctx.config`. Deliberately NOT `ctx._fileProviderResolver` — that
|
|
21
|
+
// resolver is boot-built and per-tenant-cached for the process lifetime,
|
|
22
|
+
// so an operator switching `file-foundation:config:provider` mid-session
|
|
23
|
+
// wouldn't take effect until the cache evicts.
|
|
24
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import type { ConfigResolver, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
27
|
+
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
28
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
+
import { createConfigAccessor } from "../../config";
|
|
30
|
+
import { createFileProviderForTenant } from "../../file-foundation";
|
|
31
|
+
|
|
32
|
+
export interface TenantFileProviderCtx {
|
|
33
|
+
readonly registry?: Registry;
|
|
34
|
+
readonly configResolver?: ConfigResolver;
|
|
35
|
+
readonly secrets?: SecretsContext;
|
|
36
|
+
readonly db: { readonly raw: DbConnection | TenantDb };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function resolveTenantFileProvider(
|
|
40
|
+
ctx: TenantFileProviderCtx,
|
|
41
|
+
tenantId: string,
|
|
42
|
+
handlerName: string,
|
|
43
|
+
): Promise<FileStorageProvider> {
|
|
44
|
+
if (!ctx.registry || !ctx.configResolver) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`${handlerName}: ctx.registry/ctx.configResolver missing — cannot resolve the file provider for tenant ${tenantId}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const config = createConfigAccessor(
|
|
50
|
+
ctx.registry,
|
|
51
|
+
ctx.configResolver,
|
|
52
|
+
tenantId as TenantId, // @cast-boundary engine-payload: TenantId brand
|
|
53
|
+
SYSTEM_USER_ID,
|
|
54
|
+
ctx.db.raw,
|
|
55
|
+
ctx.secrets,
|
|
56
|
+
);
|
|
57
|
+
return createFileProviderForTenant(
|
|
58
|
+
{ config, registry: ctx.registry, secrets: ctx.secrets, _userId: SYSTEM_USER_ID },
|
|
59
|
+
tenantId,
|
|
60
|
+
handlerName,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
import { expectErrorIncludes, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
19
19
|
import { AuthErrors, AuthHandlers } from "../../auth-email-password/constants";
|
|
20
20
|
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
21
|
-
import { hashPassword } from "../../auth-email-password/password-hashing";
|
|
22
21
|
import {
|
|
23
22
|
createComplianceProfilesFeature,
|
|
24
23
|
tenantComplianceProfileEntity,
|
|
@@ -28,6 +27,7 @@ import { createConfigFeature } from "../../config";
|
|
|
28
27
|
import { configValuesTable } from "../../config/table";
|
|
29
28
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
30
29
|
import { createSessionsFeature } from "../../sessions";
|
|
30
|
+
import { hashPassword } from "../../shared";
|
|
31
31
|
import { createTenantFeature } from "../../tenant";
|
|
32
32
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
33
33
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { AuthErrors
|
|
10
|
-
import { decryptStoredPii } from "../../shared";
|
|
9
|
+
import { AuthErrors } from "../../auth-email-password";
|
|
10
|
+
import { decryptStoredPii, verifyPassword } from "../../shared";
|
|
11
11
|
import { UserErrors, UserHandlers, UserQueries } from "../../user";
|
|
12
12
|
import { UserProfileErrors } from "../constants";
|
|
13
13
|
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
// Moved to shared/ — reused across admin-shell, audit, sessions, tenant,
|
|
2
|
-
// user-data-rights, user-profile, and now auth-mfa. Re-exported here for the
|
|
3
|
-
// existing call sites within this feature that import via "./password-hashing".
|
|
4
|
-
export { hashPassword, verifyDummyPassword, verifyPassword } from "../shared/password-hashing";
|