@cosmicdrift/kumiko-framework 0.264.1 → 0.266.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 +3 -3
- package/src/api/__tests__/api.test.ts +4 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +21 -2
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +4 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +4 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +4 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +4 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +4 -0
- package/src/api/__tests__/server-boot-guards.test.ts +60 -0
- package/src/api/auth-middleware.ts +2 -7
- package/src/api/auth-routes.ts +28 -14
- package/src/api/server.ts +14 -1
- package/src/changes.json +27 -0
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +84 -3
- package/src/engine/active-membership.ts +56 -0
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- package/src/engine/extension-names.ts +16 -0
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
- package/src/engine/feature-ast/extractors/hooks.ts +8 -4
- package/src/engine/index.ts +15 -0
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +38 -0
- package/src/pipeline/active-membership.ts +178 -0
- package/src/pipeline/dispatch-shared.ts +14 -1
- package/src/pipeline/dispatcher.ts +22 -1
- package/src/pipeline/system-identity-switch.ts +44 -13
- package/src/testing/handler-context.ts +4 -0
- package/src/ui-types/index.ts +1 -0
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ZodArray,
|
|
3
|
+
ZodCatch,
|
|
4
|
+
ZodDefault,
|
|
5
|
+
ZodIntersection,
|
|
6
|
+
ZodLazy,
|
|
7
|
+
ZodNonOptional,
|
|
8
|
+
ZodNullable,
|
|
9
|
+
ZodObject,
|
|
10
|
+
ZodOptional,
|
|
11
|
+
ZodPipe,
|
|
12
|
+
ZodPrefault,
|
|
13
|
+
ZodReadonly,
|
|
14
|
+
ZodRecord,
|
|
15
|
+
ZodTuple,
|
|
16
|
+
type ZodType,
|
|
17
|
+
ZodUnion,
|
|
18
|
+
ZodXor,
|
|
19
|
+
} from "zod";
|
|
20
|
+
import type { $ZodType } from "zod/v4/core";
|
|
2
21
|
|
|
3
22
|
// Drills through wrapper types (.nullable(), .optional(), .default()) a
|
|
4
23
|
// handler's schema may use around its actual object/array shape — e.g. a
|
|
@@ -49,3 +68,75 @@ export function getZodRowShape(schema: ZodType | undefined): Record<string, ZodT
|
|
|
49
68
|
? getZodObjectShape(rowsArray.element as ZodType)
|
|
50
69
|
: undefined;
|
|
51
70
|
}
|
|
71
|
+
|
|
72
|
+
type SingleInnerSchema =
|
|
73
|
+
| ZodOptional
|
|
74
|
+
| ZodNullable
|
|
75
|
+
| ZodDefault
|
|
76
|
+
| ZodPrefault
|
|
77
|
+
| ZodNonOptional
|
|
78
|
+
| ZodCatch
|
|
79
|
+
| ZodReadonly
|
|
80
|
+
| ZodLazy;
|
|
81
|
+
|
|
82
|
+
const SINGLE_INNER_SCHEMA_CLASSES = [
|
|
83
|
+
ZodOptional,
|
|
84
|
+
ZodNullable,
|
|
85
|
+
ZodDefault,
|
|
86
|
+
ZodPrefault,
|
|
87
|
+
ZodNonOptional,
|
|
88
|
+
ZodCatch,
|
|
89
|
+
ZodReadonly,
|
|
90
|
+
ZodLazy,
|
|
91
|
+
] as const;
|
|
92
|
+
|
|
93
|
+
function isSingleInnerSchema(schema: $ZodType): schema is SingleInnerSchema {
|
|
94
|
+
return SINGLE_INNER_SCHEMA_CLASSES.some((schemaClass) => schema instanceof schemaClass);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function combinatorChildSchemas(schema: $ZodType): readonly $ZodType[] {
|
|
98
|
+
if (schema instanceof ZodUnion || schema instanceof ZodXor) return schema.options;
|
|
99
|
+
if (schema instanceof ZodIntersection) return [schema.def.left, schema.def.right];
|
|
100
|
+
// z.preprocess() carries the accepted shape on `out`, .transform() on `in`.
|
|
101
|
+
if (schema instanceof ZodPipe) return [schema.in, schema.out];
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function childSchemas(schema: $ZodType): readonly $ZodType[] {
|
|
106
|
+
if (schema instanceof ZodObject) return Object.values(schema.shape);
|
|
107
|
+
if (schema instanceof ZodArray) return [schema.element];
|
|
108
|
+
if (schema instanceof ZodRecord) return [schema.valueType];
|
|
109
|
+
if (schema instanceof ZodTuple) {
|
|
110
|
+
const { items, rest } = schema.def;
|
|
111
|
+
return rest ? [...items, rest] : items;
|
|
112
|
+
}
|
|
113
|
+
if (isSingleInnerSchema(schema)) return [schema.unwrap()];
|
|
114
|
+
return combinatorChildSchemas(schema);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A z.lazy() getter that builds a fresh schema per call never revisits a node.
|
|
118
|
+
const MAX_WALKED_SCHEMAS = 10_000;
|
|
119
|
+
|
|
120
|
+
// Object keys at any depth, so a field nested in an intersection, union or an
|
|
121
|
+
// update's `changes` object is found too.
|
|
122
|
+
export function collectZodObjectKeys(schema: $ZodType | undefined): ReadonlySet<string> {
|
|
123
|
+
const keys = new Set<string>();
|
|
124
|
+
if (schema === undefined) return keys;
|
|
125
|
+
const visited = new Set<$ZodType>();
|
|
126
|
+
const pending: $ZodType[] = [schema];
|
|
127
|
+
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
|
|
128
|
+
if (visited.has(next)) continue;
|
|
129
|
+
if (visited.size >= MAX_WALKED_SCHEMAS) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`collectZodObjectKeys: schema has more than ${MAX_WALKED_SCHEMAS} nodes — ` +
|
|
132
|
+
"a z.lazy() getter probably returns a new schema on every call; hoist it into a constant.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
visited.add(next);
|
|
136
|
+
if (next instanceof ZodObject) {
|
|
137
|
+
for (const key of Object.keys(next.shape)) keys.add(key);
|
|
138
|
+
}
|
|
139
|
+
pending.push(...childSchemas(next));
|
|
140
|
+
}
|
|
141
|
+
return keys;
|
|
142
|
+
}
|
|
@@ -155,6 +155,22 @@ export const EXT_EXTERNAL_RESOURCE = "externalResource" as const;
|
|
|
155
155
|
*/
|
|
156
156
|
export const EXT_INFRA_RESOURCE = "infraResource" as const;
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* `principalStatus` — "is this principal blocked from signing in" contract,
|
|
160
|
+
* consulted by the active-membership building block; fulfilled by the bundled `user` feature.
|
|
161
|
+
*/
|
|
162
|
+
export const EXT_PRINCIPAL_STATUS = "principalStatus" as const;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `tenantLifecycleStatus` — tenant-teardown status contract, fulfilled by
|
|
166
|
+
* the bundled `tenant-lifecycle` feature; absent means no tenant is ever "in teardown".
|
|
167
|
+
*/
|
|
168
|
+
export const EXT_TENANT_LIFECYCLE_STATUS = "tenantLifecycleStatus" as const;
|
|
169
|
+
|
|
170
|
+
// Default membership-list query handler name. Accepted coupling, same
|
|
171
|
+
// pattern as FILE_PROVIDER_CONFIG_KEY — the `tenant` feature registers a handler under this literal.
|
|
172
|
+
export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships" as const;
|
|
173
|
+
|
|
158
174
|
/**
|
|
159
175
|
* Union aller standardisierten Extension-Namen der Datenschutz-Surface.
|
|
160
176
|
* Nicht alle Extensions im System sind in dieser Liste — andere
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readOptionalAccessRule } from "../extractors/hooks";
|
|
3
|
+
|
|
4
|
+
describe("readOptionalAccessRule — openToAll.personalData", () => {
|
|
5
|
+
test("keeps personalData: tenant-members on the object form", () => {
|
|
6
|
+
expect(
|
|
7
|
+
readOptionalAccessRule({
|
|
8
|
+
openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
|
|
9
|
+
}),
|
|
10
|
+
).toEqual({
|
|
11
|
+
openToAll: { reason: "members edit their team roster", personalData: "tenant-members" },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("drops an unknown personalData value", () => {
|
|
16
|
+
expect(
|
|
17
|
+
readOptionalAccessRule({
|
|
18
|
+
openToAll: { reason: "signup", personalData: "public-intake" },
|
|
19
|
+
}),
|
|
20
|
+
).toEqual({ openToAll: { reason: "signup" } });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("the deprecated openToAll: true form carries no personalData", () => {
|
|
24
|
+
expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
|
|
25
|
+
openToAll: true,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -42,12 +42,16 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
|
|
|
42
42
|
if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
|
|
43
43
|
return { roles: value["roles"] as readonly string[] };
|
|
44
44
|
}
|
|
45
|
-
const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
|
|
46
45
|
if (value["openToAll"] === true) {
|
|
47
|
-
return { openToAll: true
|
|
46
|
+
return { openToAll: true };
|
|
48
47
|
}
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
const openToAll = value["openToAll"];
|
|
49
|
+
if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
|
|
50
|
+
const personalData =
|
|
51
|
+
openToAll["personalData"] === "tenant-members"
|
|
52
|
+
? { personalData: "tenant-members" as const }
|
|
53
|
+
: {};
|
|
54
|
+
return { openToAll: { reason: openToAll["reason"], ...personalData } };
|
|
51
55
|
}
|
|
52
56
|
return undefined;
|
|
53
57
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
// Public API
|
|
2
2
|
|
|
3
3
|
export { hasAccess } from "./access";
|
|
4
|
+
export {
|
|
5
|
+
isPrincipalStatusPlugin,
|
|
6
|
+
isTenantLifecycleStatusPlugin,
|
|
7
|
+
type PrincipalStatus,
|
|
8
|
+
type PrincipalStatusPlugin,
|
|
9
|
+
TENANT_TEARDOWN_STATUSES,
|
|
10
|
+
type TenantLifecycleStatusPlugin,
|
|
11
|
+
} from "./active-membership";
|
|
4
12
|
export {
|
|
5
13
|
collectWriteHandlerQns,
|
|
6
14
|
SECURITY_BASELINE_FEATURE_NAMES,
|
|
@@ -90,12 +98,15 @@ export {
|
|
|
90
98
|
EXT_EXTERNAL_RESOURCE,
|
|
91
99
|
EXT_FILE_PROVIDER,
|
|
92
100
|
EXT_INFRA_RESOURCE,
|
|
101
|
+
EXT_PRINCIPAL_STATUS,
|
|
93
102
|
EXT_SEARCH_ADAPTER,
|
|
94
103
|
EXT_STORAGE_PROVIDER,
|
|
95
104
|
EXT_TENANT_DATA,
|
|
105
|
+
EXT_TENANT_LIFECYCLE_STATUS,
|
|
96
106
|
EXT_USER_DATA,
|
|
97
107
|
EXT_USER_DATA_ORDER,
|
|
98
108
|
FILE_PROVIDER_CONFIG_KEY,
|
|
109
|
+
TENANT_MEMBERSHIPS_QUERY,
|
|
99
110
|
} from "./extension-names";
|
|
100
111
|
export type {
|
|
101
112
|
StorageProviderDestroyTenantHook,
|
|
@@ -283,6 +294,9 @@ export {
|
|
|
283
294
|
export type {
|
|
284
295
|
AccessRule,
|
|
285
296
|
ActionFormScreenDefinition,
|
|
297
|
+
ActiveMembership,
|
|
298
|
+
ActiveMembershipRejection,
|
|
299
|
+
ActiveMembershipResult,
|
|
286
300
|
AgentExposure,
|
|
287
301
|
AgentHandlerHints,
|
|
288
302
|
AgentRisk,
|
|
@@ -418,6 +432,7 @@ export type {
|
|
|
418
432
|
OnDeleteStrategy,
|
|
419
433
|
OpenToAllAccessRule,
|
|
420
434
|
OpenToAllDeclaration,
|
|
435
|
+
OpenToAllPersonalData,
|
|
421
436
|
ParentRefDef,
|
|
422
437
|
PersonalAnnotations,
|
|
423
438
|
PersonalAnnotationsLongText,
|
|
@@ -138,6 +138,9 @@ export type {
|
|
|
138
138
|
} from "@cosmicdrift/kumiko-types/fields";
|
|
139
139
|
export type {
|
|
140
140
|
AccessRule,
|
|
141
|
+
ActiveMembership,
|
|
142
|
+
ActiveMembershipRejection,
|
|
143
|
+
ActiveMembershipResult,
|
|
141
144
|
AgentExposure,
|
|
142
145
|
AgentHandlerHints,
|
|
143
146
|
AgentRisk,
|
|
@@ -175,6 +178,7 @@ export type {
|
|
|
175
178
|
NotifyPriority,
|
|
176
179
|
OpenToAllAccessRule,
|
|
177
180
|
OpenToAllDeclaration,
|
|
181
|
+
OpenToAllPersonalData,
|
|
178
182
|
QualifiedEventName,
|
|
179
183
|
QueryEvent,
|
|
180
184
|
QueryHandlerDef,
|
|
@@ -75,6 +75,24 @@ const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
|
75
75
|
access: { roles: ["Admin"] },
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
// --- ctx.resolveActiveMembership: gated the same as a SYSTEM queryAs/writeAs ---
|
|
79
|
+
r.queryHandler(
|
|
80
|
+
"resolve-active-membership-no-hatch",
|
|
81
|
+
z.object({}),
|
|
82
|
+
async (query, ctx) => ctx.resolveActiveMembership(query.user.id, query.user.tenantId),
|
|
83
|
+
{ access: { roles: ["User"] } },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
r.queryHandler(
|
|
87
|
+
"resolve-active-membership-with-hatch",
|
|
88
|
+
z.object({}),
|
|
89
|
+
async (query, ctx) => ctx.resolveActiveMembership(query.user.id, query.user.tenantId),
|
|
90
|
+
{
|
|
91
|
+
access: { roles: ["User"] },
|
|
92
|
+
escapeHatch: { reason: "test: needs SYSTEM to resolve membership" },
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
78
96
|
// --- Direct SYSTEM switch: gated by the CALLING handler's own escapeHatch ---
|
|
79
97
|
r.writeHandler(
|
|
80
98
|
"write-as-system-no-hatch",
|
|
@@ -276,6 +294,26 @@ describe("ctx.queryAs(SYSTEM, ...) — gated by the calling handler's escapeHatc
|
|
|
276
294
|
});
|
|
277
295
|
});
|
|
278
296
|
|
|
297
|
+
describe("ctx.resolveActiveMembership — gated like a SYSTEM queryAs/writeAs", () => {
|
|
298
|
+
test("WITHOUT escapeHatch: fails with access_denied", async () => {
|
|
299
|
+
const err = await stack.http.queryErr(
|
|
300
|
+
"idswitch-probe:query:resolve-active-membership-no-hatch",
|
|
301
|
+
{},
|
|
302
|
+
user,
|
|
303
|
+
);
|
|
304
|
+
expect(err.code).toBe("access_denied");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("WITH escapeHatch: the SYSTEM-identity gate lets the call through (no membershipQuery wired here, so it fails downstream instead)", async () => {
|
|
308
|
+
const err = await stack.http.queryErr(
|
|
309
|
+
"idswitch-probe:query:resolve-active-membership-with-hatch",
|
|
310
|
+
{},
|
|
311
|
+
user,
|
|
312
|
+
);
|
|
313
|
+
expect(err.code).not.toBe("access_denied");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
279
317
|
describe("ctx.queryAs(nonSystemUser, ...) — never gated by this feature", () => {
|
|
280
318
|
test("delegates to the target's own access check: denied when the target user lacks the role", async () => {
|
|
281
319
|
const err = await stack.http.queryErr(
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// One framework-owned composition of membership / principal-status /
|
|
2
|
+
// tenant-lifecycle checks so login, switch-tenant and MFA completion can't independently drift.
|
|
3
|
+
|
|
4
|
+
import type { DbConnection } from "../db/connection";
|
|
5
|
+
import {
|
|
6
|
+
isPrincipalStatusPlugin,
|
|
7
|
+
isTenantLifecycleStatusPlugin,
|
|
8
|
+
type PrincipalStatusPlugin,
|
|
9
|
+
TENANT_TEARDOWN_STATUSES,
|
|
10
|
+
type TenantLifecycleStatusPlugin,
|
|
11
|
+
} from "../engine/active-membership";
|
|
12
|
+
import { EXT_PRINCIPAL_STATUS, EXT_TENANT_LIFECYCLE_STATUS } from "../engine/extension-names";
|
|
13
|
+
import { createSystemUser } from "../engine/system-user";
|
|
14
|
+
import type { ActiveMembershipResult, Registry } from "../engine/types";
|
|
15
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
16
|
+
import { InternalError } from "../errors";
|
|
17
|
+
import { executeQuery } from "./dispatch-query";
|
|
18
|
+
import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
|
|
19
|
+
|
|
20
|
+
export type ActiveMembershipPolicy = {
|
|
21
|
+
// destroyRequested still counts as active — owners must be able to cancel
|
|
22
|
+
// destruction; auth-middleware's own 410 gate then only admits that write.
|
|
23
|
+
readonly allowPendingDestruction: boolean;
|
|
24
|
+
// Whether a userId with no persisted principal row counts as active — a
|
|
25
|
+
// future background caller may instead want to fail closed.
|
|
26
|
+
readonly allowUnknownPrincipal: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const INTERACTIVE_SIGN_IN_POLICY: ActiveMembershipPolicy = {
|
|
30
|
+
allowPendingDestruction: true,
|
|
31
|
+
allowUnknownPrincipal: true,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RawMembershipRow = {
|
|
35
|
+
readonly tenantId: string;
|
|
36
|
+
readonly roles: readonly string[];
|
|
37
|
+
readonly tenantName?: string;
|
|
38
|
+
readonly tenantKey?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// membershipQuery is a per-app-registered handler — engine-payload, not
|
|
42
|
+
// trusted shape. Structural validation instead of a blind cast.
|
|
43
|
+
function isMembershipRow(v: unknown): v is RawMembershipRow {
|
|
44
|
+
if (typeof v !== "object" || v === null) return false;
|
|
45
|
+
if (!("tenantId" in v) || typeof v.tenantId !== "string") return false;
|
|
46
|
+
if (!("roles" in v) || !Array.isArray(v.roles) || !v.roles.every((r) => typeof r === "string")) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if ("tenantName" in v && v.tenantName !== undefined && typeof v.tenantName !== "string") {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if ("tenantKey" in v && v.tenantKey !== undefined && typeof v.tenantKey !== "string") {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function findMembership(
|
|
59
|
+
ctx: DispatchContext,
|
|
60
|
+
userId: string,
|
|
61
|
+
tenantId: TenantId,
|
|
62
|
+
): Promise<RawMembershipRow | undefined> {
|
|
63
|
+
const rawMemberships = await executeQuery(
|
|
64
|
+
ctx,
|
|
65
|
+
ctx.membershipQuery,
|
|
66
|
+
{ userId },
|
|
67
|
+
createSystemUser(tenantId),
|
|
68
|
+
);
|
|
69
|
+
if (!Array.isArray(rawMemberships) || !rawMemberships.every(isMembershipRow)) {
|
|
70
|
+
throw new InternalError({
|
|
71
|
+
message:
|
|
72
|
+
`dispatcher.resolveActiveMembership: membershipQuery "${ctx.membershipQuery}" returned ` +
|
|
73
|
+
"a malformed result — expected an array of {tenantId, roles, tenantName?, tenantKey?} rows.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return rawMemberships.find((m) => m.tenantId === tenantId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
|
|
80
|
+
const usages = registry.getExtensionUsages(EXT_PRINCIPAL_STATUS);
|
|
81
|
+
if (usages.length !== 1) {
|
|
82
|
+
throw new InternalError({
|
|
83
|
+
message:
|
|
84
|
+
usages.length === 0
|
|
85
|
+
? `dispatcher.resolveActiveMembership: no "${EXT_PRINCIPAL_STATUS}" provider registered — mount the user feature.`
|
|
86
|
+
: `dispatcher.resolveActiveMembership: multiple "${EXT_PRINCIPAL_STATUS}" providers registered — exactly one is expected.`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const usage = usages[0];
|
|
90
|
+
if (!usage || !isPrincipalStatusPlugin(usage.options)) {
|
|
91
|
+
throw new InternalError({
|
|
92
|
+
message: `dispatcher.resolveActiveMembership: "${usage?.entityName}" registered under "${EXT_PRINCIPAL_STATUS}" without a resolveStatus(userId, {db}) — extension options must be a PrincipalStatusPlugin.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return usage.options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveLifecyclePlugin(registry: Registry): TenantLifecycleStatusPlugin | undefined {
|
|
99
|
+
const usages = registry.getExtensionUsages(EXT_TENANT_LIFECYCLE_STATUS);
|
|
100
|
+
if (usages.length > 1) {
|
|
101
|
+
throw new InternalError({
|
|
102
|
+
message: `dispatcher.resolveActiveMembership: multiple "${EXT_TENANT_LIFECYCLE_STATUS}" providers registered — exactly one (or zero) is expected.`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const usage = usages[0];
|
|
106
|
+
if (!usage) return undefined;
|
|
107
|
+
if (!isTenantLifecycleStatusPlugin(usage.options)) {
|
|
108
|
+
throw new InternalError({
|
|
109
|
+
message: `dispatcher.resolveActiveMembership: "${usage.entityName}" registered under "${EXT_TENANT_LIFECYCLE_STATUS}" without a resolveStatus(tenantId, {db}) — extension options must be a TenantLifecycleStatusPlugin.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return usage.options;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isTeardownRejected(
|
|
116
|
+
status: { readonly status: string } | null,
|
|
117
|
+
policy: ActiveMembershipPolicy,
|
|
118
|
+
): boolean {
|
|
119
|
+
return (
|
|
120
|
+
status !== null &&
|
|
121
|
+
TENANT_TEARDOWN_STATUSES.has(status.status) &&
|
|
122
|
+
!(status.status === "destroyRequested" && policy.allowPendingDestruction)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Order is load-bearing: a non-member must never learn a foreign tenant's
|
|
127
|
+
// lifecycle/blocked state, so membership is resolved and checked FIRST.
|
|
128
|
+
export async function resolveActiveMembershipFn(
|
|
129
|
+
ctx: DispatchContext,
|
|
130
|
+
userId: string,
|
|
131
|
+
tenantId: TenantId,
|
|
132
|
+
policy: ActiveMembershipPolicy,
|
|
133
|
+
): Promise<ActiveMembershipResult> {
|
|
134
|
+
const { registry } = ctx;
|
|
135
|
+
|
|
136
|
+
const membership = await findMembership(ctx, userId, tenantId);
|
|
137
|
+
if (!membership) {
|
|
138
|
+
return { kind: "rejected", reason: "not_a_member" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const principalPlugin = resolvePrincipalPlugin(registry);
|
|
142
|
+
const dbSource = resolveDbSource(ctx, undefined);
|
|
143
|
+
if (!dbSource) {
|
|
144
|
+
throw new InternalError({
|
|
145
|
+
message:
|
|
146
|
+
"dispatcher.resolveActiveMembership requires a database connection — none is configured.",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// resolveDbSource(ctx, undefined) never returns a tx, only AppContext.db —
|
|
150
|
+
// the dispatcher boundary holds that as the root DbConnection, same as buildAuthClaimsContext.
|
|
151
|
+
const db = dbSource as DbConnection; // @cast-boundary db-operator
|
|
152
|
+
|
|
153
|
+
const principalStatus = await principalPlugin.resolveStatus(userId, { db });
|
|
154
|
+
if (
|
|
155
|
+
principalStatus === "blocked" ||
|
|
156
|
+
(principalStatus === "unknown" && !policy.allowUnknownPrincipal)
|
|
157
|
+
) {
|
|
158
|
+
return { kind: "rejected", reason: "principal_blocked" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const lifecyclePlugin = resolveLifecyclePlugin(registry);
|
|
162
|
+
if (lifecyclePlugin) {
|
|
163
|
+
const lifecycle = await lifecyclePlugin.resolveStatus(tenantId, { db });
|
|
164
|
+
if (isTeardownRejected(lifecycle, policy)) {
|
|
165
|
+
return { kind: "rejected", reason: "tenant_teardown" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
kind: "active",
|
|
171
|
+
membership: {
|
|
172
|
+
tenantId,
|
|
173
|
+
roles: membership.roles,
|
|
174
|
+
...(membership.tenantName !== undefined ? { tenantName: membership.tenantName } : {}),
|
|
175
|
+
...(membership.tenantKey !== undefined ? { tenantKey: membership.tenantKey } : {}),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
} from "../observability";
|
|
65
65
|
import { buildBucketKey } from "../rate-limit";
|
|
66
66
|
import { createTzContext, isValidIanaTimeZone } from "../time";
|
|
67
|
+
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
|
|
67
68
|
import { appendDomainEventCore } from "./append-event-core";
|
|
68
69
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
|
|
69
70
|
import { executeQuery } from "./dispatch-query";
|
|
@@ -75,7 +76,7 @@ import {
|
|
|
75
76
|
} from "./dispatcher-utils";
|
|
76
77
|
import type { IdempotencyGuard } from "./idempotency";
|
|
77
78
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
78
|
-
import { createGatedIdentitySwitch } from "./system-identity-switch";
|
|
79
|
+
import { createGatedIdentitySwitch, systemIdentitySwitchDenied } from "./system-identity-switch";
|
|
79
80
|
import type { TenantTimezoneCache } from "./tenant-timezone-cache";
|
|
80
81
|
|
|
81
82
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
@@ -123,6 +124,9 @@ export type DispatchContext = {
|
|
|
123
124
|
tenantTimezoneCache: TenantTimezoneCache;
|
|
124
125
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
125
126
|
meter: ReturnType<typeof getFallbackMeter>;
|
|
127
|
+
// Qualified name of the membership-list query handler consulted by
|
|
128
|
+
// resolveActiveMembershipFn — defaults to TENANT_MEMBERSHIPS_QUERY, overridable via DispatcherOptions.
|
|
129
|
+
membershipQuery: string;
|
|
126
130
|
};
|
|
127
131
|
|
|
128
132
|
// Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
|
|
@@ -591,6 +595,15 @@ export async function buildHandlerContext(
|
|
|
591
595
|
// dispatcher.resolveAuthClaims) cannot drift.
|
|
592
596
|
resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(ctx, claimsUser), // @wrapper-known semantic-alias
|
|
593
597
|
|
|
598
|
+
// Thin pass-through, same reasoning as resolveAuthClaims above — one
|
|
599
|
+
// resolve impl lives on the dispatcher so callers can't drift apart.
|
|
600
|
+
// Internally queries memberships as SYSTEM, so it needs the same grant
|
|
601
|
+
// as a SYSTEM queryAs/writeAs (r.systemScope() or escapeHatch).
|
|
602
|
+
resolveActiveMembership: (userId: string, tenantId: TenantId) => {
|
|
603
|
+
if (!allowSystemIdentity) throw systemIdentitySwitchDenied(`handler "${type}"`);
|
|
604
|
+
return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
|
|
605
|
+
},
|
|
606
|
+
|
|
594
607
|
// Feature-effective check for in-handler opt-in logic. Scope:
|
|
595
608
|
// **current user's tenant** — for cross-tenant lookups (rare,
|
|
596
609
|
// SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
2
|
import type { buildEntityTable } from "../db/table-builder";
|
|
3
|
+
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names";
|
|
3
4
|
import type { defineTransitions } from "../engine/state-machine";
|
|
4
5
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
5
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ActiveMembershipResult,
|
|
8
|
+
AppContext,
|
|
9
|
+
JobRunnerRef,
|
|
10
|
+
Registry,
|
|
11
|
+
SessionUser,
|
|
12
|
+
WriteResult,
|
|
13
|
+
} from "../engine/types";
|
|
14
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
6
15
|
import { reraiseAsKumikoError } from "../errors";
|
|
7
16
|
import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
|
|
17
|
+
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
|
|
8
18
|
import { runBatch, unwrapSingle } from "./dispatch-batch";
|
|
9
19
|
import { executeQuery } from "./dispatch-query";
|
|
10
20
|
import type { BatchCommand, BatchResult, DispatchContext } from "./dispatch-shared";
|
|
@@ -25,6 +35,9 @@ export type DispatcherOptions = {
|
|
|
25
35
|
idempotency?: IdempotencyGuard;
|
|
26
36
|
lifecycle?: LifecycleHooks;
|
|
27
37
|
jobRunner?: JobRunnerRef;
|
|
38
|
+
// Qualified name of the membership-list query handler consulted by
|
|
39
|
+
// dispatcher.resolveActiveMembership. Defaults to TENANT_MEMBERSHIPS_QUERY.
|
|
40
|
+
membershipQuery?: string;
|
|
28
41
|
// Resolves the effective-feature set per tenant — the dispatcher uses
|
|
29
42
|
// it to gate calls to handlers of disabled features (403 feature_disabled)
|
|
30
43
|
// and to populate ctx.hasFeature. Absent = all features treated as
|
|
@@ -81,6 +94,9 @@ export type Dispatcher = {
|
|
|
81
94
|
// This is the single resolve implementation — ctx.resolveAuthClaims is a
|
|
82
95
|
// thin pass-through so both entry points can't drift.
|
|
83
96
|
resolveAuthClaims(user: SessionUser): Promise<Record<string, unknown>>;
|
|
97
|
+
// Membership check for interactive sign-in paths (login, MFA completion,
|
|
98
|
+
// tenant switch) — single resolve implementation so callers can't independently drift.
|
|
99
|
+
resolveActiveMembership(userId: string, tenantId: TenantId): Promise<ActiveMembershipResult>;
|
|
84
100
|
};
|
|
85
101
|
|
|
86
102
|
export function createDispatcher(
|
|
@@ -89,6 +105,7 @@ export function createDispatcher(
|
|
|
89
105
|
options: DispatcherOptions = {},
|
|
90
106
|
): Dispatcher {
|
|
91
107
|
const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
|
|
108
|
+
const membershipQuery = options.membershipQuery ?? TENANT_MEMBERSHIPS_QUERY;
|
|
92
109
|
|
|
93
110
|
// Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
|
|
94
111
|
const tableCache = new Map<string, ReturnType<typeof buildEntityTable>>();
|
|
@@ -116,6 +133,7 @@ export function createDispatcher(
|
|
|
116
133
|
tenantTimezoneCache,
|
|
117
134
|
tracer: dispatcherTracer,
|
|
118
135
|
meter: dispatcherMeter,
|
|
136
|
+
membershipQuery,
|
|
119
137
|
};
|
|
120
138
|
|
|
121
139
|
return {
|
|
@@ -143,5 +161,8 @@ export function createDispatcher(
|
|
|
143
161
|
},
|
|
144
162
|
|
|
145
163
|
resolveAuthClaims: (user) => resolveAuthClaimsFn(ctx, user),
|
|
164
|
+
|
|
165
|
+
resolveActiveMembership: (userId, tenantId) =>
|
|
166
|
+
resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY),
|
|
146
167
|
};
|
|
147
168
|
}
|