@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
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
sessionRevokedSchema,
|
|
14
14
|
} from "../session-revoked-event";
|
|
15
15
|
|
|
16
|
+
const REVOKE_ALL_SESSIONS_REASON =
|
|
17
|
+
"revokes the target user's sessions in every tenant and appends the audit event on the SYSTEM stream";
|
|
18
|
+
|
|
16
19
|
// Mass-revoke ALL live sessions for a target user — privileged-only.
|
|
17
20
|
// Used by user-data-rights:restrict-account for account-freeze (DSGVO
|
|
18
21
|
// Art. 18) and potentially other ops flows ("ban user", "compromised
|
|
@@ -37,9 +40,13 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
37
40
|
description:
|
|
38
41
|
"Irreversibly signs a named user out of all their live sessions, across every tenant unless a tenantId narrows it; use it for operator actions such as freezing or banning an account.",
|
|
39
42
|
agent: { risk: "high" },
|
|
43
|
+
escapeHatch: {
|
|
44
|
+
reason: REVOKE_ALL_SESSIONS_REASON,
|
|
45
|
+
},
|
|
40
46
|
handler: async (event, ctx) => {
|
|
47
|
+
const runner = ctx.db.unsafeRaw(REVOKE_ALL_SESSIONS_REASON);
|
|
41
48
|
const updated = await updateMany<{ id: string }>(
|
|
42
|
-
|
|
49
|
+
runner,
|
|
43
50
|
userSessionTable,
|
|
44
51
|
{ revokedAt: Temporal.Now.instant() },
|
|
45
52
|
{
|
|
@@ -79,7 +86,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
79
86
|
sessionIds: updated.map((row) => row.id),
|
|
80
87
|
});
|
|
81
88
|
const reqCtx = requestContext.get();
|
|
82
|
-
await append(
|
|
89
|
+
await append(runner, {
|
|
83
90
|
aggregateId: generateId(),
|
|
84
91
|
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
85
92
|
tenantId: SYSTEM_TENANT_ID,
|
|
@@ -15,15 +15,25 @@ type TenantLifecycleRow = {
|
|
|
15
15
|
gracePeriodEnd: Temporal.Instant | null;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const CANCEL_DESTRUCTION_TENANT_ROW_REASON =
|
|
19
|
+
"reads the caller's tenant row, whose tenant_id is the creating tenant, not the row's own id";
|
|
20
|
+
|
|
18
21
|
export const cancelDestructionWrite = defineWriteHandler({
|
|
19
22
|
name: "cancel-destruction",
|
|
20
23
|
schema: z.object({}),
|
|
21
24
|
access: { roles: ["TenantOwner", "Admin"] },
|
|
22
25
|
description:
|
|
23
26
|
"Returns a tenant whose destruction was requested back to active and clears the grace period; use it to undo a close-account request while the grace period is still running.",
|
|
27
|
+
escapeHatch: {
|
|
28
|
+
reason: CANCEL_DESTRUCTION_TENANT_ROW_REASON,
|
|
29
|
+
},
|
|
24
30
|
handler: async (event, ctx) => {
|
|
25
31
|
const tenantId = event.user.tenantId;
|
|
26
|
-
const row = await fetchOne<TenantLifecycleRow>(
|
|
32
|
+
const row = await fetchOne<TenantLifecycleRow>(
|
|
33
|
+
ctx.db.unsafeRaw(CANCEL_DESTRUCTION_TENANT_ROW_REASON),
|
|
34
|
+
tenantTable,
|
|
35
|
+
{ id: tenantId },
|
|
36
|
+
);
|
|
27
37
|
if (!row) {
|
|
28
38
|
return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
|
|
29
39
|
}
|
|
@@ -18,6 +18,9 @@ type TenantLifecycleRow = {
|
|
|
18
18
|
gracePeriodEnd: Temporal.Instant | null;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
const REQUEST_DESTRUCTION_TENANT_ROW_REASON =
|
|
22
|
+
"reads the caller's tenant row, whose tenant_id is the creating tenant, its compliance profile, and revokes its sessions via DbRunner helpers";
|
|
23
|
+
|
|
21
24
|
export const requestDestructionWrite = defineWriteHandler({
|
|
22
25
|
name: "request-destruction",
|
|
23
26
|
schema: z.object({}),
|
|
@@ -25,9 +28,13 @@ export const requestDestructionWrite = defineWriteHandler({
|
|
|
25
28
|
description:
|
|
26
29
|
"Puts the caller's own tenant into destroyRequested, starts the compliance-profile grace period after which its data is erased, and revokes every session in the tenant; use it when an account owner asks to close their account.",
|
|
27
30
|
agent: { risk: "high" },
|
|
31
|
+
escapeHatch: {
|
|
32
|
+
reason: REQUEST_DESTRUCTION_TENANT_ROW_REASON,
|
|
33
|
+
},
|
|
28
34
|
handler: async (event, ctx) => {
|
|
29
35
|
const tenantId = event.user.tenantId;
|
|
30
|
-
const
|
|
36
|
+
const runner = ctx.db.unsafeRaw(REQUEST_DESTRUCTION_TENANT_ROW_REASON);
|
|
37
|
+
const row = await fetchOne<TenantLifecycleRow>(runner, tenantTable, { id: tenantId });
|
|
31
38
|
if (!row) {
|
|
32
39
|
return writeFailure(new UnprocessableError("tenant_not_found", { details: { tenantId } }));
|
|
33
40
|
}
|
|
@@ -39,7 +46,7 @@ export const requestDestructionWrite = defineWriteHandler({
|
|
|
39
46
|
);
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
const { profile } = await resolveProfileForTenant({ db:
|
|
49
|
+
const { profile } = await resolveProfileForTenant({ db: runner, tenantId });
|
|
43
50
|
const T = getTemporal();
|
|
44
51
|
const gracePeriodEnd = addDurationSpec(T.Now.instant(), profile.tenantDestroyGracePeriod);
|
|
45
52
|
|
|
@@ -71,7 +78,7 @@ export const requestDestructionWrite = defineWriteHandler({
|
|
|
71
78
|
});
|
|
72
79
|
|
|
73
80
|
if (await ctx.hasFeature("sessions")) {
|
|
74
|
-
await revokeTenantSessions(
|
|
81
|
+
await revokeTenantSessions(runner, tenantId);
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
return {
|
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
buildEntityTable,
|
|
47
47
|
createEventStoreExecutor,
|
|
48
48
|
createTenantDb,
|
|
49
|
-
type DbConnection,
|
|
50
49
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
51
50
|
import {
|
|
52
51
|
defineEntityCreateHandler,
|
|
@@ -373,6 +372,8 @@ export function createTierEngineFeature<
|
|
|
373
372
|
// neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
|
|
374
373
|
if (opts.defaultTier !== undefined) {
|
|
375
374
|
const defaultTier = opts.defaultTier;
|
|
375
|
+
const autoDefaultTierHookReason =
|
|
376
|
+
"seeds the default tier-assignment stream of the tenant just created";
|
|
376
377
|
r.hook(
|
|
377
378
|
"postSave",
|
|
378
379
|
{ allOf: "tenant" },
|
|
@@ -392,26 +393,17 @@ export function createTierEngineFeature<
|
|
|
392
393
|
|
|
393
394
|
// tenant is an r.systemScope() feature, so this cross-feature hook gets a
|
|
394
395
|
// fail-closed ctx.db — reach for ctx.systemDb instead (see entity-handlers.ts).
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
: ctx.db
|
|
396
|
+
// Event-store paths need the raw DbConnection — TenantDb only exposes
|
|
397
|
+
// select/insert/update/delete, not execute (db.execute(sql`SELECT pg_notify(...)`) would TypeError).
|
|
398
|
+
const rawDb = ctx.systemDb
|
|
399
|
+
? ctx.systemDb.unsafeRaw(autoDefaultTierHookReason)
|
|
400
|
+
: ctx.db && "unsafeRaw" in ctx.db
|
|
401
|
+
? ctx.db.unsafeRaw(autoDefaultTierHookReason)
|
|
402
|
+
: undefined;
|
|
400
403
|
// skip: defensive — inTransaction phase always sets db, but AppContext's
|
|
401
404
|
// type makes it optional. Throwing would be overreach (lifecycle
|
|
402
405
|
// blocking), silent-skip is defensive-soft.
|
|
403
|
-
if (!
|
|
404
|
-
|
|
405
|
-
// db is a TenantDb in the inTransaction phase (tenant-scoped proxy over
|
|
406
|
-
// the real tx). Event-store paths need the raw DbConnection — TenantDb
|
|
407
|
-
// only exposes select/insert/update/delete, not execute
|
|
408
|
-
// (event-store-append.ts:102 calls db.execute(sql`SELECT pg_notify(...)`)
|
|
409
|
-
// → TypeError otherwise). Pattern matches signup-confirm.write.ts:107
|
|
410
|
-
// (.raw), not `as DbConnection` — that's a type-lie that only crashes on
|
|
411
|
-
// the first .execute() call.
|
|
412
|
-
// skip: defensive — should never trip in the inTransaction phase.
|
|
413
|
-
if (!("raw" in db)) return;
|
|
414
|
-
const rawDb = db.raw as DbConnection; // @cast-boundary db-runner
|
|
406
|
+
if (!rawDb) return;
|
|
415
407
|
|
|
416
408
|
// Idempotency: stream-existence-check vor create. Pattern aus
|
|
417
409
|
// seedTenant.ts. Bei re-replay (rebuild) nicht versionsbumpen.
|
|
@@ -438,7 +430,7 @@ export function createTierEngineFeature<
|
|
|
438
430
|
tdb,
|
|
439
431
|
);
|
|
440
432
|
},
|
|
441
|
-
{ phase: HookPhases.inTransaction },
|
|
433
|
+
{ phase: HookPhases.inTransaction, escapeHatch: { reason: autoDefaultTierHookReason } },
|
|
442
434
|
);
|
|
443
435
|
}
|
|
444
436
|
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
3
|
-
buildEntityTable,
|
|
4
|
-
createTenantDb,
|
|
5
|
-
type DbConnection,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { buildEntityTable, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
7
3
|
import { defineQueryHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
4
|
import { z } from "zod";
|
|
9
5
|
import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
|
|
@@ -15,15 +11,20 @@ import { type TierAssignmentRow, tierAssignmentEntity } from "../entity";
|
|
|
15
11
|
|
|
16
12
|
const tierAssignmentTable = buildEntityTable("tier-assignment", tierAssignmentEntity);
|
|
17
13
|
|
|
14
|
+
const GET_TENANT_TIER_REASON = "SystemAdmin reads the tier of the tenant named in the payload";
|
|
15
|
+
|
|
18
16
|
export const getTenantTierQuery = defineQueryHandler({
|
|
19
17
|
name: "get-tenant-tier",
|
|
20
18
|
description:
|
|
21
19
|
"Returns the tier-assignment of any given tenant including where it came from (manual grant, billing sync or signup default), or null; use it to look up another tenant's plan.",
|
|
22
20
|
schema: z.object({ tenantId: z.string().min(1) }),
|
|
23
21
|
access: { roles: ["SystemAdmin"] },
|
|
22
|
+
escapeHatch: {
|
|
23
|
+
reason: GET_TENANT_TIER_REASON,
|
|
24
|
+
},
|
|
24
25
|
handler: async (query, ctx) => {
|
|
25
26
|
const tenantId = query.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
26
|
-
const tdb = createTenantDb(ctx.db.
|
|
27
|
+
const tdb = createTenantDb(ctx.db.unsafeRaw(GET_TENANT_TIER_REASON), tenantId, "system");
|
|
27
28
|
const row = await fetchOne<TierAssignmentRow>(tdb, tierAssignmentTable, { tenantId });
|
|
28
29
|
return row ?? null;
|
|
29
30
|
},
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
buildEntityTable,
|
|
4
4
|
createEventStoreExecutor,
|
|
5
5
|
createTenantDb,
|
|
6
|
-
type DbConnection,
|
|
7
6
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
8
7
|
import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
8
|
import { z } from "zod";
|
|
@@ -39,6 +38,8 @@ const executor = createEventStoreExecutor(tierAssignmentTable, tierAssignmentEnt
|
|
|
39
38
|
entityName: "tier-assignment",
|
|
40
39
|
});
|
|
41
40
|
|
|
41
|
+
const SET_TENANT_TIER_REASON = "SystemAdmin assigns the tier of the tenant named in the payload";
|
|
42
|
+
|
|
42
43
|
export type SetTenantTierOptions = {
|
|
43
44
|
/** Nach erfolgreichem Write aufgerufen, damit feature.ts den Resolver-
|
|
44
45
|
* Cache aktualisieren kann (der Executor-Write feuert den postSave-Hook
|
|
@@ -66,9 +67,12 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
66
67
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
67
68
|
}),
|
|
68
69
|
access: { roles: ["SystemAdmin"] },
|
|
70
|
+
escapeHatch: {
|
|
71
|
+
reason: SET_TENANT_TIER_REASON,
|
|
72
|
+
},
|
|
69
73
|
handler: async (event, ctx) => {
|
|
70
74
|
const tenantId = event.payload.tenantId as TenantId; // @cast-boundary engine-bridge
|
|
71
|
-
const rawDb = ctx.db.
|
|
75
|
+
const rawDb = ctx.db.unsafeRaw(SET_TENANT_TIER_REASON);
|
|
72
76
|
const tdb = createTenantDb(rawDb, tenantId, "system");
|
|
73
77
|
const systemUser = { ...event.user, tenantId };
|
|
74
78
|
const tier = event.payload.tier;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// fw#2858 — userEntity carries `tenancy: "global"` (a person's platform-wide
|
|
2
|
+
// identity, shared across tenants). Reads through ctx.db.global(userTable)
|
|
3
|
+
// work cross-tenant with no escapeHatch; a write attempt through the same
|
|
4
|
+
// accessor is denied without one — userTable stays managed (executor-only),
|
|
5
|
+
// so a hand-written write bypassing the executor must still be gated.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import {
|
|
10
|
+
createTestUser,
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
TestUsers,
|
|
14
|
+
unsafeCreateEntityTable,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { UserHandlers } from "../constants";
|
|
18
|
+
import { createUserFeature } from "../feature";
|
|
19
|
+
import { userEntity, userTable } from "../schema/user";
|
|
20
|
+
|
|
21
|
+
const globalUserAccessFeature = defineFeature("user-global-access-probe", (r) => {
|
|
22
|
+
r.queryHandler({
|
|
23
|
+
name: "read-via-global",
|
|
24
|
+
schema: z.object({ id: z.string() }),
|
|
25
|
+
access: { roles: ["User"] },
|
|
26
|
+
handler: async (query, ctx) => {
|
|
27
|
+
const row = await ctx.db
|
|
28
|
+
.global(userTable)
|
|
29
|
+
.fetchOne<{ id: string; email: string }>({ id: query.payload.id });
|
|
30
|
+
return row ?? null;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
r.writeHandler({
|
|
35
|
+
name: "write-via-global-without-hatch",
|
|
36
|
+
schema: z.object({}),
|
|
37
|
+
access: { roles: ["User"] },
|
|
38
|
+
handler: async (_event, ctx) => {
|
|
39
|
+
const accessor = ctx.db.global(userTable);
|
|
40
|
+
// @ts-expect-error executor-only brand — userTable's managed write methods
|
|
41
|
+
// are type-hidden through db.global(); the runtime object still carries
|
|
42
|
+
// them, so the escapeHatch gate below is the actual enforcement.
|
|
43
|
+
const row = await accessor.insertOne({ displayName: "hack", email: "hack@example.com" });
|
|
44
|
+
return { isSuccess: true as const, data: row };
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let stack: TestStack;
|
|
50
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
stack = await setupTestStack({ features: [createUserFeature(), globalUserAccessFeature] });
|
|
54
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await stack.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("ctx.db.global(userTable) — cross-tenant identity access (fw#2858)", () => {
|
|
62
|
+
test("a handler in tenant A reads the user via db.global(userTable).fetchOne", async () => {
|
|
63
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
64
|
+
UserHandlers.create,
|
|
65
|
+
{ email: "global-read@example.com", displayName: "Global Read", passwordHash: "seeded-hash" },
|
|
66
|
+
systemAdmin,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const readerInTenantA = createTestUser({
|
|
70
|
+
id: crypto.randomUUID(),
|
|
71
|
+
tenantId: "11111111-0000-4000-8000-0000000000a1",
|
|
72
|
+
roles: ["User"],
|
|
73
|
+
});
|
|
74
|
+
const row = await stack.http.queryOk<{ id: string; email: string } | null>(
|
|
75
|
+
"user-global-access-probe:query:read-via-global",
|
|
76
|
+
{ id: created.id },
|
|
77
|
+
readerInTenantA,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(row?.id).toBe(created.id);
|
|
81
|
+
expect(row?.email).toBe("global-read@example.com");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a write attempt through ctx.db.global(userTable) without escapeHatch fails with access_denied", async () => {
|
|
85
|
+
const writer = createTestUser({
|
|
86
|
+
id: crypto.randomUUID(),
|
|
87
|
+
tenantId: "11111111-0000-4000-8000-0000000000a2",
|
|
88
|
+
roles: ["User"],
|
|
89
|
+
});
|
|
90
|
+
const err = await stack.http.writeErr(
|
|
91
|
+
"user-global-access-probe:write:write-via-global-without-hatch",
|
|
92
|
+
{},
|
|
93
|
+
writer,
|
|
94
|
+
);
|
|
95
|
+
expect(err.code).toBe("access_denied");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -2,6 +2,7 @@ import { asEntityTableMeta, asRawClient, fetchOne } from "@cosmicdrift/kumiko-fr
|
|
|
2
2
|
import {
|
|
3
3
|
acquireNamespacedAdvisoryLock,
|
|
4
4
|
createEventStoreExecutor,
|
|
5
|
+
type DbRunner,
|
|
5
6
|
type TenantDb,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
7
8
|
import {
|
|
@@ -52,13 +53,13 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
|
|
|
52
53
|
return isActiveUserRow(row) && parseRoles(row.roles).includes("SystemAdmin");
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
async function countOtherActiveSystemAdmins(db:
|
|
56
|
+
async function countOtherActiveSystemAdmins(db: DbRunner, excludeUserId: string): Promise<number> {
|
|
56
57
|
const tableName = asEntityTableMeta(userTable)?.tableName;
|
|
57
58
|
if (!tableName) {
|
|
58
59
|
throw new InternalError({ message: "user read table meta missing" });
|
|
59
60
|
}
|
|
60
61
|
// kumiko-lint-ignore raw-sql jsonb @> prefilter for SystemAdmin roster under advisory lock
|
|
61
|
-
const rows = (await asRawClient(db
|
|
62
|
+
const rows = (await asRawClient(db).unsafe(
|
|
62
63
|
`SELECT id, roles, status, is_deleted AS "isDeleted"
|
|
63
64
|
FROM ${quoteIdent(tableName)}
|
|
64
65
|
WHERE is_deleted = false
|
|
@@ -128,7 +129,17 @@ export async function applyUserRolesUpdate(
|
|
|
128
129
|
// Lock before count+update so two concurrent demotions cannot both
|
|
129
130
|
// observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
|
|
130
131
|
await acquireNamespacedAdvisoryLock(db, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
131
|
-
|
|
132
|
+
// user is r.systemScope() — the count spans every tenant's SystemAdmins,
|
|
133
|
+
// so it needs the raw runner, not the (still tenant-shaped) `db` above.
|
|
134
|
+
if (!ctx.systemDb) {
|
|
135
|
+
throw new InternalError({ message: "user:update-roles requires ctx.systemDb" });
|
|
136
|
+
}
|
|
137
|
+
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
|
|
138
|
+
ctx.systemDb.unsafeRaw(
|
|
139
|
+
"jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
|
|
140
|
+
),
|
|
141
|
+
event.payload.id,
|
|
142
|
+
);
|
|
132
143
|
if (otherActiveSystemAdmins === 0) {
|
|
133
144
|
return writeFailure(
|
|
134
145
|
new ConflictError({
|
|
@@ -15,6 +15,9 @@ import { applyUserRolesUpdate } from "./update-roles";
|
|
|
15
15
|
|
|
16
16
|
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
17
17
|
|
|
18
|
+
const SYSTEM_ADMIN_DEMOTION_COUNT_REASON =
|
|
19
|
+
"on a SystemAdmin demotion, a jsonb @> prefilter over the global users table counts remaining active SystemAdmins";
|
|
20
|
+
|
|
18
21
|
// Users can update their OWN profile; SystemAdmin/system can update anyone.
|
|
19
22
|
// Handler-level access is openToAll — the row guard below is the actual gate,
|
|
20
23
|
// and field-level access (passwordHash/email write-locked to "privileged")
|
|
@@ -40,9 +43,19 @@ export const updateWrite = defineWriteHandler({
|
|
|
40
43
|
roles: rolesInputSchema.optional(),
|
|
41
44
|
}),
|
|
42
45
|
}),
|
|
43
|
-
access: {
|
|
46
|
+
access: {
|
|
47
|
+
openToAll: {
|
|
48
|
+
reason:
|
|
49
|
+
"Any signed-in user edits their own profile and privileged actors edit any user; " +
|
|
50
|
+
"the self-or-privileged check lives in the handler body, not in userEntity.access.write.",
|
|
51
|
+
personalData: "tenant-members",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
44
54
|
description:
|
|
45
55
|
"Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
|
|
56
|
+
escapeHatch: {
|
|
57
|
+
reason: SYSTEM_ADMIN_DEMOTION_COUNT_REASON,
|
|
58
|
+
},
|
|
46
59
|
handler: async (event, ctx) => {
|
|
47
60
|
const isSelf = event.payload.id === event.user.id;
|
|
48
61
|
const isPrivileged = hasAccess(event.user, { roles: access.privileged });
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
PrincipalProfile,
|
|
4
|
+
PrincipalStatus,
|
|
5
|
+
PrincipalStatusPlugin,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
8
|
import { USER_STATUS, type UserStatus, userTable } from "./schema/user";
|
|
4
9
|
|
|
5
10
|
// Locked accounts whose live sessions must be refused. deletionRequested is
|
|
@@ -23,4 +28,20 @@ export const principalStatusPlugin: PrincipalStatusPlugin = {
|
|
|
23
28
|
if (!row) return "unknown";
|
|
24
29
|
return isPrincipalBlocked(row.status) ? "blocked" : "active";
|
|
25
30
|
},
|
|
31
|
+
|
|
32
|
+
// ctx.queryAsMember's global-roles/timezone/locale source — same fields
|
|
33
|
+
// login mints a session from (see login.write.ts gateBuildSession).
|
|
34
|
+
async resolveProfile(userId, { db }): Promise<PrincipalProfile | null> {
|
|
35
|
+
const row = await fetchOne<{ roles: unknown; timezone: string | null; locale: string | null }>(
|
|
36
|
+
db,
|
|
37
|
+
userTable,
|
|
38
|
+
{ id: userId },
|
|
39
|
+
);
|
|
40
|
+
if (!row) return null;
|
|
41
|
+
return {
|
|
42
|
+
globalRoles: parseRoles(row.roles),
|
|
43
|
+
...(row.timezone ? { timezone: row.timezone } : {}),
|
|
44
|
+
...(row.locale ? { locale: row.locale } : {}),
|
|
45
|
+
};
|
|
46
|
+
},
|
|
26
47
|
};
|
package/src/user/schema/user.ts
CHANGED
|
@@ -65,6 +65,9 @@ export const userEntity = createEntity({
|
|
|
65
65
|
// Tenant-independent identity aggregate — its event stream lives on
|
|
66
66
|
// SYSTEM_TENANT_ID instead of whichever tenant happened to create it (#497).
|
|
67
67
|
systemStream: true,
|
|
68
|
+
// Every row lives on SYSTEM_TENANT_ID; writes with another tenantId are
|
|
69
|
+
// rejected (fw#2858).
|
|
70
|
+
tenancy: "global",
|
|
68
71
|
fields: {
|
|
69
72
|
// Identity — anyone who can see the user can read the email, but only
|
|
70
73
|
// privileged roles (SYSTEM auth code, SystemAdmin) may change it.
|
|
@@ -178,7 +178,9 @@ describe("my-audit-log — kumiko-framework#1525: no ambient Temporal global", (
|
|
|
178
178
|
payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
179
179
|
user: alice,
|
|
180
180
|
} as Parameters<typeof myAuditLogQuery.handler>[0],
|
|
181
|
-
{ db: {
|
|
181
|
+
{ db: { unsafeRaw: () => stack.db } } as unknown as Parameters<
|
|
182
|
+
typeof myAuditLogQuery.handler
|
|
183
|
+
>[1],
|
|
182
184
|
);
|
|
183
185
|
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
184
186
|
} finally {
|
|
@@ -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 { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
3
|
import { z } from "zod";
|
|
@@ -6,6 +5,9 @@ import { isWithinGracePeriod } from "../../shared";
|
|
|
6
5
|
import { USER_STATUS, userTable } from "../../user";
|
|
7
6
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle";
|
|
8
7
|
|
|
8
|
+
const APPEND_LIFECYCLE_EVENT_REASON =
|
|
9
|
+
"appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
|
|
10
|
+
|
|
9
11
|
// POST /api/user/cancel-deletion (S2.U5).
|
|
10
12
|
//
|
|
11
13
|
// Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
|
|
@@ -23,14 +25,14 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
23
25
|
access: { openToAll: true },
|
|
24
26
|
description:
|
|
25
27
|
"Withdraws the calling user's own pending account-deletion request and puts the account back to active, accepted only while the grace period is still running.",
|
|
28
|
+
escapeHatch: {
|
|
29
|
+
reason: APPEND_LIFECYCLE_EVENT_REASON,
|
|
30
|
+
},
|
|
26
31
|
handler: async (event, ctx) => {
|
|
27
|
-
|
|
28
|
-
// ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
|
|
29
|
-
// muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
|
|
30
|
-
const row = await fetchOne<{
|
|
32
|
+
const row = await ctx.db.global(userTable).fetchOne<{
|
|
31
33
|
status: string;
|
|
32
34
|
gracePeriodEnd: Temporal.Instant | null;
|
|
33
|
-
}>(
|
|
35
|
+
}>({ id: event.user.id });
|
|
34
36
|
|
|
35
37
|
if (!row) {
|
|
36
38
|
return writeFailure(
|
|
@@ -52,12 +54,11 @@ export const cancelDeletionWrite = defineWriteHandler({
|
|
|
52
54
|
return writeFailure(new UnprocessableError("grace_period_expired"));
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
await updateUserLifecycle(ctx.db.
|
|
57
|
+
await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), event.user.id, {
|
|
56
58
|
status: USER_STATUS.Active,
|
|
57
59
|
gracePeriodEnd: null,
|
|
58
|
-
// #354/1:
|
|
59
|
-
//
|
|
60
|
-
// nicht mehr und kann keine zweite Grace-Period armen.
|
|
60
|
+
// #354/1: closes the replay-after-cancel window — a still-TTL-valid email
|
|
61
|
+
// token verified against the nulled requestId can no longer arm a second grace period.
|
|
61
62
|
pendingDeletionRequestId: null,
|
|
62
63
|
});
|
|
63
64
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { defineWriteHandler, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
3
|
import { z } from "zod";
|
|
@@ -25,9 +24,11 @@ async function readPendingDeletionRequestId(
|
|
|
25
24
|
userId: string,
|
|
26
25
|
): Promise<string | null> {
|
|
27
26
|
try {
|
|
28
|
-
const row = await
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
const row = await ctx.db
|
|
28
|
+
.global(userTable)
|
|
29
|
+
.fetchOne<{ pendingDeletionRequestId: string | null }>({
|
|
30
|
+
id: userId,
|
|
31
|
+
});
|
|
31
32
|
return row?.["pendingDeletionRequestId"] ?? null;
|
|
32
33
|
} catch {
|
|
33
34
|
return null;
|
|
@@ -54,7 +55,8 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
54
55
|
reason:
|
|
55
56
|
"startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
56
57
|
"...) to compute the grace period end — the anonymous token holder has no read access " +
|
|
57
|
-
"to that tenant-config projection."
|
|
58
|
+
"to that tenant-config projection. It also appends the user lifecycle event on the " +
|
|
59
|
+
"SYSTEM_TENANT_ID user stream.",
|
|
58
60
|
},
|
|
59
61
|
agent: { expose: false },
|
|
60
62
|
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
1
|
import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
3
2
|
import { createSystemUser, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
3
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -32,11 +31,9 @@ export async function startDeletionGracePeriod(
|
|
|
32
31
|
userId: string,
|
|
33
32
|
complianceTenantId: string,
|
|
34
33
|
): Promise<StartGracePeriodResult> {
|
|
35
|
-
const userRow = await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{ id: userId },
|
|
39
|
-
);
|
|
34
|
+
const userRow = await ctx.db
|
|
35
|
+
.global(userTable)
|
|
36
|
+
.fetchOne<{ status: string; email: string; locale: string | null }>({ id: userId });
|
|
40
37
|
if (!userRow) {
|
|
41
38
|
return {
|
|
42
39
|
ok: false,
|
|
@@ -66,10 +63,14 @@ export async function startDeletionGracePeriod(
|
|
|
66
63
|
const T = getTemporal();
|
|
67
64
|
const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
|
|
68
65
|
|
|
69
|
-
await updateUserLifecycle(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
await updateUserLifecycle(
|
|
67
|
+
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
68
|
+
userId,
|
|
69
|
+
{
|
|
70
|
+
status: USER_STATUS.DeletionRequested,
|
|
71
|
+
gracePeriodEnd,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
73
74
|
|
|
74
75
|
return {
|
|
75
76
|
ok: true,
|