@cosmicdrift/kumiko-bundled-features 0.280.0 → 0.282.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/handlers/confirm-token-flow.ts +9 -0
- package/src/auth-email-password/handlers/login.write.ts +12 -17
- package/src/auth-mfa/mfa-code-verifier.ts +9 -1
- package/src/auth-mfa/mfa-status-checker.ts +5 -0
- package/src/cap-overview/__tests__/caps-usage-escape-hatch.integration.test.ts +111 -0
- package/src/cap-overview/changes.json +6 -0
- package/src/cap-overview/handlers/caps-usage.query.ts +6 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +24 -23
- package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +5 -10
- package/src/custom-fields/db/queries/field-access.ts +3 -5
- package/src/custom-fields/db/queries/quota.ts +6 -5
- package/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
- package/src/custom-fields/lib/field-access.ts +5 -5
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +19 -2
- package/src/user-data-rights/handlers/deletion-grace-period.ts +19 -27
- package/src/user-data-rights/handlers/request-deletion.write.ts +15 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.282.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.282.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.282.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.282.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.282.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.282.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.282.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.282.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.282.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
26
|
createSystemUser,
|
|
27
|
+
declareEscapeHatch,
|
|
27
28
|
type HandlerContext,
|
|
28
29
|
type SessionUser,
|
|
29
30
|
SYSTEM_TENANT_ID,
|
|
@@ -69,6 +70,10 @@ export async function runConfirmTokenFlow<TSuccessData>(
|
|
|
69
70
|
expiresAtMs: number,
|
|
70
71
|
spec: ConfirmTokenFlowSpec<TSuccessData>,
|
|
71
72
|
): Promise<WriteResult<TSuccessData>> {
|
|
73
|
+
declareEscapeHatch({
|
|
74
|
+
reason:
|
|
75
|
+
"writes the confirming user's row via UserHandlers.update on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
|
|
76
|
+
});
|
|
72
77
|
if (!ctx.redis) {
|
|
73
78
|
return writeFailure(new InternalError({ message: spec.redisRequiredMessage }));
|
|
74
79
|
}
|
|
@@ -131,6 +136,10 @@ async function loadValidatedUser(
|
|
|
131
136
|
systemUser: SessionUser,
|
|
132
137
|
userId: string,
|
|
133
138
|
): Promise<(AuthUserRow & { version: number }) | null> {
|
|
139
|
+
declareEscapeHatch({
|
|
140
|
+
reason:
|
|
141
|
+
"reads the confirming user's row via UserQueries.findForAuth on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
|
|
142
|
+
});
|
|
134
143
|
const me = parseAuthUserRow(
|
|
135
144
|
await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
|
|
136
145
|
);
|
|
@@ -95,12 +95,9 @@ type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
|
|
|
95
95
|
|
|
96
96
|
/** Uniform response on any credential miss — burns argon2 cost (#774). */
|
|
97
97
|
export async function gateResolveAuthUser(
|
|
98
|
-
|
|
99
|
-
systemUser: SessionUser,
|
|
100
|
-
email: string,
|
|
98
|
+
found: AuthUserRow | null,
|
|
101
99
|
password: string,
|
|
102
100
|
): Promise<GateOutcome<AuthenticatableUserRow>> {
|
|
103
|
-
const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
|
|
104
101
|
if (!found?.passwordHash || found.isDeleted) {
|
|
105
102
|
await verifyDummyPassword(password);
|
|
106
103
|
return reject(invalidCredentials());
|
|
@@ -176,15 +173,9 @@ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefi
|
|
|
176
173
|
/** Pick membership (last-active preferred); merge global + tenant roles. */
|
|
177
174
|
export async function gateResolveMembership(
|
|
178
175
|
ctx: HandlerContext,
|
|
179
|
-
systemUser: SessionUser,
|
|
180
176
|
found: AuthUserRow,
|
|
177
|
+
memberships: ReadonlyArray<Membership>,
|
|
181
178
|
): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
|
|
182
|
-
// Still needed for candidate ORDER (preferred tenant first) — the actual
|
|
183
|
-
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
184
|
-
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
185
|
-
userId: found.id,
|
|
186
|
-
})) as Array<Membership>; // @cast-boundary db-runner
|
|
187
|
-
|
|
188
179
|
if (memberships.length === 0) {
|
|
189
180
|
return reject(noMembership());
|
|
190
181
|
}
|
|
@@ -296,12 +287,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
296
287
|
handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
|
|
297
288
|
const systemUser = createSystemUser(SYSTEM_USER_ID);
|
|
298
289
|
|
|
299
|
-
const
|
|
300
|
-
ctx,
|
|
301
|
-
systemUser,
|
|
302
|
-
event.payload.email,
|
|
303
|
-
event.payload.password,
|
|
290
|
+
const foundRow = parseAuthUserRow(
|
|
291
|
+
await ctx.queryAs(systemUser, UserQueries.findForAuth, { email: event.payload.email }),
|
|
304
292
|
);
|
|
293
|
+
const userGate = await gateResolveAuthUser(foundRow, event.payload.password);
|
|
305
294
|
if (!userGate.ok) return userGate.result;
|
|
306
295
|
const found = userGate.value;
|
|
307
296
|
|
|
@@ -323,7 +312,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
323
312
|
const statusGate = gateEnforceAccountStatus(found);
|
|
324
313
|
if (!statusGate.ok) return statusGate.result;
|
|
325
314
|
|
|
326
|
-
|
|
315
|
+
// Still needed for candidate ORDER (preferred tenant first) — the actual
|
|
316
|
+
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
317
|
+
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
318
|
+
userId: found.id,
|
|
319
|
+
})) as Array<Membership>; // @cast-boundary db-runner
|
|
320
|
+
|
|
321
|
+
const membershipGate = await gateResolveMembership(ctx, found, memberships);
|
|
327
322
|
if (!membershipGate.ok) return membershipGate.result;
|
|
328
323
|
const { chosen, mergedRoles } = membershipGate.value;
|
|
329
324
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
declareEscapeHatch,
|
|
4
|
+
type HandlerContext,
|
|
5
|
+
type TenantId,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
3
7
|
import { findUserMfaRow } from "./db/queries";
|
|
4
8
|
import { verifyMfaFactor } from "./verify-factor";
|
|
5
9
|
|
|
@@ -21,6 +25,10 @@ export type MfaCodeVerifier = (
|
|
|
21
25
|
// codes exist for.
|
|
22
26
|
export function createMfaCodeVerifier(): MfaCodeVerifier {
|
|
23
27
|
return async (ctx, userId, tenantId, code) => {
|
|
28
|
+
declareEscapeHatch({
|
|
29
|
+
reason:
|
|
30
|
+
"reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
|
|
31
|
+
});
|
|
24
32
|
const scopedDb = createTenantDb(
|
|
25
33
|
ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
|
|
26
34
|
tenantId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
access,
|
|
4
|
+
declareEscapeHatch,
|
|
4
5
|
type HandlerContext,
|
|
5
6
|
type SessionUser,
|
|
6
7
|
type TenantId,
|
|
@@ -38,6 +39,10 @@ export function createMfaStatusChecker(opts: {
|
|
|
38
39
|
readonly challengeTokenSecret: string;
|
|
39
40
|
}): MfaStatusChecker {
|
|
40
41
|
return async (ctx, userId, tenantId, roles) => {
|
|
42
|
+
declareEscapeHatch({
|
|
43
|
+
reason:
|
|
44
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
|
|
45
|
+
});
|
|
41
46
|
const scopedDb = createTenantDb(
|
|
42
47
|
ctx.db.unsafeRaw(
|
|
43
48
|
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// fw#2971 — withUnsafeRawGrant was un-exported, so an app-owned CapSpec.usage()
|
|
2
|
+
// provider's db.unsafeRaw() call was rejected even though caps:usage itself
|
|
3
|
+
// runs with r.systemScope(). caps-usage.query.ts now declares its own
|
|
4
|
+
// escapeHatch so those providers keep working. This proves the grant
|
|
5
|
+
// actually reaches a cap provider doing a real raw-SQL SUM aggregate (the
|
|
6
|
+
// shape a typed TenantDb helper like count() can't express) end to end
|
|
7
|
+
// through the real caps:usage HTTP handler.
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
10
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
testTenantId,
|
|
18
|
+
unsafeCreateEntityTable,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { billingFoundationFeature } from "../../billing-foundation";
|
|
21
|
+
import {
|
|
22
|
+
createComplianceProfilesFeature,
|
|
23
|
+
tenantComplianceProfileEntity,
|
|
24
|
+
} from "../../compliance-profiles";
|
|
25
|
+
import { createConfigFeature } from "../../config";
|
|
26
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
27
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
28
|
+
import { seedTenant } from "../../tenant/seeding";
|
|
29
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
30
|
+
import { TierEngineHandlers, tierAssignmentEntity, tierEngineFeature } from "../../tier-engine";
|
|
31
|
+
import { CapOverviewQueries } from "../constants";
|
|
32
|
+
import { createCapOverviewFeature } from "../feature";
|
|
33
|
+
import type { CapSpec } from "../types";
|
|
34
|
+
|
|
35
|
+
const RAW_USAGE_REASON =
|
|
36
|
+
"test: cap provider sums cap_overview_raw_usage_probe via raw SQL (fw#2971 regression)";
|
|
37
|
+
const TENANT_A = testTenantId(9101);
|
|
38
|
+
|
|
39
|
+
const rawSqlSumCap: CapSpec = {
|
|
40
|
+
id: "raw-sql-sum",
|
|
41
|
+
label: "test.cap.rawSqlSum",
|
|
42
|
+
limit: () => 1000,
|
|
43
|
+
usage: async (db, tenantId) => {
|
|
44
|
+
// @cast-boundary db-operator — unsafeRaw's DbRunner narrows to
|
|
45
|
+
// DbConnection|DbTx; this call site only ever runs outside a tx.
|
|
46
|
+
const raw = asRawClient(db.unsafeRaw(RAW_USAGE_REASON) as DbConnection);
|
|
47
|
+
const rows = await raw.unsafe<{ total: number }>(
|
|
48
|
+
"SELECT COALESCE(SUM(amount), 0)::int AS total FROM cap_overview_raw_usage_probe WHERE tenant_id = $1",
|
|
49
|
+
[tenantId],
|
|
50
|
+
);
|
|
51
|
+
return rows[0]?.total ?? 0;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let stack: TestStack;
|
|
56
|
+
let db: DbConnection;
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
stack = await setupTestStack({
|
|
60
|
+
features: [
|
|
61
|
+
createConfigFeature(),
|
|
62
|
+
createTenantFeature(),
|
|
63
|
+
createComplianceProfilesFeature(),
|
|
64
|
+
createTenantLifecycleFeature(),
|
|
65
|
+
billingFoundationFeature,
|
|
66
|
+
tierEngineFeature,
|
|
67
|
+
createCapOverviewFeature({ caps: [rawSqlSumCap] }),
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
db = stack.db;
|
|
71
|
+
await createEventsTable(db);
|
|
72
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
73
|
+
await unsafeCreateEntityTable(db, tierAssignmentEntity);
|
|
74
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
75
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
76
|
+
|
|
77
|
+
await asRawClient(db).unsafe(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS cap_overview_raw_usage_probe (
|
|
79
|
+
tenant_id text NOT NULL,
|
|
80
|
+
amount int NOT NULL
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
await asRawClient(db).unsafe(
|
|
84
|
+
"INSERT INTO cap_overview_raw_usage_probe (tenant_id, amount) VALUES ($1, $2), ($1, $3)",
|
|
85
|
+
[TENANT_A, 30, 12],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await seedTenant(db, { id: TENANT_A, key: `cap-overview-raw-${TENANT_A}`, name: "Tenant Raw" });
|
|
89
|
+
const ownerA = createTestUser({
|
|
90
|
+
id: 91011,
|
|
91
|
+
tenantId: TENANT_A,
|
|
92
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
93
|
+
});
|
|
94
|
+
await stack.http.writeOk(TierEngineHandlers.create, { tier: "pro" }, ownerA);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
await asRawClient(db).unsafe("DROP TABLE IF EXISTS cap_overview_raw_usage_probe");
|
|
99
|
+
await stack.cleanup();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("caps:usage's own escapeHatch grants a cap provider raw SQL", () => {
|
|
103
|
+
test("a cap provider's db.unsafeRaw() SUM aggregate runs successfully through caps:usage", async () => {
|
|
104
|
+
const memberA = createTestUser({ id: 91012, tenantId: TENANT_A, roles: ["User"] });
|
|
105
|
+
const result = await stack.http.queryOk<{
|
|
106
|
+
rows: readonly { id: string; used: number | null }[];
|
|
107
|
+
}>(CapOverviewQueries.capsUsage, {}, memberA);
|
|
108
|
+
const row = result.rows.find((r) => r.id === rawSqlSumCap.id);
|
|
109
|
+
expect(row?.used).toBe(42);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.282.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "caps:usage declares escapeHatch again so raw-SQL cap providers work",
|
|
6
|
+
"detail": "fw#2971 un-exported withUnsafeRawGrant, the mechanism app-owned CapSpec.usage() providers used (via caps:usage's system-mode db) to run raw SQL for aggregates TenantDb has no typed helper for (a SUM, unlike the COUNT(*)-only TenantDb.count() added in fw#2854). caps:usage's own defineQueryHandler(...) call now carries `escapeHatch: { reason: \"...\" }`, restoring the grant through the same mechanism every other handler-owned unsafeRaw use goes through. Cap providers that only need COUNT(*) should migrate to TenantDb.count(table, where) instead — it needs no escapeHatch at all."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.233.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -23,6 +23,12 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
|
|
|
23
23
|
"Returns the calling user's own tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (only SystemAdmin may target another tenant via tenantId).",
|
|
24
24
|
schema: z.object({ tenantId: z.string().min(1).optional() }),
|
|
25
25
|
access: { roles: MY_CAPS_ACCESS_ROLES },
|
|
26
|
+
// App-owned CapSpec.usage() providers get this handler's db; a SUM has
|
|
27
|
+
// no TenantDb helper (count() is COUNT(*)-only), so they need raw SQL.
|
|
28
|
+
escapeHatch: {
|
|
29
|
+
reason:
|
|
30
|
+
"cap-overview:caps:usage — grants app-owned CapSpec.usage() providers raw SQL for aggregates TenantDb has no typed helper for (e.g. a SUM)",
|
|
31
|
+
},
|
|
26
32
|
handler: async (query, ctx) => {
|
|
27
33
|
if (!ctx.systemDb) {
|
|
28
34
|
throw new InternalError({
|
|
@@ -192,9 +192,9 @@ async function appendDenialAuditEvent(
|
|
|
192
192
|
subjectKey: string,
|
|
193
193
|
subjectKind: SubjectIdInput["kind"],
|
|
194
194
|
denialCode: string,
|
|
195
|
+
denialAuditRunner: DbRunner | undefined,
|
|
195
196
|
): Promise<WriteFailure | null> {
|
|
196
|
-
|
|
197
|
-
if (!outsideTx) {
|
|
197
|
+
if (!denialAuditRunner) {
|
|
198
198
|
return writeFailure(
|
|
199
199
|
new InternalError({
|
|
200
200
|
message:
|
|
@@ -230,28 +230,23 @@ async function appendDenialAuditEvent(
|
|
|
230
230
|
// This event is the only proof the denial happened. Skipping
|
|
231
231
|
// runProjectionsForEvent here is fine — nothing projects
|
|
232
232
|
// crypto-shredding:event:forget-denied.
|
|
233
|
-
await append(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
userId: event.user.id,
|
|
249
|
-
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
250
|
-
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
251
|
-
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
252
|
-
},
|
|
233
|
+
await append(denialAuditRunner, {
|
|
234
|
+
aggregateId: generateId(),
|
|
235
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
236
|
+
// MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
|
|
237
|
+
// bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
|
|
238
|
+
tenantId: event.user.tenantId,
|
|
239
|
+
expectedVersion: 0,
|
|
240
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
241
|
+
eventVersion: eventDef.version,
|
|
242
|
+
payload,
|
|
243
|
+
metadata: {
|
|
244
|
+
userId: event.user.id,
|
|
245
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
246
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
247
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
253
248
|
},
|
|
254
|
-
);
|
|
249
|
+
});
|
|
255
250
|
return null;
|
|
256
251
|
}
|
|
257
252
|
|
|
@@ -316,6 +311,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
316
311
|
subjectKey,
|
|
317
312
|
raw.kind,
|
|
318
313
|
tenantScopeDenial.error.code,
|
|
314
|
+
ctx.dbOutsideTransaction?.unsafeRaw(
|
|
315
|
+
"denial audit append: names the prober's own tenant stream on the outside-transaction db",
|
|
316
|
+
),
|
|
319
317
|
);
|
|
320
318
|
return auditFailure ?? tenantScopeDenial;
|
|
321
319
|
}
|
|
@@ -335,6 +333,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
335
333
|
subjectKey,
|
|
336
334
|
raw.kind,
|
|
337
335
|
TARGET_RECORD_RETENTION_BLOCK_DELETE,
|
|
336
|
+
ctx.dbOutsideTransaction?.unsafeRaw(
|
|
337
|
+
"denial audit append: names the prober's own tenant stream on the outside-transaction db",
|
|
338
|
+
),
|
|
338
339
|
);
|
|
339
340
|
return auditFailure ?? retentionDenial;
|
|
340
341
|
}
|
|
@@ -43,19 +43,14 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
43
43
|
describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
44
44
|
test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
|
|
45
45
|
const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
|
|
46
|
-
const result = await selectSerializedFieldDefinition(
|
|
47
|
-
{ unsafeRaw: () => raw } as never,
|
|
48
|
-
"t1",
|
|
49
|
-
"entity",
|
|
50
|
-
"field",
|
|
51
|
-
);
|
|
46
|
+
const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
|
|
52
47
|
expect(result).toBe("sf1");
|
|
53
48
|
expect(raw.calls).toBe(2);
|
|
54
49
|
});
|
|
55
50
|
|
|
56
51
|
test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
|
|
57
52
|
const raw = fakeClient([closedConnectionError()], { n: 3 });
|
|
58
|
-
const result = await countTenantFieldDefinitions(
|
|
53
|
+
const result = await countTenantFieldDefinitions(raw, "t1");
|
|
59
54
|
expect(result).toBe(3);
|
|
60
55
|
expect(raw.calls).toBe(2);
|
|
61
56
|
});
|
|
@@ -90,9 +85,9 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
90
85
|
const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
|
|
91
86
|
serialized_field: "sf1",
|
|
92
87
|
});
|
|
93
|
-
await expect(
|
|
94
|
-
|
|
95
|
-
)
|
|
88
|
+
await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toThrow(
|
|
89
|
+
"connection was closed",
|
|
90
|
+
);
|
|
96
91
|
expect(raw.calls).toBe(2);
|
|
97
92
|
});
|
|
98
93
|
});
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
4
|
export async function selectSerializedFieldDefinition(
|
|
5
|
-
|
|
5
|
+
runner: DbRunner,
|
|
6
6
|
tenantId: string,
|
|
7
7
|
entityName: string,
|
|
8
8
|
fieldKey: string,
|
|
9
9
|
): Promise<unknown | null> {
|
|
10
10
|
const rows = await unsafeReadRetrying(
|
|
11
|
-
|
|
12
|
-
"reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
13
|
-
),
|
|
11
|
+
runner,
|
|
14
12
|
"SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
|
|
15
13
|
[entityName, fieldKey, tenantId],
|
|
16
14
|
);
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
|
-
export async function countTenantFieldDefinitions(
|
|
4
|
+
export async function countTenantFieldDefinitions(
|
|
5
|
+
runner: DbRunner,
|
|
6
|
+
tenantId: string,
|
|
7
|
+
): Promise<number> {
|
|
5
8
|
// Active definitions only — delete soft-deletes (the deterministic stream is
|
|
6
9
|
// kept so a re-define can restore it), so isDeleted rows must not consume quota.
|
|
7
10
|
const rowsResult = await unsafeReadRetrying(
|
|
8
|
-
|
|
9
|
-
"counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
10
|
-
),
|
|
11
|
+
runner,
|
|
11
12
|
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
12
13
|
[tenantId],
|
|
13
14
|
);
|
|
@@ -39,7 +39,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
39
39
|
const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
|
|
40
40
|
|
|
41
41
|
const accessCheck = await checkFieldAccessForWrite(
|
|
42
|
-
ctx.db,
|
|
42
|
+
ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
|
|
43
43
|
event.user.tenantId,
|
|
44
44
|
payload.entityName,
|
|
45
45
|
payload.fieldKey,
|
|
@@ -73,7 +73,10 @@ export function createDefineTenantFieldHandler(
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
if (limit !== undefined) {
|
|
76
|
-
const current = await countTenantFieldDefinitions(
|
|
76
|
+
const current = await countTenantFieldDefinitions(
|
|
77
|
+
ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
|
|
78
|
+
tenantId,
|
|
79
|
+
);
|
|
77
80
|
if (current >= limit) {
|
|
78
81
|
return failUnprocessable("cap_exceeded", {
|
|
79
82
|
capName: "customFields.fieldDefinition.count",
|
|
@@ -62,7 +62,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
62
62
|
const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
|
|
63
63
|
|
|
64
64
|
const loaded = await loadFieldDefinition(
|
|
65
|
-
ctx.db,
|
|
65
|
+
ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
|
|
66
66
|
event.user.tenantId,
|
|
67
67
|
payload.entityName,
|
|
68
68
|
payload.fieldKey,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Plus single-fetch loader so set-custom-field can run access-check AND
|
|
3
3
|
// value-validation off one DB read (no double fetch).
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
6
6
|
import { selectSerializedFieldDefinition } from "../db/queries/field-access";
|
|
7
7
|
import { parseSerializedField, type SerializedFieldShape } from "./parse-serialized-field";
|
|
8
8
|
|
|
@@ -22,12 +22,12 @@ export type LoadedFieldDefinition =
|
|
|
22
22
|
| { found: true; field: SerializedFieldShape | null };
|
|
23
23
|
|
|
24
24
|
export async function loadFieldDefinition(
|
|
25
|
-
|
|
25
|
+
runner: DbRunner,
|
|
26
26
|
tenantId: string,
|
|
27
27
|
entityName: string,
|
|
28
28
|
fieldKey: string,
|
|
29
29
|
): Promise<LoadedFieldDefinition> {
|
|
30
|
-
const serialized = await selectSerializedFieldDefinition(
|
|
30
|
+
const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
|
|
31
31
|
if (serialized === null) return { found: false };
|
|
32
32
|
// parseSerializedField throws on a #972 legacy `sensitive` definition —
|
|
33
33
|
// that's a corrupt/unprocessable row from this write-gate's perspective,
|
|
@@ -57,13 +57,13 @@ export function fieldWriteAccessDeniedRoles(
|
|
|
57
57
|
// Convenience wrapper retained for clear-custom-field (no value-validation
|
|
58
58
|
// needed there) — does the load + access-check in one call.
|
|
59
59
|
export async function checkFieldAccessForWrite(
|
|
60
|
-
|
|
60
|
+
runner: DbRunner,
|
|
61
61
|
tenantId: string,
|
|
62
62
|
entityName: string,
|
|
63
63
|
fieldKey: string,
|
|
64
64
|
userRoles: ReadonlyArray<string>,
|
|
65
65
|
): Promise<FieldAccessCheckResult> {
|
|
66
|
-
const loaded = await loadFieldDefinition(
|
|
66
|
+
const loaded = await loadFieldDefinition(runner, tenantId, entityName, fieldKey);
|
|
67
67
|
if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
|
|
68
68
|
if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
|
|
69
69
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type HandlerContext,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
2
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
8
|
import { z } from "zod";
|
|
4
9
|
import { USER_STATUS, userTable } from "../../user";
|
|
@@ -81,7 +86,19 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
|
|
|
81
86
|
);
|
|
82
87
|
if (!verified.ok) return writeFailure(invalidToken());
|
|
83
88
|
|
|
84
|
-
|
|
89
|
+
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
90
|
+
// the compliance-profile shape.
|
|
91
|
+
const profile = (await ctx.queryAs(
|
|
92
|
+
createSystemUser(event.user.tenantId),
|
|
93
|
+
"compliance-profiles:query:for-tenant",
|
|
94
|
+
{},
|
|
95
|
+
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
96
|
+
const res = await startDeletionGracePeriod(
|
|
97
|
+
ctx,
|
|
98
|
+
verified.userId,
|
|
99
|
+
profile.profile.userRights.gracePeriod,
|
|
100
|
+
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
101
|
+
);
|
|
85
102
|
if (!res.ok) {
|
|
86
103
|
// Generischer 422 statt res.error: dieser Endpoint ist anonym-öffentlich,
|
|
87
104
|
// res.error trägt den konkreten User-Status (currentStatus aus
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { addDurationSpec, type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
|
-
import {
|
|
2
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
5
6
|
import { decryptStoredPii } from "../../shared";
|
|
@@ -17,19 +18,23 @@ export type StartGracePeriodResult =
|
|
|
17
18
|
}
|
|
18
19
|
| { readonly ok: false; readonly error: UnprocessableError };
|
|
19
20
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// request-deletion
|
|
23
|
-
// (userId
|
|
21
|
+
// Flips an active user to DeletionRequested and sets gracePeriodEnd from the
|
|
22
|
+
// caller-supplied grace period. Shared between the authenticated
|
|
23
|
+
// request-deletion path (event.user) and the anonymous confirm-by-token path
|
|
24
|
+
// (userId from a verified token) — one source for the grace-period logic.
|
|
24
25
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// The user row is tenant-agnostic (account-wide deletion), so it is read via
|
|
27
|
+
// ctx.db.global(userTable); only the grace period duration is tenant-configured.
|
|
28
|
+
//
|
|
29
|
+
// `gracePeriod` and `lifecycleRunner` are resolved by the caller: both
|
|
30
|
+
// require an escalation (reading the tenant compliance profile, appending to
|
|
31
|
+
// the SYSTEM_TENANT_ID user stream) that only the declaring handler's
|
|
32
|
+
// escapeHatch covers.
|
|
29
33
|
export async function startDeletionGracePeriod(
|
|
30
34
|
ctx: HandlerContext,
|
|
31
35
|
userId: string,
|
|
32
|
-
|
|
36
|
+
gracePeriod: DurationSpec,
|
|
37
|
+
lifecycleRunner: DbRunner,
|
|
33
38
|
): Promise<StartGracePeriodResult> {
|
|
34
39
|
const userRow = await ctx.db
|
|
35
40
|
.global(userTable)
|
|
@@ -51,26 +56,13 @@ export async function startDeletionGracePeriod(
|
|
|
51
56
|
};
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
// @cast-boundary engine-payload — queryAs liefert unknown, narrow auf den
|
|
55
|
-
// effektiven Profile-Shape (siehe request-deletion-Original).
|
|
56
|
-
const profile = (await ctx.queryAs(
|
|
57
|
-
createSystemUser(complianceTenantId),
|
|
58
|
-
"compliance-profiles:query:for-tenant",
|
|
59
|
-
{},
|
|
60
|
-
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
61
|
-
|
|
62
|
-
const gracePeriod = profile.profile.userRights.gracePeriod;
|
|
63
59
|
const T = getTemporal();
|
|
64
60
|
const gracePeriodEnd = addDurationSpec(T.Now.instant(), gracePeriod);
|
|
65
61
|
|
|
66
|
-
await updateUserLifecycle(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
status: USER_STATUS.DeletionRequested,
|
|
71
|
-
gracePeriodEnd,
|
|
72
|
-
},
|
|
73
|
-
);
|
|
62
|
+
await updateUserLifecycle(lifecycleRunner, userId, {
|
|
63
|
+
status: USER_STATUS.DeletionRequested,
|
|
64
|
+
gracePeriodEnd,
|
|
65
|
+
});
|
|
74
66
|
|
|
75
67
|
return {
|
|
76
68
|
ok: true,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
2
|
-
import {
|
|
2
|
+
import type { DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
3
|
+
import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
4
|
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { USER_STATUS } from "../../user";
|
|
@@ -61,7 +62,19 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
|
|
|
61
62
|
"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.",
|
|
62
63
|
agent: { risk: "high" },
|
|
63
64
|
handler: async (event, ctx) => {
|
|
64
|
-
|
|
65
|
+
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
66
|
+
// the compliance-profile shape.
|
|
67
|
+
const profile = (await ctx.queryAs(
|
|
68
|
+
createSystemUser(event.user.tenantId),
|
|
69
|
+
"compliance-profiles:query:for-tenant",
|
|
70
|
+
{},
|
|
71
|
+
)) as { profile: { userRights: { gracePeriod: DurationSpec } } };
|
|
72
|
+
const res = await startDeletionGracePeriod(
|
|
73
|
+
ctx,
|
|
74
|
+
event.user.id,
|
|
75
|
+
profile.profile.userRights.gracePeriod,
|
|
76
|
+
ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
|
|
77
|
+
);
|
|
65
78
|
if (!res.ok) return writeFailure(res.error);
|
|
66
79
|
const { gracePeriodEnd, userEmail, userLocale } = res;
|
|
67
80
|
|