@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -80,7 +80,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
80
80
|
|
|
81
81
|
if (!jobRow || jobRow.userId !== userId) {
|
|
82
82
|
await recordInvalidAttempt({
|
|
83
|
-
db: ctx.db.raw,
|
|
83
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
84
84
|
tenantId,
|
|
85
85
|
now,
|
|
86
86
|
result: "notFound",
|
|
@@ -98,7 +98,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
98
98
|
|
|
99
99
|
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
100
100
|
await recordInvalidAttempt({
|
|
101
|
-
db: ctx.db.raw,
|
|
101
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
102
102
|
tenantId,
|
|
103
103
|
now,
|
|
104
104
|
result: "failed",
|
|
@@ -115,7 +115,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
115
115
|
}
|
|
116
116
|
if (!jobRow.downloadStorageKey) {
|
|
117
117
|
await recordInvalidAttempt({
|
|
118
|
-
db: ctx.db.raw,
|
|
118
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
119
119
|
tenantId,
|
|
120
120
|
now,
|
|
121
121
|
result: "expired",
|
|
@@ -138,13 +138,13 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
138
138
|
registry: ctx.registry,
|
|
139
139
|
configResolver: ctx.configResolver,
|
|
140
140
|
secrets: ctx.secrets,
|
|
141
|
-
db: ctx.db.raw,
|
|
141
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
142
142
|
userId: SYSTEM_USER_ID,
|
|
143
143
|
handlerName: "user-data-rights:query:download-by-job",
|
|
144
144
|
})(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
|
|
145
145
|
if (!provider.getSignedUrl) {
|
|
146
146
|
await recordInvalidAttempt({
|
|
147
|
-
db: ctx.db.raw,
|
|
147
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
148
148
|
tenantId,
|
|
149
149
|
now,
|
|
150
150
|
result: "signedUrlNotSupported",
|
|
@@ -181,7 +181,7 @@ export const downloadByJobQuery = defineQueryHandler({
|
|
|
181
181
|
|
|
182
182
|
if (tokenRow) {
|
|
183
183
|
await recordDownloadUse({
|
|
184
|
-
db: ctx.db.raw,
|
|
184
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
185
185
|
tokenId: tokenRow.id,
|
|
186
186
|
tokenVersion: tokenRow.version,
|
|
187
187
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
@@ -123,7 +123,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
123
123
|
);
|
|
124
124
|
if (jobForAudit) {
|
|
125
125
|
await recordInvalidAttempt({
|
|
126
|
-
db: ctx.db.raw,
|
|
126
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
127
127
|
tenantId: jobForAudit.requestedFromTenantId,
|
|
128
128
|
now,
|
|
129
129
|
result: "expired",
|
|
@@ -152,7 +152,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
152
152
|
}
|
|
153
153
|
if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
|
|
154
154
|
await recordInvalidAttempt({
|
|
155
|
-
db: ctx.db.raw,
|
|
155
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
156
156
|
tenantId: jobRow.requestedFromTenantId,
|
|
157
157
|
now,
|
|
158
158
|
result: "failed",
|
|
@@ -169,7 +169,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
169
169
|
}
|
|
170
170
|
if (!jobRow.downloadStorageKey) {
|
|
171
171
|
await recordInvalidAttempt({
|
|
172
|
-
db: ctx.db.raw,
|
|
172
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
173
173
|
tenantId: jobRow.requestedFromTenantId,
|
|
174
174
|
now,
|
|
175
175
|
result: "expired",
|
|
@@ -192,13 +192,13 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
192
192
|
registry: ctx.registry,
|
|
193
193
|
configResolver: ctx.configResolver,
|
|
194
194
|
secrets: ctx.secrets,
|
|
195
|
-
db: ctx.db.raw,
|
|
195
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
196
196
|
userId: SYSTEM_USER_ID,
|
|
197
197
|
handlerName: "user-data-rights:query:download-by-token",
|
|
198
198
|
})(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
|
|
199
199
|
if (!provider.getSignedUrl) {
|
|
200
200
|
await recordInvalidAttempt({
|
|
201
|
-
db: ctx.db.raw,
|
|
201
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
202
202
|
tenantId: jobRow.requestedFromTenantId,
|
|
203
203
|
now,
|
|
204
204
|
result: "signedUrlNotSupported",
|
|
@@ -229,7 +229,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
229
229
|
// Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
|
|
230
230
|
// Audit ist nicht security-relevant.
|
|
231
231
|
await recordDownloadUse({
|
|
232
|
-
db: ctx.db.raw,
|
|
232
|
+
db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
233
233
|
tokenId: tokenRow.id,
|
|
234
234
|
tokenVersion: tokenRow.version,
|
|
235
235
|
tokenUseCount: tokenRow.useCount ?? 0,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AccessDeniedError,
|
|
5
|
+
UnprocessableError,
|
|
6
|
+
writeFailure,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
4
8
|
import { z } from "zod";
|
|
9
|
+
import { tenantMembershipsTable } from "../../tenant";
|
|
5
10
|
import { USER_STATUS, userTable } from "../../user";
|
|
11
|
+
import { isSystemAdminActor } from "../lib/is-admin-actor";
|
|
6
12
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
7
13
|
|
|
8
14
|
// POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
|
|
@@ -13,6 +19,16 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
13
19
|
// endpoint. There is no self-service path today, so lifting a restriction
|
|
14
20
|
// always targets someone else's account by id.
|
|
15
21
|
//
|
|
22
|
+
// Tenant scope: `access.admin` includes TenantAdmin, which is tenant-
|
|
23
|
+
// scoped even though the User-entity lookup below uses `ctx.db.raw`
|
|
24
|
+
// (bypasses the auto-tenant-filter, same as restrict-account.write.ts —
|
|
25
|
+
// User status is intentionally global, see user-data-rights.md
|
|
26
|
+
// "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
|
|
27
|
+
// from tenant A could unrestrict/reactivate a user who has never been a
|
|
28
|
+
// member of tenant A. Only SystemAdmin (platform-wide) skips the check;
|
|
29
|
+
// TenantAdmin/Admin must have an active membership in the target's own
|
|
30
|
+
// tenantId.
|
|
31
|
+
//
|
|
16
32
|
// State-Transitions:
|
|
17
33
|
// Restricted → Active ✓
|
|
18
34
|
// Active → ... ✗ 422 not_restricted (Idempotenz-Guard)
|
|
@@ -20,10 +36,25 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
20
36
|
// Deleted → ... ✗ 422 not_restricted
|
|
21
37
|
export const liftRestrictionWrite = defineWriteHandler({
|
|
22
38
|
name: "lift-restriction",
|
|
23
|
-
schema: z.object({ userId: z.string() }),
|
|
39
|
+
schema: z.object({ userId: z.string().uuid() }),
|
|
24
40
|
access: { roles: access.admin },
|
|
25
41
|
handler: async (event, ctx) => {
|
|
26
42
|
const targetUserId = event.payload.userId;
|
|
43
|
+
|
|
44
|
+
if (!isSystemAdminActor(event.user)) {
|
|
45
|
+
const membership = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
|
|
46
|
+
userId: targetUserId,
|
|
47
|
+
tenantId: event.user.tenantId,
|
|
48
|
+
});
|
|
49
|
+
if (!membership) {
|
|
50
|
+
return writeFailure(
|
|
51
|
+
new AccessDeniedError({
|
|
52
|
+
details: { reason: "target_user_not_in_admin_tenant" },
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
|
|
28
59
|
id: targetUserId,
|
|
29
60
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { downloadAttemptsTable } from "../schema/download-attempt";
|
|
5
6
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
|
|
6
7
|
// DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import {
|
|
4
4
|
AccessDeniedError,
|
|
5
5
|
UnprocessableError,
|
|
6
6
|
writeFailure,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { tenantMembershipsTable } from "../../tenant";
|
|
9
10
|
import { USER_STATUS, userTable } from "../../user";
|
|
11
|
+
import { isAdminActor, isSystemAdminActor } from "../lib/is-admin-actor";
|
|
10
12
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
11
13
|
|
|
12
14
|
// POST /api/user/restrict (S2.U6) — DSGVO Art. 18 Account-Freeze.
|
|
@@ -25,6 +27,16 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
25
27
|
// sessionChecker and they can no longer call this (or any) endpoint
|
|
26
28
|
// themselves; only an operator path reaches it at that point.
|
|
27
29
|
//
|
|
30
|
+
// Tenant scope: `isAdminActor` accepts access.admin, which includes
|
|
31
|
+
// TenantAdmin — tenant-scoped, even though the User-entity lookup below
|
|
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
|
|
36
|
+
// has never been a member of tenant A. Only SystemAdmin (platform-wide)
|
|
37
|
+
// skips the check; TenantAdmin/Admin must have an active membership in
|
|
38
|
+
// the target's own tenantId.
|
|
39
|
+
//
|
|
28
40
|
// State-Transitions:
|
|
29
41
|
// Active → Restricted ✓ (dieser Handler)
|
|
30
42
|
// Restricted → Restricted ✗ 422 already_restricted (Idempotenz-Guard)
|
|
@@ -32,19 +44,36 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
|
32
44
|
// Deleted → ... ✗ 422 user_not_in_active_state
|
|
33
45
|
export const restrictAccountWrite = defineWriteHandler({
|
|
34
46
|
name: "restrict-account",
|
|
35
|
-
schema: z.object({ userId: z.string().optional() }),
|
|
47
|
+
schema: z.object({ userId: z.string().uuid().optional() }),
|
|
36
48
|
access: { openToAll: true },
|
|
37
49
|
handler: async (event, ctx) => {
|
|
38
50
|
const targetUserId = event.payload.userId ?? event.user.id;
|
|
39
51
|
if (targetUserId !== event.user.id) {
|
|
40
|
-
|
|
41
|
-
if (!isAdmin) {
|
|
52
|
+
if (!isAdminActor(event.user)) {
|
|
42
53
|
return writeFailure(
|
|
43
54
|
new AccessDeniedError({
|
|
44
55
|
details: { reason: "admin_required_for_other_user" },
|
|
45
56
|
}),
|
|
46
57
|
);
|
|
47
58
|
}
|
|
59
|
+
// Same cross-tenant guard as lift-restriction.write.ts: only
|
|
60
|
+
// SystemAdmin (platform-wide) may target a user without an active
|
|
61
|
+
// membership in the caller's own tenant. Without this, a TenantAdmin
|
|
62
|
+
// from tenant A could restrict — and force-revoke the sessions of —
|
|
63
|
+
// a user who has never been a member of tenant A.
|
|
64
|
+
if (!isSystemAdminActor(event.user)) {
|
|
65
|
+
const membership = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
|
|
66
|
+
userId: targetUserId,
|
|
67
|
+
tenantId: event.user.tenantId,
|
|
68
|
+
});
|
|
69
|
+
if (!membership) {
|
|
70
|
+
return writeFailure(
|
|
71
|
+
new AccessDeniedError({
|
|
72
|
+
details: { reason: "target_user_not_in_admin_tenant" },
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
48
77
|
}
|
|
49
78
|
|
|
50
79
|
// ctx.db.raw weil User-Entity tenant-agnostisch ist (analog
|
|
@@ -77,6 +77,8 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
77
77
|
hookCallsAttempted: result.hookCallsAttempted,
|
|
78
78
|
errorCount: result.errors.length,
|
|
79
79
|
errors: result.errors,
|
|
80
|
+
incompleteCount: result.incomplete.length,
|
|
81
|
+
incomplete: result.incomplete,
|
|
80
82
|
},
|
|
81
83
|
};
|
|
82
84
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
|
|
4
|
+
const ADMIN_ROLES: ReadonlySet<string> = new Set(access.admin);
|
|
5
|
+
const SYSTEM_ADMIN_ROLES: ReadonlySet<string> = new Set(access.systemAdmin);
|
|
6
|
+
|
|
7
|
+
// Reused by handlers that are `openToAll: true` but still need a manual
|
|
8
|
+
// self-or-admin gate (e.g. restrict-account.write.ts) — Set-lookup instead
|
|
9
|
+
// of the O(n·m) `roles.some(role => access.admin.includes(role))` re-scan.
|
|
10
|
+
export function isAdminActor(user: Pick<SessionUser, "roles">): boolean {
|
|
11
|
+
return user.roles.some((role) => ADMIN_ROLES.has(role));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Platform-wide operator role — exempt from the cross-tenant membership
|
|
15
|
+
// check that otherwise gates the tenant-scoped admin roles (TenantAdmin/
|
|
16
|
+
// Admin) before they act on another tenant's user.
|
|
17
|
+
export function isSystemAdminActor(user: Pick<SessionUser, "roles">): boolean {
|
|
18
|
+
return user.roles.some((role) => SYSTEM_ADMIN_ROLES.has(role));
|
|
19
|
+
}
|
|
@@ -142,6 +142,13 @@ export interface ForgetCleanupIncomplete {
|
|
|
142
142
|
readonly tenantId: TenantId;
|
|
143
143
|
readonly entityName: string;
|
|
144
144
|
readonly reason: string;
|
|
145
|
+
/** True when a LATER hook in the same user's sub-tx threw, rolling back
|
|
146
|
+
* the DB rows (including the status flip) — but this hook's own
|
|
147
|
+
* non-transactional side effect (e.g. an already-issued S3 delete,
|
|
148
|
+
* a provider API call) already ran and does NOT get undone by that
|
|
149
|
+
* rollback. The user is still DeletionRequested and will retry, but
|
|
150
|
+
* this partial external effect needs its own operator follow-up. */
|
|
151
|
+
readonly rolledBack: boolean;
|
|
145
152
|
}
|
|
146
153
|
|
|
147
154
|
export interface RunForgetCleanupResult {
|
|
@@ -168,6 +175,15 @@ interface HookEntry {
|
|
|
168
175
|
// hook that nulls that column. See custom-fields wire-user-data-rights.ts.
|
|
169
176
|
const HOOK_ORDER_DEFAULT = EXT_USER_DATA_ORDER.DEFAULT;
|
|
170
177
|
|
|
178
|
+
// Hook-reported reasons are external input (provider error messages can embed
|
|
179
|
+
// user-controlled filenames/emails) — strip newlines before a single-line
|
|
180
|
+
// console.warn so a crafted reason can't forge extra log lines, and cap the
|
|
181
|
+
// length against a log-flood. The unsanitized reason still lands in the
|
|
182
|
+
// returned `incomplete` entry untouched.
|
|
183
|
+
export function sanitizeReasonForLog(reason: string): string {
|
|
184
|
+
return reason.replace(/[\r\n]+/g, " ").slice(0, 500);
|
|
185
|
+
}
|
|
186
|
+
|
|
171
187
|
export async function runForgetCleanup(
|
|
172
188
|
args: RunForgetCleanupArgs,
|
|
173
189
|
): Promise<RunForgetCleanupResult> {
|
|
@@ -177,7 +193,7 @@ export async function runForgetCleanup(
|
|
|
177
193
|
|
|
178
194
|
// Step 1: Find users with expired grace period.
|
|
179
195
|
const dueUsers = await selectUsersDueForForgetCleanup(
|
|
180
|
-
db,
|
|
196
|
+
db as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
|
|
181
197
|
USER_STATUS.DeletionRequested,
|
|
182
198
|
now.toString(),
|
|
183
199
|
);
|
|
@@ -285,7 +301,7 @@ async function processUser(args: {
|
|
|
285
301
|
}): Promise<ProcessUserResult> {
|
|
286
302
|
const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
|
|
287
303
|
const errors: ForgetCleanupError[] = [];
|
|
288
|
-
const incomplete: ForgetCleanupIncomplete[] = [];
|
|
304
|
+
const incomplete: Omit<ForgetCleanupIncomplete, "rolledBack">[] = [];
|
|
289
305
|
let hookCallsAttempted = 0;
|
|
290
306
|
|
|
291
307
|
// Atom 5b — userEmail VOR der Tx cachen. user-Hook (user-data-rights-
|
|
@@ -374,7 +390,7 @@ async function processUser(args: {
|
|
|
374
390
|
});
|
|
375
391
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for partial hook completion
|
|
376
392
|
console.warn(
|
|
377
|
-
`[user-data-rights:run-forget-cleanup] hook incomplete userId=${userId} tenantId=${tenantId} entityName=${entry.entityName} reason=${hookResult.reason}`,
|
|
393
|
+
`[user-data-rights:run-forget-cleanup] hook incomplete userId=${userId} tenantId=${tenantId} entityName=${entry.entityName} reason=${sanitizeReasonForLog(hookResult.reason)}`,
|
|
378
394
|
);
|
|
379
395
|
}
|
|
380
396
|
}
|
|
@@ -431,10 +447,14 @@ async function processUser(args: {
|
|
|
431
447
|
success: txSucceeded,
|
|
432
448
|
hookCallsAttempted,
|
|
433
449
|
errors,
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
450
|
+
// Reported either way — a rolled-back sub-tx doesn't undo an
|
|
451
|
+
// "incomplete" hook's own non-transactional side effect (S3 delete,
|
|
452
|
+
// provider call), so dropping the report entirely would hide exactly
|
|
453
|
+
// the case its own reason string describes ("would report a
|
|
454
|
+
// not-yet-deleted user as partially cleaned up" — true of the DB rows,
|
|
455
|
+
// not of that external effect). `rolledBack` lets the operator tell
|
|
456
|
+
// the two situations apart instead of losing the signal outright.
|
|
457
|
+
incomplete: incomplete.map((i) => ({ ...i, rolledBack: !txSucceeded })),
|
|
438
458
|
userEmailBeforeDelete,
|
|
439
459
|
userLocaleBeforeDelete,
|
|
440
460
|
tenantIdsBeforeDelete,
|