@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
|
@@ -119,7 +119,11 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
119
119
|
escapeHatch: {
|
|
120
120
|
reason:
|
|
121
121
|
"Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
|
|
122
|
-
"membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant."
|
|
122
|
+
"membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
|
|
123
|
+
"Also reads the pending invitation by id (the invitee is not yet a member of the " +
|
|
124
|
+
"invitation's tenant), adds the membership and accepts the invitation in the " +
|
|
125
|
+
"invitation's tenant, which differs from the caller's tenant, and reads the MFA " +
|
|
126
|
+
"enrollment of that tenant via the mfaStatusChecker callback.",
|
|
123
127
|
},
|
|
124
128
|
agent: { expose: false },
|
|
125
129
|
// kumiko-lint-ignore complexity-budget reuses login.write.ts's gate chain (lockout/password/email/status/membership/mfa) plus invite-specific branches (email match, already-member check, invitation update, unburn-on-failure) — splitting would scatter gate order across functions without reducing risk
|
|
@@ -154,9 +158,13 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
154
158
|
|
|
155
159
|
let committed = false;
|
|
156
160
|
try {
|
|
157
|
-
const invitation = await fetchOne<InvitationRow>(
|
|
158
|
-
|
|
159
|
-
|
|
161
|
+
const invitation = await fetchOne<InvitationRow>(
|
|
162
|
+
ctx.db.unsafeRaw(
|
|
163
|
+
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant",
|
|
164
|
+
),
|
|
165
|
+
tenantInvitationsTable,
|
|
166
|
+
{ id: invitationId },
|
|
167
|
+
);
|
|
160
168
|
if (!invitation || invitation.status !== INVITATION_STATUS.pending)
|
|
161
169
|
return invalidInviteToken();
|
|
162
170
|
|
|
@@ -178,7 +186,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
178
186
|
// login.write.ts runs (see login-gates.test.ts for the gate contracts).
|
|
179
187
|
// Email uniqueness is partial on live rows (framework#2593) — without
|
|
180
188
|
// this filter the lookup can resolve a soft-deleted row.
|
|
181
|
-
const userRow = await fetchOne<UserAuthRow>(
|
|
189
|
+
const userRow = await ctx.db.global(userTable).fetchOne<UserAuthRow>({
|
|
182
190
|
email: invitationEmail,
|
|
183
191
|
isDeleted: false,
|
|
184
192
|
});
|
|
@@ -215,7 +223,9 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
|
|
|
215
223
|
)) as Array<{ tenantId: string }>; // @cast-boundary db-row
|
|
216
224
|
const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
|
|
217
225
|
|
|
218
|
-
const dbConn = ctx.db.
|
|
226
|
+
const dbConn = ctx.db.unsafeRaw(
|
|
227
|
+
"adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant",
|
|
228
|
+
);
|
|
219
229
|
|
|
220
230
|
if (!alreadyMember) {
|
|
221
231
|
const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
|
|
@@ -67,6 +67,13 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
67
67
|
{ entityName: "tenant-invitation" },
|
|
68
68
|
);
|
|
69
69
|
|
|
70
|
+
const INVITE_ACCEPT_ESCAPE_HATCH_REASON =
|
|
71
|
+
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant.";
|
|
72
|
+
const READ_PENDING_INVITATION_REASON =
|
|
73
|
+
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
|
|
74
|
+
const ADD_MEMBERSHIP_INVITATION_TENANT_REASON =
|
|
75
|
+
"adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant";
|
|
76
|
+
|
|
70
77
|
export function createInviteAcceptHandler() {
|
|
71
78
|
return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
|
|
72
79
|
name: "invite-accept",
|
|
@@ -76,6 +83,9 @@ export function createInviteAcceptHandler() {
|
|
|
76
83
|
// dispatched wird.
|
|
77
84
|
access: { openToAll: true },
|
|
78
85
|
agent: { expose: false },
|
|
86
|
+
escapeHatch: {
|
|
87
|
+
reason: INVITE_ACCEPT_ESCAPE_HATCH_REASON,
|
|
88
|
+
},
|
|
79
89
|
// kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
|
|
80
90
|
handler: async (event, ctx) => {
|
|
81
91
|
if (!ctx.redis) {
|
|
@@ -101,9 +111,11 @@ export function createInviteAcceptHandler() {
|
|
|
101
111
|
|
|
102
112
|
let committed = false;
|
|
103
113
|
try {
|
|
104
|
-
const invitation = await fetchOne<InvitationRow>(
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
const invitation = await fetchOne<InvitationRow>(
|
|
115
|
+
ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON),
|
|
116
|
+
tenantInvitationsTable,
|
|
117
|
+
{ id: invitationId },
|
|
118
|
+
);
|
|
107
119
|
if (!invitation || invitation.status !== INVITATION_STATUS.pending)
|
|
108
120
|
return invalidInviteToken();
|
|
109
121
|
|
|
@@ -119,7 +131,7 @@ export function createInviteAcceptHandler() {
|
|
|
119
131
|
// Email-Match: User muss mit der eingeladenen Email matchen.
|
|
120
132
|
// Sonst kann ein Angreifer mit Zugriff zur invitee-Mail seinen
|
|
121
133
|
// eigenen Account dem Tenant zuschlagen.
|
|
122
|
-
const userRow = await fetchOne<UserEmailRow>(
|
|
134
|
+
const userRow = await ctx.db.global(userTable).fetchOne<UserEmailRow>({
|
|
123
135
|
id: event.user.id,
|
|
124
136
|
});
|
|
125
137
|
const userEmail = userRow?.email
|
|
@@ -134,13 +146,14 @@ export function createInviteAcceptHandler() {
|
|
|
134
146
|
// ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
|
|
135
147
|
// alreadyMember=false sehen und am Unique-Constraint scheitern.
|
|
136
148
|
// Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
|
|
137
|
-
const
|
|
149
|
+
const invitationTenantRunner = ctx.db.unsafeRaw(ADD_MEMBERSHIP_INVITATION_TENANT_REASON);
|
|
150
|
+
const membershipRow = await fetchOne(invitationTenantRunner, tenantMembershipsTable, {
|
|
138
151
|
userId: event.user.id,
|
|
139
152
|
tenantId: invitationTenantId,
|
|
140
153
|
});
|
|
141
154
|
const alreadyMember = membershipRow !== undefined;
|
|
142
155
|
|
|
143
|
-
const dbConn =
|
|
156
|
+
const dbConn = invitationTenantRunner;
|
|
144
157
|
|
|
145
158
|
if (!alreadyMember) {
|
|
146
159
|
// Membership-Add via seedTenantMembership-helper (event-store-
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
// invite-create.
|
|
19
19
|
|
|
20
20
|
import { generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
21
|
-
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
22
21
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
23
22
|
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
23
|
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -132,7 +131,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
132
131
|
// max. eine Row. Status egal (cancelled/accepted/expired/pending);
|
|
133
132
|
// wir setzen sie auf pending zurück und vergeben einen frischen
|
|
134
133
|
// Token wenn der bisherige nicht mehr lebt.
|
|
135
|
-
const existing = await
|
|
134
|
+
const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
|
|
136
135
|
|
|
137
136
|
let invitationId: string;
|
|
138
137
|
let token: string;
|
|
@@ -77,6 +77,13 @@ const invitationExecutor = createEventStoreExecutor(
|
|
|
77
77
|
{ entityName: "tenant-invitation" },
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
+
const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON =
|
|
81
|
+
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
|
|
82
|
+
const READ_PENDING_INVITATION_REASON =
|
|
83
|
+
"reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
|
|
84
|
+
const CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON =
|
|
85
|
+
"creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists";
|
|
86
|
+
|
|
80
87
|
export function createInviteSignupCompleteHandler() {
|
|
81
88
|
return defineWriteHandler<
|
|
82
89
|
"invite-signup-complete",
|
|
@@ -87,6 +94,9 @@ export function createInviteSignupCompleteHandler() {
|
|
|
87
94
|
schema: InviteSignupCompleteSchema,
|
|
88
95
|
access: { roles: ["all"] },
|
|
89
96
|
agent: { expose: false },
|
|
97
|
+
escapeHatch: {
|
|
98
|
+
reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
|
|
99
|
+
},
|
|
90
100
|
handler: async (event, ctx) => {
|
|
91
101
|
if (!ctx.redis) {
|
|
92
102
|
return writeFailure(
|
|
@@ -110,9 +120,11 @@ export function createInviteSignupCompleteHandler() {
|
|
|
110
120
|
|
|
111
121
|
let committed = false;
|
|
112
122
|
try {
|
|
113
|
-
const invitation = await fetchOne<InvitationRow>(
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
const invitation = await fetchOne<InvitationRow>(
|
|
124
|
+
ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON),
|
|
125
|
+
tenantInvitationsTable,
|
|
126
|
+
{ id: invitationId },
|
|
127
|
+
);
|
|
116
128
|
if (!invitation || invitation.status !== INVITATION_STATUS.pending)
|
|
117
129
|
return invalidInviteToken();
|
|
118
130
|
|
|
@@ -131,7 +143,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
131
143
|
// Password zu setzen für denselben User.
|
|
132
144
|
// Email uniqueness is partial on live rows (framework#2593) — without
|
|
133
145
|
// this filter the lookup can resolve a soft-deleted row.
|
|
134
|
-
const existingUser = await
|
|
146
|
+
const existingUser = await ctx.db.global(userTable).fetchOne({
|
|
135
147
|
email: invitationEmail,
|
|
136
148
|
isDeleted: false,
|
|
137
149
|
});
|
|
@@ -139,9 +151,11 @@ export function createInviteSignupCompleteHandler() {
|
|
|
139
151
|
|
|
140
152
|
// User anlegen via seedUserWithPassword (gleiches Pattern wie
|
|
141
153
|
// signup-confirm), emailVerified=true wegen Magic-Link.
|
|
142
|
-
// @cast-boundary db-runner —
|
|
143
|
-
//
|
|
144
|
-
const dbConn = ctx.db.
|
|
154
|
+
// @cast-boundary db-runner — helpers use only the query API that
|
|
155
|
+
// DbConnection and DbTx share.
|
|
156
|
+
const dbConn = ctx.db.unsafeRaw(
|
|
157
|
+
CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON,
|
|
158
|
+
) as DbConnection;
|
|
145
159
|
const { id: userId } = await seedUserWithPassword(dbConn, {
|
|
146
160
|
email: invitationEmail,
|
|
147
161
|
password: event.payload.password,
|
|
@@ -287,7 +287,9 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
287
287
|
reason:
|
|
288
288
|
"Unauthenticated login has no caller identity yet — it looks up the user row by " +
|
|
289
289
|
"email via ctx.queryAs(SYSTEM, ...) and resolves tenant membership via " +
|
|
290
|
-
"ctx.resolveActiveMembership before a session exists."
|
|
290
|
+
"ctx.resolveActiveMembership before a session exists. It also reads the MFA " +
|
|
291
|
+
"enrollment of the tenant named in the signed login/setup token, not the guest " +
|
|
292
|
+
"dispatch tenant, via the mfaStatusChecker callback.",
|
|
291
293
|
},
|
|
292
294
|
description:
|
|
293
295
|
"Signs a user in with email and password, running the lockout, email-verification, account-status, tenant-membership and MFA gates, and answering with a session or with an MFA challenge or setup requirement.",
|
|
@@ -60,12 +60,18 @@ export type SignupConfirmData = {
|
|
|
60
60
|
readonly tenantKey: string;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
const SIGNUP_CONFIRM_PROVISION_REASON =
|
|
64
|
+
"provisions a new tenant, its first user and membership before any tenant context exists";
|
|
65
|
+
|
|
63
66
|
export function createSignupConfirmHandler() {
|
|
64
67
|
return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
|
|
65
68
|
name: "signup-confirm",
|
|
66
69
|
schema: SignupConfirmSchema,
|
|
67
70
|
access: { roles: ["all"] },
|
|
68
71
|
agent: { expose: false },
|
|
72
|
+
escapeHatch: {
|
|
73
|
+
reason: SIGNUP_CONFIRM_PROVISION_REASON,
|
|
74
|
+
},
|
|
69
75
|
handler: async (event, ctx) => {
|
|
70
76
|
if (!ctx.redis) {
|
|
71
77
|
return writeFailure(
|
|
@@ -90,11 +96,9 @@ export function createSignupConfirmHandler() {
|
|
|
90
96
|
// Tenant-Key: 2-Wort-Slug aus framework/random, mit DB-Conflict-
|
|
91
97
|
// Check gegen tenants.key. 22.500 Default-Combos + Suffix-
|
|
92
98
|
// Fallback bei Kollision (siehe generateUniqueName).
|
|
93
|
-
// @cast-boundary db-runner —
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
// on the Tx flavor — same drizzle calls, same behavior.
|
|
97
|
-
const dbConn = ctx.db.raw as DbConnection;
|
|
99
|
+
// @cast-boundary db-runner — helpers use only the query API that
|
|
100
|
+
// DbConnection and DbTx share.
|
|
101
|
+
const dbConn = ctx.db.unsafeRaw(SIGNUP_CONFIRM_PROVISION_REASON) as DbConnection;
|
|
98
102
|
|
|
99
103
|
const tenantKey = await generateUniqueName({
|
|
100
104
|
isAvailable: async (slug) => {
|
|
@@ -67,7 +67,9 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
67
67
|
escapeHatch: {
|
|
68
68
|
reason:
|
|
69
69
|
"Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
|
|
70
|
-
"user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user."
|
|
70
|
+
"user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user. " +
|
|
71
|
+
"Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
|
|
72
|
+
"guest dispatch tenant.",
|
|
71
73
|
},
|
|
72
74
|
description:
|
|
73
75
|
"Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
|
|
@@ -125,7 +127,13 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
125
127
|
|
|
126
128
|
// "system" mode: no session exists yet, tenantId comes from the
|
|
127
129
|
// verified token, mirroring enable-start-preauth.write.ts.
|
|
128
|
-
const scopedDb = createTenantDb(
|
|
130
|
+
const scopedDb = createTenantDb(
|
|
131
|
+
ctx.db.unsafeRaw(
|
|
132
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
133
|
+
),
|
|
134
|
+
tenantId,
|
|
135
|
+
"system",
|
|
136
|
+
);
|
|
129
137
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
130
138
|
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
131
139
|
if (existing) return mfaAlreadyEnabled();
|
|
@@ -11,6 +11,9 @@ import { buildOtpauthUri } from "../otpauth-uri";
|
|
|
11
11
|
import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
|
|
12
12
|
import { generateTotpSecret } from "../totp";
|
|
13
13
|
|
|
14
|
+
const ENABLE_START_PREAUTH_TENANT_REASON =
|
|
15
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant";
|
|
16
|
+
|
|
14
17
|
export type EnableStartPreauthOptions = {
|
|
15
18
|
// Verifies the incoming preauthSetupToken — must match login.write.ts's
|
|
16
19
|
// mfaStatusChecker (challengeTokenSecret), NOT setupTokenSecret.
|
|
@@ -39,6 +42,9 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
|
|
|
39
42
|
"Begins TOTP enrollment for a user whose sign-in was blocked because the tenant requires two-factor authentication, taking identity from the pre-auth token login issued instead of from a session.",
|
|
40
43
|
// Same secret-bearing result as enable-start.
|
|
41
44
|
agent: { expose: false },
|
|
45
|
+
escapeHatch: {
|
|
46
|
+
reason: ENABLE_START_PREAUTH_TENANT_REASON,
|
|
47
|
+
},
|
|
42
48
|
handler: async (event, ctx) => {
|
|
43
49
|
const verified = verifyMfaPreauthSetupToken(
|
|
44
50
|
event.payload.preauthSetupToken,
|
|
@@ -53,7 +59,11 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
|
|
|
53
59
|
// "system" mode: the guest dispatch identity's own tenantId is
|
|
54
60
|
// meaningless here — the preauthSetupToken is the source of truth
|
|
55
61
|
// for which tenant's row to read, mirroring verify.write.ts.
|
|
56
|
-
const scopedDb = createTenantDb(
|
|
62
|
+
const scopedDb = createTenantDb(
|
|
63
|
+
ctx.db.unsafeRaw(ENABLE_START_PREAUTH_TENANT_REASON),
|
|
64
|
+
tenantId,
|
|
65
|
+
"system",
|
|
66
|
+
);
|
|
57
67
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
58
68
|
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
59
69
|
if (existing) return mfaAlreadyEnabled();
|
|
@@ -49,7 +49,9 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
49
49
|
escapeHatch: {
|
|
50
50
|
reason:
|
|
51
51
|
"Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
|
|
52
|
-
"user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names."
|
|
52
|
+
"user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names. " +
|
|
53
|
+
"Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
|
|
54
|
+
"guest dispatch tenant.",
|
|
53
55
|
},
|
|
54
56
|
description:
|
|
55
57
|
"Finishes a two-step sign-in by checking a TOTP or recovery code against the challenge token that login handed back, under a per-account attempt cap, and derives the resulting session.",
|
|
@@ -92,7 +94,13 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
92
94
|
// "system" mode: this handler runs with a guest identity whose own
|
|
93
95
|
// tenantId is meaningless here — the challenge token is the source
|
|
94
96
|
// of truth for which tenant's row to read.
|
|
95
|
-
const scopedDb = createTenantDb(
|
|
97
|
+
const scopedDb = createTenantDb(
|
|
98
|
+
ctx.db.unsafeRaw(
|
|
99
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
100
|
+
),
|
|
101
|
+
tenantId,
|
|
102
|
+
"system",
|
|
103
|
+
);
|
|
96
104
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
97
105
|
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
98
106
|
// MFA got disabled between login and verify (race, or a stale
|
|
@@ -21,7 +21,11 @@ export type MfaCodeVerifier = (
|
|
|
21
21
|
// codes exist for.
|
|
22
22
|
export function createMfaCodeVerifier(): MfaCodeVerifier {
|
|
23
23
|
return async (ctx, userId, tenantId, code) => {
|
|
24
|
-
const scopedDb = createTenantDb(
|
|
24
|
+
const scopedDb = createTenantDb(
|
|
25
|
+
ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
|
|
26
|
+
tenantId,
|
|
27
|
+
"system",
|
|
28
|
+
);
|
|
25
29
|
const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
|
|
26
30
|
if (!row) return { enrolled: false, ok: false };
|
|
27
31
|
// Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
|
|
@@ -38,7 +38,13 @@ export function createMfaStatusChecker(opts: {
|
|
|
38
38
|
readonly challengeTokenSecret: string;
|
|
39
39
|
}): MfaStatusChecker {
|
|
40
40
|
return async (ctx, userId, tenantId, roles) => {
|
|
41
|
-
const scopedDb = createTenantDb(
|
|
41
|
+
const scopedDb = createTenantDb(
|
|
42
|
+
ctx.db.unsafeRaw(
|
|
43
|
+
"reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
|
|
44
|
+
),
|
|
45
|
+
tenantId,
|
|
46
|
+
"system",
|
|
47
|
+
);
|
|
42
48
|
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
43
49
|
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
44
50
|
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// `read_subscriptions`-projection. Tenant-Admins lesen ihre eigene
|
|
3
3
|
// subscription via getSubscriptionForTenant-helper (= ctx.db ist
|
|
4
4
|
// tenant-scoped, gibt automatisch nur die row des Callers zurück).
|
|
5
|
-
|
|
6
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
7
5
|
import {
|
|
8
6
|
configuredPiiSubjectKms,
|
|
9
7
|
decryptPiiFieldValues,
|
|
@@ -22,7 +20,7 @@ export const listSubscriptionsQuery: QueryHandlerDef = {
|
|
|
22
20
|
schema: listSchema,
|
|
23
21
|
access: { roles: ["SystemAdmin", "TenantAdmin"] },
|
|
24
22
|
handler: async (_query, ctx) => {
|
|
25
|
-
const rows = await
|
|
23
|
+
const rows = await ctx.db.selectMany(subscriptionsProjectionTable, {
|
|
26
24
|
tenantId: ctx.user.tenantId,
|
|
27
25
|
});
|
|
28
26
|
const piiKms = configuredPiiSubjectKms();
|
|
@@ -55,10 +55,15 @@ export function createStockCapGuard<TCaps>(
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function withStockCap(handler: WriteHandlerDef, spec: StockCapSpec<TCaps>): WriteHandlerDef {
|
|
58
|
+
const capReason =
|
|
59
|
+
"counts the caller tenant's rows and resolves its tier caps through the raw-runner cap API";
|
|
58
60
|
return {
|
|
59
61
|
...handler,
|
|
62
|
+
escapeHatch: handler.escapeHatch
|
|
63
|
+
? { reason: `${handler.escapeHatch.reason} ${capReason}` }
|
|
64
|
+
: { reason: capReason },
|
|
60
65
|
handler: async (event, ctx) => {
|
|
61
|
-
const failure = await checkStockCap(ctx.db.
|
|
66
|
+
const failure = await checkStockCap(ctx.db.unsafeRaw(capReason), event.user.tenantId, spec);
|
|
62
67
|
return failure ?? handler.handler(event, ctx);
|
|
63
68
|
},
|
|
64
69
|
};
|
|
@@ -230,25 +230,28 @@ 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
|
-
|
|
249
|
-
|
|
233
|
+
await append(
|
|
234
|
+
outsideTx.unsafeRaw(
|
|
235
|
+
"denial audit append: names the prober's own tenant stream on the outside-transaction db",
|
|
236
|
+
),
|
|
237
|
+
{
|
|
238
|
+
aggregateId: generateId(),
|
|
239
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
240
|
+
// MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
|
|
241
|
+
// bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
|
|
242
|
+
tenantId: event.user.tenantId,
|
|
243
|
+
expectedVersion: 0,
|
|
244
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
245
|
+
eventVersion: eventDef.version,
|
|
246
|
+
payload,
|
|
247
|
+
metadata: {
|
|
248
|
+
userId: event.user.id,
|
|
249
|
+
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
250
|
+
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
251
|
+
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
252
|
+
},
|
|
250
253
|
},
|
|
251
|
-
|
|
254
|
+
);
|
|
252
255
|
return null;
|
|
253
256
|
}
|
|
254
257
|
|
|
@@ -270,6 +273,13 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
270
273
|
// Erasing the subject key is irreversible: there is no undo, so an agent must
|
|
271
274
|
// not be able to reach it at all.
|
|
272
275
|
agent: { expose: false },
|
|
276
|
+
escapeHatch: {
|
|
277
|
+
reason:
|
|
278
|
+
"denial audit append names the prober's own tenant stream on the outside-transaction db; " +
|
|
279
|
+
"the tenant-scope check runs against the subject's tenant, not necessarily the caller's; " +
|
|
280
|
+
"the blind-index sweep and search purge address the subject across tenants; the user " +
|
|
281
|
+
"lifecycle update and PAT revoke run on the SYSTEM user stream.",
|
|
282
|
+
},
|
|
273
283
|
handler: async (event, ctx) => {
|
|
274
284
|
const kms = configuredPiiSubjectKms();
|
|
275
285
|
if (!kms) {
|
|
@@ -292,7 +302,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
292
302
|
const subjectKey = subjectIdToKey(subject);
|
|
293
303
|
|
|
294
304
|
const tenantScopeDenial = await resolveTenantScopeDenial(
|
|
295
|
-
ctx.db.
|
|
305
|
+
ctx.db.unsafeRaw(
|
|
306
|
+
"tenant-scope check runs against the subject's tenant, not necessarily the caller's",
|
|
307
|
+
),
|
|
296
308
|
ctx.registry.features,
|
|
297
309
|
event.user,
|
|
298
310
|
raw,
|
|
@@ -337,17 +349,18 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
337
349
|
eraseReason: event.payload.reason,
|
|
338
350
|
});
|
|
339
351
|
|
|
340
|
-
// Blind-index sweep (#818):
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
352
|
+
// Blind-index sweep (#818): nulls bidx columns now so the deterministic
|
|
353
|
+
// HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
|
|
354
|
+
const crossTenantSubjectRunner = ctx.db.unsafeRaw(
|
|
355
|
+
"blind-index sweep and search purge address the subject across tenants",
|
|
356
|
+
);
|
|
357
|
+
await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
|
|
345
358
|
|
|
346
359
|
// Derived search index still holds plaintext (#1610) — purge next to the
|
|
347
360
|
// blind-index sweep. No adapter → no-op (apps without search).
|
|
348
361
|
if (ctx.searchAdapter) {
|
|
349
362
|
await purgeSearchDocumentsForSubject(
|
|
350
|
-
|
|
363
|
+
crossTenantSubjectRunner,
|
|
351
364
|
ctx.registry.features,
|
|
352
365
|
ctx.searchAdapter,
|
|
353
366
|
subjectKey,
|
|
@@ -376,9 +389,12 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
376
389
|
// update is best-effort for real users.
|
|
377
390
|
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
378
391
|
try {
|
|
379
|
-
|
|
392
|
+
const userLifecycleRunner = ctx.db.unsafeRaw(
|
|
393
|
+
"user lifecycle update and PAT revoke run on the SYSTEM user stream",
|
|
394
|
+
);
|
|
395
|
+
await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
|
|
380
396
|
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
381
|
-
await revokeAllPatTokensForUser(
|
|
397
|
+
await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
|
|
382
398
|
}
|
|
383
399
|
} catch {
|
|
384
400
|
// User row may not exist (e.g. email subscribers with user-style
|
|
@@ -41,16 +41,21 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
44
|
-
test("selectSerializedFieldDefinition retries once through db.
|
|
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(
|
|
46
|
+
const result = await selectSerializedFieldDefinition(
|
|
47
|
+
{ unsafeRaw: () => raw } as never,
|
|
48
|
+
"t1",
|
|
49
|
+
"entity",
|
|
50
|
+
"field",
|
|
51
|
+
);
|
|
47
52
|
expect(result).toBe("sf1");
|
|
48
53
|
expect(raw.calls).toBe(2);
|
|
49
54
|
});
|
|
50
55
|
|
|
51
|
-
test("countTenantFieldDefinitions retries once through db.
|
|
56
|
+
test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
|
|
52
57
|
const raw = fakeClient([closedConnectionError()], { n: 3 });
|
|
53
|
-
const result = await countTenantFieldDefinitions({ raw } as never, "t1");
|
|
58
|
+
const result = await countTenantFieldDefinitions({ unsafeRaw: () => raw } as never, "t1");
|
|
54
59
|
expect(result).toBe(3);
|
|
55
60
|
expect(raw.calls).toBe(2);
|
|
56
61
|
});
|
|
@@ -86,7 +91,7 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
|
|
|
86
91
|
serialized_field: "sf1",
|
|
87
92
|
});
|
|
88
93
|
await expect(
|
|
89
|
-
selectSerializedFieldDefinition({ raw } as never, "t1", "entity", "field"),
|
|
94
|
+
selectSerializedFieldDefinition({ unsafeRaw: () => raw } as never, "t1", "entity", "field"),
|
|
90
95
|
).rejects.toThrow("connection was closed");
|
|
91
96
|
expect(raw.calls).toBe(2);
|
|
92
97
|
});
|
|
@@ -8,7 +8,9 @@ export async function selectSerializedFieldDefinition(
|
|
|
8
8
|
fieldKey: string,
|
|
9
9
|
): Promise<unknown | null> {
|
|
10
10
|
const rows = await unsafeReadRetrying(
|
|
11
|
-
db.
|
|
11
|
+
db.unsafeRaw(
|
|
12
|
+
"reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
13
|
+
),
|
|
12
14
|
"SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
|
|
13
15
|
[entityName, fieldKey, tenantId],
|
|
14
16
|
);
|
|
@@ -5,7 +5,9 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
|
|
|
5
5
|
// Active definitions only — delete soft-deletes (the deterministic stream is
|
|
6
6
|
// kept so a re-define can restore it), so isDeleted rows must not consume quota.
|
|
7
7
|
const rowsResult = await unsafeReadRetrying(
|
|
8
|
-
db.
|
|
8
|
+
db.unsafeRaw(
|
|
9
|
+
"counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
|
|
10
|
+
),
|
|
9
11
|
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
10
12
|
[tenantId],
|
|
11
13
|
);
|
|
@@ -16,6 +16,9 @@ export const clearCustomFieldPayloadSchema = z.object({
|
|
|
16
16
|
});
|
|
17
17
|
export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
|
|
18
18
|
|
|
19
|
+
const CLEAR_CUSTOM_FIELD_REASON =
|
|
20
|
+
"reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
|
|
21
|
+
|
|
19
22
|
// clear-custom-field — entfernt einen Custom-Field-Wert von einer host-
|
|
20
23
|
// entity. Emittiert customField.cleared-Event; MSP entfernt key aus
|
|
21
24
|
// jsonb-column (key-removal, nicht null-set).
|
|
@@ -29,6 +32,9 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
|
29
32
|
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
30
33
|
description:
|
|
31
34
|
"Removes the stored value of one custom field from a single host entity row after re-checking that field's per-field write roles; use it to blank a field the user emptied instead of writing a null value.",
|
|
35
|
+
escapeHatch: {
|
|
36
|
+
reason: CLEAR_CUSTOM_FIELD_REASON,
|
|
37
|
+
},
|
|
32
38
|
handler: async (event, ctx) => {
|
|
33
39
|
const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
|
|
34
40
|
|
|
@@ -45,6 +45,9 @@ export interface DefineTenantFieldOptions {
|
|
|
45
45
|
readonly roles?: readonly string[];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const DEFINE_TENANT_FIELD_REASON =
|
|
49
|
+
"counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
|
|
50
|
+
|
|
48
51
|
export function createDefineTenantFieldHandler(
|
|
49
52
|
opts: DefineTenantFieldOptions = {},
|
|
50
53
|
): WriteHandlerDef {
|
|
@@ -55,6 +58,9 @@ export function createDefineTenantFieldHandler(
|
|
|
55
58
|
access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
|
|
56
59
|
description:
|
|
57
60
|
"Creates a custom-field definition owned by the caller's own tenant on the named entity, rejecting the write once the tenant's definition quota is reached; use it when one tenant needs an extra field the other tenants must not see.",
|
|
61
|
+
escapeHatch: {
|
|
62
|
+
reason: DEFINE_TENANT_FIELD_REASON,
|
|
63
|
+
},
|
|
58
64
|
handler: async (event, ctx) => {
|
|
59
65
|
const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
|
|
60
66
|
const tenantId = event.user.tenantId;
|
|
@@ -22,6 +22,9 @@ export const setCustomFieldPayloadSchema = z.object({
|
|
|
22
22
|
});
|
|
23
23
|
export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
24
24
|
|
|
25
|
+
const SET_CUSTOM_FIELD_REASON =
|
|
26
|
+
"reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
|
|
27
|
+
|
|
25
28
|
// set-custom-field — schreibt einen Custom-Field-Wert auf eine host-entity.
|
|
26
29
|
//
|
|
27
30
|
// **ES-Option-B**: emittiert customField.set-Event auf dem host-aggregate
|
|
@@ -52,6 +55,9 @@ export const setCustomFieldHandler: WriteHandlerDef = {
|
|
|
52
55
|
access: { roles: DEFAULT_VALUE_WRITE_ROLES },
|
|
53
56
|
description:
|
|
54
57
|
"Stores one custom-field value on a single host entity row after validating it against the field definition's declared type and per-field write roles; use it to save what a user entered into a custom field.",
|
|
58
|
+
escapeHatch: {
|
|
59
|
+
reason: SET_CUSTOM_FIELD_REASON,
|
|
60
|
+
},
|
|
55
61
|
handler: async (event, ctx) => {
|
|
56
62
|
const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
|
|
57
63
|
|