@cosmicdrift/kumiko-bundled-features 0.265.0 → 0.268.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__/query-as-member.integration.test.ts +747 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +16 -6
- package/src/auth-email-password/handlers/invite-accept.write.ts +19 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +1 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +21 -7
- package/src/auth-email-password/handlers/login.write.ts +3 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +9 -5
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +10 -2
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +11 -1
- package/src/auth-mfa/handlers/verify.write.ts +10 -2
- package/src/auth-mfa/mfa-code-verifier.ts +5 -1
- package/src/auth-mfa/mfa-status-checker.ts +7 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -3
- package/src/cap-counter/stock-cap-guard.ts +6 -1
- package/src/crypto-shredding/handlers/forget-subject.write.ts +43 -27
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +10 -5
- package/src/custom-fields/db/queries/field-access.ts +3 -1
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/handlers/clear-custom-field.write.ts +6 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +6 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +6 -0
- package/src/delivery/jobs.ts +6 -5
- package/src/feature-toggles/global-feature-state-table.ts +15 -11
- package/src/feature-toggles/handlers/list.query.ts +1 -2
- package/src/feature-toggles/handlers/registered.query.ts +1 -2
- package/src/form-draft/handlers/discard.write.ts +7 -1
- package/src/form-draft/handlers/save.write.ts +11 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +16 -6
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -3
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -2
- package/src/jobs/handlers/projection-rebuild.job.ts +4 -4
- package/src/jobs/handlers/reindex-entity.job.ts +5 -2
- package/src/ledger/__tests__/ledger-reports-tenant-isolation.integration.test.ts +148 -0
- package/src/ledger/handlers/confirm-schedule-period.write.ts +2 -3
- package/src/ledger/handlers/reports.query.ts +2 -3
- package/src/ledger/handlers/reverse-transaction.write.ts +1 -2
- package/src/managed-pages/handlers/set.write.ts +13 -1
- package/src/notes-history/handlers/add-note.write.ts +17 -8
- package/src/personal-access-tokens/handlers/create.write.ts +2 -1
- package/src/secrets/handlers/list.query.ts +1 -3
- package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -2
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +11 -1
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +10 -3
- package/src/tier-engine/feature.ts +11 -19
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -6
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +6 -2
- package/src/user/__tests__/user-global-tenancy.integration.test.ts +97 -0
- package/src/user/handlers/update-roles.ts +14 -3
- package/src/user/handlers/update.write.ts +14 -1
- package/src/user/principal-status.ts +22 -1
- package/src/user/schema/user.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +3 -1
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +11 -10
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +7 -5
- package/src/user-data-rights/handlers/deletion-grace-period.ts +11 -10
- package/src/user-data-rights/handlers/download-by-job.query.ts +17 -10
- package/src/user-data-rights/handlers/download-by-token.query.ts +21 -12
- package/src/user-data-rights/handlers/export-status.query.ts +8 -4
- package/src/user-data-rights/handlers/lift-restriction.write.ts +21 -9
- package/src/user-data-rights/handlers/my-audit-log.query.ts +7 -4
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -8
- package/src/user-data-rights/handlers/request-deletion.write.ts +2 -1
- package/src/user-data-rights/handlers/request-export.write.ts +13 -8
- package/src/user-data-rights/handlers/restrict-account.write.ts +18 -13
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +9 -5
- package/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
- package/src/user-data-rights/run-forget-cleanup.ts +2 -2
- package/src/user-data-rights/schema/export-job.ts +2 -4
|
@@ -40,6 +40,9 @@ import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
|
|
|
40
40
|
|
|
41
41
|
const SIGNED_URL_TTL_SECONDS = 300; // 5 min — matched download-by-token
|
|
42
42
|
|
|
43
|
+
const DOWNLOAD_BY_JOB_REASON =
|
|
44
|
+
"export jobs, their download tokens and audit rows are keyed by job id / userId across the user's tenant memberships, not the caller's current tenant";
|
|
45
|
+
|
|
43
46
|
interface TokenRow {
|
|
44
47
|
readonly id: string;
|
|
45
48
|
readonly version: number;
|
|
@@ -63,6 +66,9 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
63
66
|
access: { openToAll: true }, // openToAll = auth-required, kein anonymous
|
|
64
67
|
description:
|
|
65
68
|
"Returns a short-lived signed download URL for the calling user's own finished data-export job named by job id, backing the download button in the privacy center once export-status reports the job done.",
|
|
69
|
+
escapeHatch: {
|
|
70
|
+
reason: DOWNLOAD_BY_JOB_REASON,
|
|
71
|
+
},
|
|
66
72
|
handler: async (query, ctx) => {
|
|
67
73
|
const T = getTemporal();
|
|
68
74
|
const now = T.Now.instant();
|
|
@@ -74,15 +80,14 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
74
80
|
// Wert (603/2).
|
|
75
81
|
const auditIp = requestContext.get()?.ip ?? null;
|
|
76
82
|
const auditUa = requestContext.get()?.userAgent ?? null;
|
|
83
|
+
const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_JOB_REASON);
|
|
77
84
|
|
|
78
85
|
// Step 1-2: job-lookup + cross-user-isolation
|
|
79
|
-
|
|
80
|
-
// aus Tenant A erstellten Job.
|
|
81
|
-
const jobRow = await fetchOne<JobRow>(ctx.db.raw, exportJobsTable, { id: jobId });
|
|
86
|
+
const jobRow = await fetchOne<JobRow>(runner, exportJobsTable, { id: jobId });
|
|
82
87
|
|
|
83
88
|
if (!jobRow || jobRow.userId !== userId) {
|
|
84
89
|
await recordInvalidAttempt({
|
|
85
|
-
db:
|
|
90
|
+
db: runner,
|
|
86
91
|
tenantId,
|
|
87
92
|
now,
|
|
88
93
|
result: "notFound",
|
|
@@ -100,7 +105,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
100
105
|
|
|
101
106
|
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
102
107
|
await recordInvalidAttempt({
|
|
103
|
-
db:
|
|
108
|
+
db: runner,
|
|
104
109
|
tenantId,
|
|
105
110
|
now,
|
|
106
111
|
result: "failed",
|
|
@@ -117,7 +122,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
117
122
|
}
|
|
118
123
|
if (!jobRow.downloadStorageKey) {
|
|
119
124
|
await recordInvalidAttempt({
|
|
120
|
-
db:
|
|
125
|
+
db: runner,
|
|
121
126
|
tenantId,
|
|
122
127
|
now,
|
|
123
128
|
result: "expired",
|
|
@@ -140,13 +145,15 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
140
145
|
registry: ctx.registry,
|
|
141
146
|
configResolver: ctx.configResolver,
|
|
142
147
|
secrets: ctx.secrets,
|
|
143
|
-
|
|
148
|
+
// @cast-boundary db-runner — helpers use only the query API that
|
|
149
|
+
// DbConnection and DbTx share.
|
|
150
|
+
db: runner as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
144
151
|
userId: SYSTEM_USER_ID,
|
|
145
152
|
handlerName: "user-data-rights:query:download-by-job",
|
|
146
153
|
})(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
|
|
147
154
|
if (!provider.getSignedUrl) {
|
|
148
155
|
await recordInvalidAttempt({
|
|
149
|
-
db:
|
|
156
|
+
db: runner,
|
|
150
157
|
tenantId,
|
|
151
158
|
now,
|
|
152
159
|
result: "signedUrlNotSupported",
|
|
@@ -177,13 +184,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
177
184
|
// den plain-Token, aber wir wollen den useCount inkrementieren
|
|
178
185
|
// damit die Audit-Felder konsistent sind (UI-clicks zaehlen auch
|
|
179
186
|
// als Use). Lookup via jobId — UNIQUE-Index garantiert max 1 Row.
|
|
180
|
-
const tokenRow = await fetchOne<TokenRow>(
|
|
187
|
+
const tokenRow = await fetchOne<TokenRow>(runner, exportDownloadTokensTable, {
|
|
181
188
|
jobId,
|
|
182
189
|
});
|
|
183
190
|
|
|
184
191
|
if (tokenRow) {
|
|
185
192
|
await recordDownloadUse({
|
|
186
|
-
db:
|
|
193
|
+
db: runner,
|
|
187
194
|
tokenId: tokenRow.id,
|
|
188
195
|
tokenVersion: tokenRow.version,
|
|
189
196
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
@@ -45,6 +45,9 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
|
45
45
|
|
|
46
46
|
const SIGNED_URL_TTL_SECONDS = 300; // 5 min — kurz genug fuer Replay-Schutz, lang genug fuer slow connections
|
|
47
47
|
|
|
48
|
+
const DOWNLOAD_BY_TOKEN_REASON =
|
|
49
|
+
"the download token, its job and audit rows are keyed by token hash / job id, not the caller's tenant — the anonymous magic-link path has no tenant context at all";
|
|
50
|
+
|
|
48
51
|
interface TokenRow {
|
|
49
52
|
readonly id: string;
|
|
50
53
|
readonly version: number;
|
|
@@ -82,15 +85,17 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
82
85
|
// Connection-Abbruch); blockiert automatisierte Probing-Loops.
|
|
83
86
|
// Memory `feedback_security_default_on`.
|
|
84
87
|
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
88
|
+
escapeHatch: {
|
|
89
|
+
reason: DOWNLOAD_BY_TOKEN_REASON,
|
|
90
|
+
},
|
|
85
91
|
handler: async (query, ctx) => {
|
|
86
92
|
const T = getTemporal();
|
|
87
93
|
const now = T.Now.instant();
|
|
94
|
+
const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_TOKEN_REASON);
|
|
88
95
|
|
|
89
96
|
// Step 1: hash + lookup
|
|
90
97
|
const hash = await hashDownloadToken(query.payload.token);
|
|
91
|
-
|
|
92
|
-
// keinen tenant-context im query.user.
|
|
93
|
-
const tokenRow = await fetchOne<TokenRow>(ctx.db.raw, exportDownloadTokensTable, {
|
|
98
|
+
const tokenRow = await fetchOne<TokenRow>(runner, exportDownloadTokensTable, {
|
|
94
99
|
tokenHash: hash,
|
|
95
100
|
});
|
|
96
101
|
|
|
@@ -118,13 +123,15 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
118
123
|
// tenantId unbekannt. Wir laden den Job hier noch fuer Audit-Context
|
|
119
124
|
// (best-effort — wenn Job auch fehlt, audit-skip ist akzeptabel).
|
|
120
125
|
const jobForAudit = await fetchOne<{ requestedFromTenantId: string }>(
|
|
121
|
-
|
|
126
|
+
runner,
|
|
122
127
|
exportJobsTable,
|
|
123
|
-
{
|
|
128
|
+
{
|
|
129
|
+
id: tokenRow.jobId,
|
|
130
|
+
},
|
|
124
131
|
);
|
|
125
132
|
if (jobForAudit) {
|
|
126
133
|
await recordInvalidAttempt({
|
|
127
|
-
db:
|
|
134
|
+
db: runner,
|
|
128
135
|
tenantId: jobForAudit.requestedFromTenantId,
|
|
129
136
|
now,
|
|
130
137
|
result: "expired",
|
|
@@ -142,7 +149,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
// Step 3-4: job-checks
|
|
145
|
-
const jobRow = await fetchOne<JobRow>(
|
|
152
|
+
const jobRow = await fetchOne<JobRow>(runner, exportJobsTable, {
|
|
146
153
|
id: tokenRow.jobId,
|
|
147
154
|
});
|
|
148
155
|
|
|
@@ -153,7 +160,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
153
160
|
}
|
|
154
161
|
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
155
162
|
await recordInvalidAttempt({
|
|
156
|
-
db:
|
|
163
|
+
db: runner,
|
|
157
164
|
tenantId: jobRow.requestedFromTenantId,
|
|
158
165
|
now,
|
|
159
166
|
result: "failed",
|
|
@@ -170,7 +177,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
170
177
|
}
|
|
171
178
|
if (!jobRow.downloadStorageKey) {
|
|
172
179
|
await recordInvalidAttempt({
|
|
173
|
-
db:
|
|
180
|
+
db: runner,
|
|
174
181
|
tenantId: jobRow.requestedFromTenantId,
|
|
175
182
|
now,
|
|
176
183
|
result: "expired",
|
|
@@ -193,13 +200,15 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
193
200
|
registry: ctx.registry,
|
|
194
201
|
configResolver: ctx.configResolver,
|
|
195
202
|
secrets: ctx.secrets,
|
|
196
|
-
|
|
203
|
+
// @cast-boundary db-runner — helpers use only the query API that
|
|
204
|
+
// DbConnection and DbTx share.
|
|
205
|
+
db: runner as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
197
206
|
userId: SYSTEM_USER_ID,
|
|
198
207
|
handlerName: "user-data-rights:query:download-by-token",
|
|
199
208
|
})(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
|
|
200
209
|
if (!provider.getSignedUrl) {
|
|
201
210
|
await recordInvalidAttempt({
|
|
202
|
-
db:
|
|
211
|
+
db: runner,
|
|
203
212
|
tenantId: jobRow.requestedFromTenantId,
|
|
204
213
|
now,
|
|
205
214
|
result: "signedUrlNotSupported",
|
|
@@ -230,7 +239,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
230
239
|
// Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
|
|
231
240
|
// Audit ist nicht security-relevant.
|
|
232
241
|
await recordDownloadUse({
|
|
233
|
-
db:
|
|
242
|
+
db: runner,
|
|
234
243
|
tokenId: tokenRow.id,
|
|
235
244
|
tokenVersion: tokenRow.version,
|
|
236
245
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// **Cross-User-Isolation:** Filter ist `userId === query.user.id` — kein
|
|
7
7
|
// User kann fremde Job-Status sehen, auch nicht via ID-Guess. Pre-Check
|
|
8
|
-
//
|
|
8
|
+
// uses ctx.db.unsafeRaw — ExportJob is tenant-agnostic (see plan doc,
|
|
9
9
|
// "Cross-Tenant-Semantik").
|
|
10
10
|
//
|
|
11
11
|
// **Read-Only-Endpoint:** Pollt nur, kein State-Flip. Idempotent + cache-
|
|
@@ -19,6 +19,9 @@ import { exportJobsTable } from "../schema/export-job";
|
|
|
19
19
|
|
|
20
20
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
21
21
|
|
|
22
|
+
const EXPORT_STATUS_REASON =
|
|
23
|
+
"export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
|
|
24
|
+
|
|
22
25
|
type ExportJobRow = {
|
|
23
26
|
readonly id: string;
|
|
24
27
|
readonly status: string;
|
|
@@ -35,11 +38,12 @@ export const exportStatusQuery = defineQueryHandler({
|
|
|
35
38
|
access: { openToAll: true },
|
|
36
39
|
description:
|
|
37
40
|
"Returns the calling user's own most recent data-export job with its status, expiry and error, or hasJob false, for polling after a request-export while the job is still running.",
|
|
41
|
+
escapeHatch: {
|
|
42
|
+
reason: EXPORT_STATUS_REASON,
|
|
43
|
+
},
|
|
38
44
|
handler: async (query, ctx) => {
|
|
39
|
-
// ctx.db.raw weil tenant-agnostisch — ein User der aus Tenant B
|
|
40
|
-
// pollt, sieht den aus Tenant A erstellten Job.
|
|
41
45
|
const rows = await selectMany<ExportJobRow>(
|
|
42
|
-
ctx.db.
|
|
46
|
+
ctx.db.unsafeRaw(EXPORT_STATUS_REASON),
|
|
43
47
|
exportJobsTable,
|
|
44
48
|
{ userId: query.user.id },
|
|
45
49
|
{ limit: 1, orderBy: { col: "requestedAt", direction: "desc" } },
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
3
|
import { z } from "zod";
|
|
@@ -6,6 +5,13 @@ import { USER_STATUS, userTable } from "../../user";
|
|
|
6
5
|
import { denyIfTargetOutsideAdminTenant } from "../lib/deny-if-target-outside-admin-tenant";
|
|
7
6
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
8
7
|
|
|
8
|
+
const LIFT_RESTRICTION_ESCAPE_HATCH_REASON =
|
|
9
|
+
"checks the target user's membership in the admin's tenant and appends the user lifecycle status change on the SYSTEM_TENANT_ID user stream, both via DbRunner helpers outside the admin's own tenant scope.";
|
|
10
|
+
const CHECK_TARGET_MEMBERSHIP_REASON =
|
|
11
|
+
"checks the target user's membership in the admin's tenant via the DbRunner helper";
|
|
12
|
+
const APPEND_LIFECYCLE_EVENT_REASON =
|
|
13
|
+
"appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
|
|
14
|
+
|
|
9
15
|
// POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
|
|
10
16
|
//
|
|
11
17
|
// Operator-only. A Restricted user's own session is unconditionally
|
|
@@ -14,11 +20,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
14
20
|
// endpoint. There is no self-service path today, so lifting a restriction
|
|
15
21
|
// always targets someone else's account by id.
|
|
16
22
|
//
|
|
17
|
-
// Tenant scope: `access.admin` includes TenantAdmin
|
|
18
|
-
//
|
|
19
|
-
// (bypasses the auto-tenant-filter, same as restrict-account.write.ts —
|
|
20
|
-
// User status is intentionally global, see user-data-rights.md
|
|
21
|
-
// "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
|
|
23
|
+
// Tenant scope: `access.admin` includes TenantAdmin (tenant-scoped) even
|
|
24
|
+
// though the User-entity is global — see user-data-rights.md "Cross-Tenant-Semantik". Without a membership check, a TenantAdmin
|
|
22
25
|
// from tenant A could unrestrict/reactivate a user who has never been a
|
|
23
26
|
// member of tenant A. Only SystemAdmin (platform-wide) skips the check;
|
|
24
27
|
// the target must have a membership row in the acting admin's
|
|
@@ -35,13 +38,20 @@ export const liftRestrictionWrite = defineWriteHandler({
|
|
|
35
38
|
access: { roles: access.admin },
|
|
36
39
|
description:
|
|
37
40
|
"Lifts a GDPR Art. 18 processing restriction on the named user and returns the account to active; operator-only, because a restricted user's own session is rejected and cannot reach this endpoint.",
|
|
41
|
+
escapeHatch: {
|
|
42
|
+
reason: LIFT_RESTRICTION_ESCAPE_HATCH_REASON,
|
|
43
|
+
},
|
|
38
44
|
handler: async (event, ctx) => {
|
|
39
45
|
const targetUserId = event.payload.userId;
|
|
40
46
|
|
|
41
|
-
const outside = await denyIfTargetOutsideAdminTenant(
|
|
47
|
+
const outside = await denyIfTargetOutsideAdminTenant(
|
|
48
|
+
ctx.db.unsafeRaw(CHECK_TARGET_MEMBERSHIP_REASON),
|
|
49
|
+
event.user,
|
|
50
|
+
targetUserId,
|
|
51
|
+
);
|
|
42
52
|
if (outside) return outside;
|
|
43
53
|
|
|
44
|
-
const userRow = await fetchOne<{ status: string }>(
|
|
54
|
+
const userRow = await ctx.db.global(userTable).fetchOne<{ status: string }>({
|
|
45
55
|
id: targetUserId,
|
|
46
56
|
});
|
|
47
57
|
|
|
@@ -62,7 +72,9 @@ export const liftRestrictionWrite = defineWriteHandler({
|
|
|
62
72
|
);
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
await updateUserLifecycle(ctx.db.
|
|
75
|
+
await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), targetUserId, {
|
|
76
|
+
status: USER_STATUS.Active,
|
|
77
|
+
});
|
|
66
78
|
|
|
67
79
|
return {
|
|
68
80
|
isSuccess: true as const,
|
|
@@ -10,6 +10,9 @@ import { z } from "zod";
|
|
|
10
10
|
// auf account-weite Sicht ueber alle Memberships — analog Forget-Pfad.
|
|
11
11
|
const MAX_LIMIT = 100;
|
|
12
12
|
|
|
13
|
+
const MY_AUDIT_LOG_REASON =
|
|
14
|
+
"reads the caller's own events across all tenant memberships, bounded by createdBy = caller";
|
|
15
|
+
|
|
13
16
|
export const myAuditLogQuery = defineQueryHandler({
|
|
14
17
|
name: "my-audit-log",
|
|
15
18
|
schema: z
|
|
@@ -28,12 +31,12 @@ export const myAuditLogQuery = defineQueryHandler({
|
|
|
28
31
|
access: { openToAll: true },
|
|
29
32
|
description:
|
|
30
33
|
"Returns the calling user's own event-store entries across all their tenant memberships, paged and filterable by aggregate type, event type and time range, for the GDPR Art. 15 self-disclosure; it can never read another user's history.",
|
|
34
|
+
escapeHatch: {
|
|
35
|
+
reason: MY_AUDIT_LOG_REASON,
|
|
36
|
+
},
|
|
31
37
|
handler: async (query, ctx) => {
|
|
32
38
|
const p = query.payload;
|
|
33
39
|
|
|
34
|
-
// ctx.db.raw weil events-table tenantId-Spalte hat und TenantDb
|
|
35
|
-
// sonst auto-filtert auf currentTenant. Account-weite Sicht ist
|
|
36
|
-
// hier explizit gewollt; Sicherung erfolgt via createdBy-Filter.
|
|
37
40
|
const where: WhereObject = { createdBy: query.user.id };
|
|
38
41
|
if (p.aggregateType) where["aggregateType"] = p.aggregateType;
|
|
39
42
|
if (p.eventType) where["type"] = p.eventType;
|
|
@@ -53,7 +56,7 @@ export const myAuditLogQuery = defineQueryHandler({
|
|
|
53
56
|
type: string;
|
|
54
57
|
payload: Record<string, unknown>;
|
|
55
58
|
created_at: unknown;
|
|
56
|
-
}>(ctx.db.
|
|
59
|
+
}>(ctx.db.unsafeRaw(MY_AUDIT_LOG_REASON), eventsTable, where, {
|
|
57
60
|
orderBy: { col: "id", direction: "desc" },
|
|
58
61
|
limit: p.limit,
|
|
59
62
|
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { USER_STATUS, userTable } from "../../user";
|
|
@@ -41,6 +40,9 @@ export type RequestDeletionByEmailOptions = {
|
|
|
41
40
|
// logs, unlike `?token=`). Same convention as the export-download link
|
|
42
41
|
// (feature.ts, issue #1271). Preserves any existing query params on `base`
|
|
43
42
|
// (`?lang=de` stays a query param; only the token is a fragment).
|
|
43
|
+
const APPEND_LIFECYCLE_EVENT_REASON =
|
|
44
|
+
"appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
|
|
45
|
+
|
|
44
46
|
export function buildDeletionVerifyUrl(base: string, token: string): string {
|
|
45
47
|
const url = new URL(base);
|
|
46
48
|
url.hash = `token=${encodeURIComponent(token)}`;
|
|
@@ -66,18 +68,21 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
|
|
|
66
68
|
agent: { risk: "high" },
|
|
67
69
|
// Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
|
|
68
70
|
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
71
|
+
escapeHatch: {
|
|
72
|
+
reason: APPEND_LIFECYCLE_EVENT_REASON,
|
|
73
|
+
},
|
|
69
74
|
handler: async (event, ctx) => {
|
|
70
75
|
const success = { isSuccess: true as const, data: { kind: "requested" as const } };
|
|
71
76
|
|
|
72
77
|
// not-configured-safe: ohne Secret/URL kein Link, aber gleiche Antwort.
|
|
73
78
|
if (!opts.deletionTokenSecret || !opts.deletionVerifyUrl) return success;
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
const userRow = await ctx.db
|
|
81
|
+
.global(userTable)
|
|
82
|
+
.fetchOne<{ id: string; status: string; email: string }>({
|
|
83
|
+
email: event.payload.email,
|
|
84
|
+
isDeleted: false,
|
|
85
|
+
});
|
|
81
86
|
if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
|
|
82
87
|
return success;
|
|
83
88
|
}
|
|
@@ -86,7 +91,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
|
|
|
86
91
|
// user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
|
|
87
92
|
// nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
|
|
88
93
|
const requestId = crypto.randomUUID();
|
|
89
|
-
await updateUserLifecycle(ctx.db.
|
|
94
|
+
await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), userRow["id"], {
|
|
90
95
|
pendingDeletionRequestId: requestId,
|
|
91
96
|
});
|
|
92
97
|
|
|
@@ -48,7 +48,8 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
|
|
|
48
48
|
reason:
|
|
49
49
|
"startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
50
50
|
"...) to compute the grace period end — the calling user's own identity has no read " +
|
|
51
|
-
"access to that tenant-config projection."
|
|
51
|
+
"access to that tenant-config projection. It also appends the user lifecycle event on " +
|
|
52
|
+
"the SYSTEM_TENANT_ID user stream.",
|
|
52
53
|
},
|
|
53
54
|
description:
|
|
54
55
|
"Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
|
|
@@ -14,10 +14,8 @@
|
|
|
14
14
|
// Catch + re-fetch + return existing als isExisting=true. Race-
|
|
15
15
|
// Window <1ms, in Production extrem selten.
|
|
16
16
|
//
|
|
17
|
-
// **Cross-Tenant-Semantik:** ExportJob
|
|
18
|
-
//
|
|
19
|
-
// TenantDb-Filter) — Alice klickt aus Tenant A, klickt dann aus Tenant
|
|
20
|
-
// B → Pre-Check aus B findet den A-Job, kein 2. Job entsteht.
|
|
17
|
+
// **Cross-Tenant-Semantik:** ExportJob is tenant-agnostic (1 job per userId
|
|
18
|
+
// across all memberships) — the pre-check uses ctx.db.unsafeRaw (no TenantDb filter) so a click from tenant B still finds the job from tenant A.
|
|
21
19
|
// `requestedFromTenantId` persistiert den Initial-Tenant aus dem
|
|
22
20
|
// 1. Klick — Worker liest sein Compliance-Profile aus DIESEM Tenant
|
|
23
21
|
// fuer Job-TTL/Stale/Cleanup.
|
|
@@ -39,6 +37,9 @@ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
|
|
|
39
37
|
entityName: "export-job",
|
|
40
38
|
});
|
|
41
39
|
|
|
40
|
+
const REQUEST_EXPORT_REASON =
|
|
41
|
+
"export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
|
|
42
|
+
|
|
42
43
|
/**
|
|
43
44
|
* Race-Loss-Detection: createEventStoreExecutor.create catched 23505
|
|
44
45
|
* intern + returnt `WriteFailure(UniqueViolationError)`, **kein throw**.
|
|
@@ -62,14 +63,18 @@ export const requestExportWrite = defineWriteHandler({
|
|
|
62
63
|
access: { openToAll: true },
|
|
63
64
|
description:
|
|
64
65
|
"Queues a GDPR Art. 15 and 20 data export for the calling user and returns its job id, handing back the running job with isExisting true instead of a second one when an export is already pending.",
|
|
66
|
+
escapeHatch: {
|
|
67
|
+
reason: REQUEST_EXPORT_REASON,
|
|
68
|
+
},
|
|
65
69
|
handler: async (event, ctx) => {
|
|
66
70
|
const userId = event.user.id;
|
|
67
71
|
const T = getTemporal();
|
|
68
72
|
const now = T.Now.instant();
|
|
73
|
+
const exportJobRunner = ctx.db.unsafeRaw(REQUEST_EXPORT_REASON);
|
|
69
74
|
|
|
70
|
-
// Pre-Check:
|
|
71
|
-
//
|
|
72
|
-
const existing = await findActiveJob(
|
|
75
|
+
// Pre-Check: exportJobRunner bypasses the TenantDb-Filter — the
|
|
76
|
+
// TenantDb-Wrapper would hide cross-tenant jobs.
|
|
77
|
+
const existing = await findActiveJob(exportJobRunner, userId);
|
|
73
78
|
if (existing) {
|
|
74
79
|
// Snapshot-Status (kann zwischen fetchOne + Response stale werden
|
|
75
80
|
// wenn Worker parallel den State flippt; window minimal). User
|
|
@@ -104,7 +109,7 @@ export const requestExportWrite = defineWriteHandler({
|
|
|
104
109
|
// Constraint. Re-fetch + return existing als isExisting=true.
|
|
105
110
|
// Andere Failures (validation, version-conflict, ...) propagieren.
|
|
106
111
|
if (isActiveJobConflict(result)) {
|
|
107
|
-
const winner = await findActiveJob(
|
|
112
|
+
const winner = await findActiveJob(exportJobRunner, userId);
|
|
108
113
|
if (!winner) {
|
|
109
114
|
// Sollte nie passieren — Constraint-Violation ohne existing
|
|
110
115
|
// active Job hiesse der Constraint matcht etwas anderes.
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import {
|
|
4
3
|
AccessDeniedError,
|
|
@@ -27,12 +26,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
27
26
|
// sessionChecker and they can no longer call this (or any) endpoint
|
|
28
27
|
// themselves; only an operator path reaches it at that point.
|
|
29
28
|
//
|
|
30
|
-
// Tenant scope: `isAdminActor` accepts access.admin, which includes
|
|
31
|
-
//
|
|
32
|
-
// uses `ctx.db.raw` (bypasses the auto-tenant-filter, same as
|
|
33
|
-
// lift-restriction.write.ts — User status is intentionally global, see
|
|
34
|
-
// user-data-rights.md "Cross-Tenant-Semantik"). Without a membership
|
|
35
|
-
// check, a TenantAdmin from tenant A could restrict/unrestrict a user who
|
|
29
|
+
// Tenant scope: `isAdminActor` accepts access.admin, which includes TenantAdmin
|
|
30
|
+
// (tenant-scoped) even though the User-entity is global — see user-data-rights.md "Cross-Tenant-Semantik". Without a membership check, a TenantAdmin from tenant A could restrict/unrestrict a user who
|
|
36
31
|
// has never been a member of tenant A. Only SystemAdmin (platform-wide)
|
|
37
32
|
// skips the check; the target must have a membership row in the acting
|
|
38
33
|
// admin's `event.user.tenantId` (row existence only — no active-status field).
|
|
@@ -50,7 +45,9 @@ export const restrictAccountWrite = defineWriteHandler({
|
|
|
50
45
|
reason:
|
|
51
46
|
"Revokes all live sessions of the (possibly self-)restricted user via ctx.writeAs(SYSTEM, " +
|
|
52
47
|
"sessions:write:user-session:revoke-all-for-user) — that handler's own access gate needs " +
|
|
53
|
-
"privileged roles the acting caller doesn't necessarily have."
|
|
48
|
+
"privileged roles the acting caller doesn't necessarily have. It also checks the target " +
|
|
49
|
+
"user's membership in the admin's tenant and appends the user lifecycle status change on " +
|
|
50
|
+
"the SYSTEM_TENANT_ID user stream, both via DbRunner helpers.",
|
|
54
51
|
},
|
|
55
52
|
description:
|
|
56
53
|
"Freezes an account under GDPR Art. 18 by flipping it to restricted and revoking all of its live sessions, locking the user out until an admin calls lift-restriction; targets the caller by default, an admin may name another userId.",
|
|
@@ -65,13 +62,17 @@ export const restrictAccountWrite = defineWriteHandler({
|
|
|
65
62
|
}),
|
|
66
63
|
);
|
|
67
64
|
}
|
|
68
|
-
const outside = await denyIfTargetOutsideAdminTenant(
|
|
65
|
+
const outside = await denyIfTargetOutsideAdminTenant(
|
|
66
|
+
ctx.db.unsafeRaw(
|
|
67
|
+
"checks the target user's membership in the admin's tenant via the DbRunner helper",
|
|
68
|
+
),
|
|
69
|
+
event.user,
|
|
70
|
+
targetUserId,
|
|
71
|
+
);
|
|
69
72
|
if (outside) return outside;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
// request-deletion.write.ts Cross-Tenant-Section).
|
|
74
|
-
const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
|
|
75
|
+
const userRow = await ctx.db.global(userTable).fetchOne<{ status: string }>({
|
|
75
76
|
id: targetUserId,
|
|
76
77
|
});
|
|
77
78
|
|
|
@@ -99,7 +100,11 @@ export const restrictAccountWrite = defineWriteHandler({
|
|
|
99
100
|
);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
await updateUserLifecycle(
|
|
103
|
+
await updateUserLifecycle(
|
|
104
|
+
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
105
|
+
targetUserId,
|
|
106
|
+
{ status: USER_STATUS.Restricted },
|
|
107
|
+
);
|
|
103
108
|
|
|
104
109
|
// Cross-Feature: alle live sessions revoken — sonst koennte der User
|
|
105
110
|
// mit existierendem JWT bis zur Token-Expiry weiter schreiben.
|
|
@@ -23,12 +23,18 @@ export type RunForgetCleanupOptions = {
|
|
|
23
23
|
readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const RUN_FORGET_CLEANUP_REASON =
|
|
27
|
+
"operator forget cleanup iterates users across tenants in per-user sub-transactions";
|
|
28
|
+
|
|
26
29
|
export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}) {
|
|
27
30
|
return defineWriteHandler({
|
|
28
31
|
name: "run-forget-cleanup",
|
|
29
32
|
schema: z.object({}),
|
|
30
33
|
access: { roles: access.privileged },
|
|
31
34
|
agent: { expose: false },
|
|
35
|
+
escapeHatch: {
|
|
36
|
+
reason: RUN_FORGET_CLEANUP_REASON,
|
|
37
|
+
},
|
|
32
38
|
handler: async (_event, ctx) => {
|
|
33
39
|
if (!ctx.registry) {
|
|
34
40
|
return writeFailure(
|
|
@@ -38,15 +44,13 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
38
44
|
);
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
// ctx.db.raw ist DbRunner. runForgetCleanup oeffnet pro User eine
|
|
42
|
-
// Sub-Tx (SAVEPOINT wenn Outer-Dispatcher-Tx aktiv) — siehe
|
|
43
|
-
// run-forget-cleanup.ts Header.
|
|
44
47
|
const T = getTemporal();
|
|
48
|
+
const runner = ctx.db.unsafeRaw(RUN_FORGET_CLEANUP_REASON);
|
|
45
49
|
// Operator-triggered forget must also erase binaries, not just rows —
|
|
46
50
|
// it flips users to Deleted, after which the cron never re-processes
|
|
47
51
|
// them, so a row-only delete here would permanently leak the binaries.
|
|
48
52
|
// Resolve through the same file-foundation path the cron uses.
|
|
49
|
-
const forgetDb =
|
|
53
|
+
const forgetDb = runner as DbConnection; // @cast-boundary db-runner — DbRunner narrows to DbConnection, config reads tolerate the outer tx
|
|
50
54
|
const tenantModel = await resolveAppTenantModel({
|
|
51
55
|
registry: ctx.registry,
|
|
52
56
|
configResolver: ctx.configResolver,
|
|
@@ -54,7 +58,7 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
54
58
|
userId: ctx._userId ?? SYSTEM_USER_ID,
|
|
55
59
|
});
|
|
56
60
|
const result = await runForgetCleanup({
|
|
57
|
-
db:
|
|
61
|
+
db: runner,
|
|
58
62
|
registry: ctx.registry,
|
|
59
63
|
now: T.Now.instant(),
|
|
60
64
|
tenantModel,
|
|
@@ -21,8 +21,8 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
|
21
21
|
// kann abweichen).
|
|
22
22
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
23
23
|
|
|
24
|
-
// `conn`
|
|
25
|
-
//
|
|
24
|
+
// `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
|
|
25
|
+
// (forget-cleanup sub-tx) — keeps the event append atomic with the write.
|
|
26
26
|
export async function updateUserLifecycle(
|
|
27
27
|
conn: DbRunner,
|
|
28
28
|
userId: string,
|
|
@@ -510,8 +510,8 @@ async function runInSubTransaction(
|
|
|
510
510
|
db: DbRunner,
|
|
511
511
|
fn: (tx: DbRunner) => Promise<void>,
|
|
512
512
|
): Promise<void> {
|
|
513
|
-
// `db` is already the raw runner (the handler passes ctx.db.
|
|
514
|
-
// top-level connection) — cast to read the transaction surface directly,
|
|
513
|
+
// `db` is already the raw runner (the handler passes ctx.db.unsafeRaw(...),
|
|
514
|
+
// the tests a top-level connection) — cast to read the transaction surface directly,
|
|
515
515
|
// without asRawClient (a test-only escape hatch). A top-level connection
|
|
516
516
|
// exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
|
|
517
517
|
// exclusive, so prefer whichever is present.
|
|
@@ -45,10 +45,8 @@ const EXPORT_JOB_STATUS_OPTIONS = [
|
|
|
45
45
|
EXPORT_JOB_STATUS.Failed,
|
|
46
46
|
] as const;
|
|
47
47
|
|
|
48
|
-
// **Tenant-
|
|
49
|
-
//
|
|
50
|
-
// in der Domain ignoriert (Cross-Tenant-Iteration laeuft im Worker via
|
|
51
|
-
// ctx.db.raw, gleiches Pattern wie runForgetCleanup).
|
|
48
|
+
// **Tenant-agnostic** like userTable — one job per userId across all
|
|
49
|
+
// memberships; the worker iterates cross-tenant via ctx.db.unsafeRaw (same pattern as runForgetCleanup).
|
|
52
50
|
//
|
|
53
51
|
// **Idempotency:** Partial-UNIQUE-Index auf `(userId)` WHERE
|
|
54
52
|
// `status IN ('pending', 'running')`. User mit pending-Job kann keinen
|