@cosmicdrift/kumiko-framework 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 +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +35 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- 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/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +4 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +3 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +89 -10
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
- package/src/ui-types/index.ts +1 -0
|
@@ -1,38 +1,88 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AccessRule,
|
|
3
|
+
FeatureDefinition,
|
|
4
|
+
OwnershipMap,
|
|
5
|
+
OwnershipRule,
|
|
6
|
+
QueryHandlerDef,
|
|
7
|
+
WriteHandlerDef,
|
|
8
|
+
} from "../types";
|
|
2
9
|
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
3
|
-
import {
|
|
10
|
+
import { collectZodObjectKeys } from "./zod-shape";
|
|
4
11
|
|
|
5
12
|
type HandlerKind = "write" | "query" | "stream";
|
|
6
13
|
|
|
7
14
|
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
8
15
|
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
9
|
-
// sense openToAll
|
|
16
|
+
// sense the openToAll personal-data check is guarding against.
|
|
10
17
|
function isPersonalDataField(field: unknown): boolean {
|
|
11
18
|
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
12
19
|
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
function
|
|
22
|
+
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
23
|
+
if (rule === "all" || rule.kind !== "from") return false;
|
|
24
|
+
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The executor checks access.write against every created/updated row; one "all" role
|
|
28
|
+
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
29
|
+
function writeMapBindsRowsToCaller(
|
|
30
|
+
writeMap: OwnershipMap | undefined,
|
|
31
|
+
ownerColumn: string,
|
|
32
|
+
): boolean {
|
|
33
|
+
const rules = Object.values(writeMap ?? {});
|
|
34
|
+
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ROW_ID_COLUMN = "id";
|
|
38
|
+
|
|
39
|
+
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
40
|
+
// makes that row the caller — on any other entity "self" names a third party.
|
|
41
|
+
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
42
|
+
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
43
|
+
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
48
|
+
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
49
|
+
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function personalFieldNames(
|
|
53
|
+
entity: EntityDefinition,
|
|
54
|
+
honorOwnerBinding: boolean,
|
|
55
|
+
): ReadonlySet<string> {
|
|
16
56
|
const names = new Set<string>();
|
|
17
57
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
18
|
-
|
|
58
|
+
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
59
|
+
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
19
60
|
}
|
|
20
61
|
return names;
|
|
21
62
|
}
|
|
22
63
|
|
|
64
|
+
// escapeHatch and r.systemScope() can write around the entity's write map
|
|
65
|
+
// (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
|
|
66
|
+
function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
|
|
67
|
+
return handler.escapeHatch !== undefined || feature.systemScope;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Owner binding counts only for a handler mapped to one entity.
|
|
23
71
|
function candidatePersonalFieldNames(
|
|
24
72
|
feature: FeatureDefinition,
|
|
25
73
|
handlerName: string,
|
|
74
|
+
handler: WriteHandlerDef,
|
|
26
75
|
): ReadonlySet<string> {
|
|
27
76
|
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
28
77
|
const entities = feature.entities ?? {};
|
|
29
78
|
if (mappedEntityName) {
|
|
30
79
|
const entity = entities[mappedEntityName];
|
|
31
|
-
|
|
80
|
+
const honorOwnerBinding = !canWriteAroundExecutor(feature, handler);
|
|
81
|
+
return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
|
|
32
82
|
}
|
|
33
83
|
const names = new Set<string>();
|
|
34
84
|
for (const entity of Object.values(entities)) {
|
|
35
|
-
for (const name of personalFieldNames(entity)) names.add(name);
|
|
85
|
+
for (const name of personalFieldNames(entity, false)) names.add(name);
|
|
36
86
|
}
|
|
37
87
|
return names;
|
|
38
88
|
}
|
|
@@ -52,8 +102,16 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
52
102
|
return "openToAll" in access;
|
|
53
103
|
}
|
|
54
104
|
|
|
55
|
-
|
|
56
|
-
|
|
105
|
+
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
106
|
+
function declaredPersonalData(access: AccessRule): unknown {
|
|
107
|
+
if (!("openToAll" in access)) return undefined;
|
|
108
|
+
const openToAll: unknown = access.openToAll;
|
|
109
|
+
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
110
|
+
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
114
|
+
return declaredPersonalData(access) === "tenant-members";
|
|
57
115
|
}
|
|
58
116
|
|
|
59
117
|
function validateOpenToAllReason(
|
|
@@ -86,19 +144,32 @@ function validateEscapeHatchReason(
|
|
|
86
144
|
);
|
|
87
145
|
}
|
|
88
146
|
|
|
89
|
-
function
|
|
147
|
+
function validatePersonalDataOnlyOnWrite(
|
|
90
148
|
feature: FeatureDefinition,
|
|
91
149
|
kind: HandlerKind,
|
|
92
150
|
handlerName: string,
|
|
93
151
|
access: AccessRule,
|
|
94
152
|
): void {
|
|
95
|
-
// skip: write handlers may declare
|
|
96
|
-
if (kind === "write" ||
|
|
153
|
+
// skip: write handlers may declare personalData, or it wasn't declared here
|
|
154
|
+
if (kind === "write" || declaredPersonalData(access) === undefined) return;
|
|
97
155
|
throw new Error(
|
|
98
156
|
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
|
|
157
|
+
"openToAll.personalData — it only applies to write handlers, whose input is " +
|
|
158
|
+
"checked for personal-data fields.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validatePersonalDataValue(
|
|
163
|
+
feature: FeatureDefinition,
|
|
164
|
+
handlerName: string,
|
|
165
|
+
access: AccessRule,
|
|
166
|
+
): void {
|
|
167
|
+
const declared = declaredPersonalData(access);
|
|
168
|
+
// skip: nothing declared, or the one supported value
|
|
169
|
+
if (declared === undefined || declared === "tenant-members") return;
|
|
170
|
+
throw new Error(
|
|
171
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
|
|
172
|
+
`openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
|
|
102
173
|
);
|
|
103
174
|
}
|
|
104
175
|
|
|
@@ -108,20 +179,22 @@ function validateOpenToAllPersonalData(
|
|
|
108
179
|
handler: WriteHandlerDef,
|
|
109
180
|
): void {
|
|
110
181
|
const access = handler.access;
|
|
111
|
-
// skip: no openToAll declared, or
|
|
112
|
-
if (!hasOpenToAll(access) ||
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const personalNames = candidatePersonalFieldNames(feature, handlerName);
|
|
117
|
-
const offending = Object.keys(shape).filter((key) => personalNames.has(key));
|
|
182
|
+
// skip: no openToAll declared, or the handler declares tenant members may write personal data
|
|
183
|
+
if (!hasOpenToAll(access) || declaresTenantMembersPersonalData(access)) return;
|
|
184
|
+
const inputKeys = collectZodObjectKeys(handler.schema);
|
|
185
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler);
|
|
186
|
+
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
118
187
|
// skip: no personal-data fields in the handler's input
|
|
119
188
|
if (offending.length === 0) return;
|
|
120
189
|
throw new Error(
|
|
121
190
|
`[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
|
|
122
|
-
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")}
|
|
123
|
-
"
|
|
124
|
-
"
|
|
191
|
+
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} that are ` +
|
|
192
|
+
"not bound to the caller. Choose one: (1) bind rows to the caller — every role in the " +
|
|
193
|
+
'entity\'s access.write is from("user:id", "<ownerField>") for a personal: { of: "<ownerField>" } ' +
|
|
194
|
+
'field, or from("user:id", "id") for a personal: "self" field, with no escapeHatch on the ' +
|
|
195
|
+
"handler and no r.systemScope() on its feature; (2) restrict access to roles; (3) declare " +
|
|
196
|
+
'{ openToAll: { reason: "<why any signed-in tenant member may write this personal data>", ' +
|
|
197
|
+
'personalData: "tenant-members" } }.',
|
|
125
198
|
);
|
|
126
199
|
}
|
|
127
200
|
|
|
@@ -129,15 +202,16 @@ export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
|
129
202
|
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
130
203
|
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
131
204
|
validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
|
|
205
|
+
validatePersonalDataValue(feature, handlerName, handler.access);
|
|
132
206
|
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
133
207
|
}
|
|
134
208
|
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
135
209
|
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
136
210
|
validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
|
|
137
|
-
|
|
211
|
+
validatePersonalDataOnlyOnWrite(feature, "query", handlerName, handler.access);
|
|
138
212
|
}
|
|
139
213
|
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
140
214
|
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
141
|
-
|
|
215
|
+
validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
142
216
|
}
|
|
143
217
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
|
|
4
|
+
// surfaces the systemStream invariant once across all features before any dispatch.
|
|
5
|
+
export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
|
|
6
|
+
for (const feature of features) {
|
|
7
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
8
|
+
if (entity.tenancy === "global" && entity.systemStream !== true) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
|
|
11
|
+
"also declare systemStream: true — a global aggregate's event stream must live on " +
|
|
12
|
+
"the system tenant, not the creating tenant's.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./entity-handler";
|
|
37
37
|
import { validateEntityListScreens } from "./entity-list-screens";
|
|
38
38
|
import { validateGdprStoragePersistence } from "./gdpr-storage";
|
|
39
|
+
import { validateGlobalTenancyEntities } from "./global-tenancy";
|
|
39
40
|
import { validateI18nSurfaceKeys } from "./i18n-keys";
|
|
40
41
|
import {
|
|
41
42
|
collectKnownRoles,
|
|
@@ -238,6 +239,7 @@ export function validateBoot(
|
|
|
238
239
|
validateRelatedListSectionQueries(features);
|
|
239
240
|
validateExtensionPreSaveWiring(features);
|
|
240
241
|
validateGdprStoragePersistence(features);
|
|
242
|
+
validateGlobalTenancyEntities(features);
|
|
241
243
|
validateFeatureBootChecks(features);
|
|
242
244
|
|
|
243
245
|
if (hasEncryptedFields) {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
2
|
-
import {
|
|
2
|
+
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
3
|
import { LifecycleHookTypes } from "./constants";
|
|
4
4
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
5
5
|
import { resolveName } from "./handler-helpers";
|
|
@@ -143,7 +143,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
143
143
|
const wrapped: LifecycleHookFn | ValidationHookFn =
|
|
144
144
|
type === "validation"
|
|
145
145
|
? fn
|
|
146
|
-
:
|
|
146
|
+
: bindHookEscapeHatchGrant(fn as LifecycleHookFn, hookLabel, options?.escapeHatch); // @cast-boundary engine-bridge
|
|
147
147
|
|
|
148
148
|
// Entity-wide target ("all write/query handlers of this entity") —
|
|
149
149
|
// replaces the old r.entityHook(type, entity, fn).
|
package/src/engine/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { hasAccess } from "./access";
|
|
|
4
4
|
export {
|
|
5
5
|
isPrincipalStatusPlugin,
|
|
6
6
|
isTenantLifecycleStatusPlugin,
|
|
7
|
+
type PrincipalProfile,
|
|
7
8
|
type PrincipalStatus,
|
|
8
9
|
type PrincipalStatusPlugin,
|
|
9
10
|
TENANT_TEARDOWN_STATUSES,
|
|
@@ -410,6 +411,7 @@ export type {
|
|
|
410
411
|
ListSortSpec,
|
|
411
412
|
LongTextFindability,
|
|
412
413
|
ManyToManyRelation,
|
|
414
|
+
MemberReader,
|
|
413
415
|
MetricNavigate,
|
|
414
416
|
MetricSpec,
|
|
415
417
|
MspErrorMode,
|
|
@@ -432,6 +434,7 @@ export type {
|
|
|
432
434
|
OnDeleteStrategy,
|
|
433
435
|
OpenToAllAccessRule,
|
|
434
436
|
OpenToAllDeclaration,
|
|
437
|
+
OpenToAllPersonalData,
|
|
435
438
|
ParentRefDef,
|
|
436
439
|
PersonalAnnotations,
|
|
437
440
|
PersonalAnnotationsLongText,
|
|
@@ -477,6 +480,7 @@ export type {
|
|
|
477
480
|
SecretsEditSection,
|
|
478
481
|
SelectFieldDef,
|
|
479
482
|
SessionUser,
|
|
483
|
+
SessionUserOrigin,
|
|
480
484
|
StreamHandlerDef,
|
|
481
485
|
StreamHandlerFn,
|
|
482
486
|
Subscribe,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
-
import {
|
|
2
|
+
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
|
|
3
3
|
import { resolveName } from "./handler-helpers";
|
|
4
4
|
import type {
|
|
5
5
|
RegistryState,
|
|
@@ -126,10 +126,10 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
126
126
|
// RegistrarExtensionRegistration and use that here.
|
|
127
127
|
const extOwner = "*";
|
|
128
128
|
// Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
|
|
129
|
-
// (system-identity-switch.ts) —
|
|
129
|
+
// (system-identity-switch.ts) — bindHookEscapeHatchGrant(..., undefined) below.
|
|
130
130
|
if (ext.hooks) {
|
|
131
131
|
if (ext.hooks.postSave) {
|
|
132
|
-
const wrapped =
|
|
132
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
133
133
|
ext.hooks.postSave,
|
|
134
134
|
`postSave hook of extension "${usage.extensionName}"`,
|
|
135
135
|
undefined,
|
|
@@ -143,7 +143,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
143
143
|
state.entityPostSaveHooks.set(usage.entityName, existing);
|
|
144
144
|
}
|
|
145
145
|
if (ext.hooks.preDelete) {
|
|
146
|
-
const wrapped =
|
|
146
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
147
147
|
ext.hooks.preDelete,
|
|
148
148
|
`preDelete hook of extension "${usage.extensionName}"`,
|
|
149
149
|
undefined,
|
|
@@ -157,7 +157,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
157
157
|
state.entityPreDeleteHooks.set(usage.entityName, existing);
|
|
158
158
|
}
|
|
159
159
|
if (ext.hooks.postDelete) {
|
|
160
|
-
const wrapped =
|
|
160
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
161
161
|
ext.hooks.postDelete,
|
|
162
162
|
`postDelete hook of extension "${usage.extensionName}"`,
|
|
163
163
|
undefined,
|
|
@@ -172,7 +172,7 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
172
172
|
}
|
|
173
173
|
// preSave on extensions: store as handler hook for all CRUD handlers of this entity
|
|
174
174
|
if (ext.hooks.preSave) {
|
|
175
|
-
const wrapped =
|
|
175
|
+
const wrapped = bindHookEscapeHatchGrant(
|
|
176
176
|
ext.hooks.preSave,
|
|
177
177
|
`preSave hook of extension "${usage.extensionName}"`,
|
|
178
178
|
undefined,
|
|
@@ -171,6 +171,7 @@ export type {
|
|
|
171
171
|
HandlerRef,
|
|
172
172
|
JobContext,
|
|
173
173
|
JobRunnerRef,
|
|
174
|
+
MemberReader,
|
|
174
175
|
NameOrRef,
|
|
175
176
|
NotifyFactory,
|
|
176
177
|
NotifyFn,
|
|
@@ -178,6 +179,7 @@ export type {
|
|
|
178
179
|
NotifyPriority,
|
|
179
180
|
OpenToAllAccessRule,
|
|
180
181
|
OpenToAllDeclaration,
|
|
182
|
+
OpenToAllPersonalData,
|
|
181
183
|
QualifiedEventName,
|
|
182
184
|
QueryEvent,
|
|
183
185
|
QueryHandlerDef,
|
|
@@ -185,6 +187,7 @@ export type {
|
|
|
185
187
|
RateLimitOption,
|
|
186
188
|
RateLimitPer,
|
|
187
189
|
SessionUser,
|
|
190
|
+
SessionUserOrigin,
|
|
188
191
|
StreamHandlerDef,
|
|
189
192
|
StreamHandlerFn,
|
|
190
193
|
TryAppendEventFn,
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { buildServer, withFileProviderResolver } from "../api/server";
|
|
|
38
38
|
import type { SseBroker } from "../api/sse-broker";
|
|
39
39
|
import type { PgClient } from "../db/connection";
|
|
40
40
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
41
|
-
import type { AppContext,
|
|
41
|
+
import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
|
|
42
42
|
import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
|
|
43
43
|
import { createJobRunner } from "../jobs/job-runner";
|
|
44
44
|
import type { Lifecycle } from "../lifecycle";
|
|
@@ -47,6 +47,7 @@ import type { ObservabilityOptions, ObservabilityProvider } from "../observabili
|
|
|
47
47
|
import { createNoopProvider } from "../observability";
|
|
48
48
|
import type { EventDedup, EventDispatcher } from "../pipeline";
|
|
49
49
|
import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher";
|
|
50
|
+
import { dispatcherToWriteRef } from "../pipeline/dispatcher";
|
|
50
51
|
import type { SystemHooks } from "../pipeline/lifecycle-pipeline";
|
|
51
52
|
|
|
52
53
|
// Shared fields across all three modes. A caller that swaps between
|
|
@@ -206,18 +207,6 @@ function contextWithObservability(
|
|
|
206
207
|
};
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
// Adapts the command-dispatcher's positional (type, payload, user) calls to
|
|
210
|
-
// DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
|
|
211
|
-
// pass identity explicitly per call (boot-time singleton), while Dispatcher
|
|
212
|
-
// takes it last (request-scoped closure caller). Same underlying pipeline,
|
|
213
|
-
// different argument order.
|
|
214
|
-
function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
|
|
215
|
-
return {
|
|
216
|
-
write: (user, qn, payload) => dispatcher.write(qn, payload, user),
|
|
217
|
-
queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
210
|
// buildApiServer shapes ServerOptions from API-mode caller-options.
|
|
222
211
|
// AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
|
|
223
212
|
// subtyping makes the all-in-one path a valid caller without an explicit
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -97,6 +97,26 @@ system_identity_switch_denied:
|
|
|
97
97
|
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
98
98
|
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
99
99
|
|
|
100
|
+
member_resolution_denied:
|
|
101
|
+
endUser: |
|
|
102
|
+
Diese Aktion ist nicht erlaubt.
|
|
103
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
104
|
+
developer: |
|
|
105
|
+
`AccessDeniedError`: `ctx.queryAsMember(userId, ...)` konnte für `userId`
|
|
106
|
+
keine aktive Mitgliedschaft im Tenant des Aufrufers auflösen (kein
|
|
107
|
+
Mitglied, Principal gesperrt, oder der Tenant befindet sich im Teardown).
|
|
108
|
+
Bewusst generisch — `details` verrät nie, welche Prüfung fehlgeschlagen ist.
|
|
109
|
+
|
|
110
|
+
member_resolution_read_only:
|
|
111
|
+
endUser: |
|
|
112
|
+
Diese Aktion ist nicht erlaubt.
|
|
113
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
114
|
+
developer: |
|
|
115
|
+
`AccessDeniedError`: ein über `ctx.queryAsMember` erreichter Query-Handler
|
|
116
|
+
hat versucht zu schreiben (`ctx.write`/`writeAs`/`appendEvent`/
|
|
117
|
+
`jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
|
|
118
|
+
kein `writeAsMember`.
|
|
119
|
+
|
|
100
120
|
agent.tool_not_allowed:
|
|
101
121
|
endUser: |
|
|
102
122
|
Dieses Tool ist im aktuellen Modus nicht verfügbar.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -94,6 +94,25 @@ system_identity_switch_denied:
|
|
|
94
94
|
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
95
95
|
move the logic into an `r.systemScope()` feature or a job.
|
|
96
96
|
|
|
97
|
+
member_resolution_denied:
|
|
98
|
+
endUser: |
|
|
99
|
+
This action isn't allowed.
|
|
100
|
+
Please contact an administrator if you believe this is a mistake.
|
|
101
|
+
developer: |
|
|
102
|
+
`AccessDeniedError`: `ctx.queryAsMember(userId, ...)` could not resolve an
|
|
103
|
+
active membership for `userId` in the caller's tenant (not a member,
|
|
104
|
+
principal blocked, or the tenant is in teardown). Deliberately generic —
|
|
105
|
+
`details` never says which check failed.
|
|
106
|
+
|
|
107
|
+
member_resolution_read_only:
|
|
108
|
+
endUser: |
|
|
109
|
+
This action isn't allowed.
|
|
110
|
+
Please contact an administrator if you believe this is a mistake.
|
|
111
|
+
developer: |
|
|
112
|
+
`AccessDeniedError`: a query handler reached via `ctx.queryAsMember` tried
|
|
113
|
+
to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
|
|
114
|
+
member principals are read-only — there is no `writeAsMember`.
|
|
115
|
+
|
|
97
116
|
agent.tool_not_allowed:
|
|
98
117
|
endUser: |
|
|
99
118
|
This tool is not available in the current mode.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -42,6 +42,14 @@ export const FrameworkReasons = {
|
|
|
42
42
|
// AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
|
|
43
43
|
// SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
|
|
44
44
|
systemIdentitySwitchDenied: "system_identity_switch_denied",
|
|
45
|
+
|
|
46
|
+
// AccessDeniedError: ctx.queryAsMember's userId is not an active member of
|
|
47
|
+
// ctx's tenant. Deliberately generic — a caller can't probe which check failed.
|
|
48
|
+
memberResolutionDenied: "member_resolution_denied",
|
|
49
|
+
|
|
50
|
+
// AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
|
|
51
|
+
// write — a resolved member principal is read-only by construction.
|
|
52
|
+
memberResolutionReadOnly: "member_resolution_read_only",
|
|
45
53
|
} as const;
|
|
46
54
|
|
|
47
55
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type DispatchWriteRef,
|
|
11
11
|
type JobContext,
|
|
12
12
|
type JobRunIn,
|
|
13
|
+
type MemberReader,
|
|
13
14
|
type Registry,
|
|
14
15
|
type SessionUser,
|
|
15
16
|
SYSTEM_TENANT_ID,
|
|
@@ -552,6 +553,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
552
553
|
tenantId,
|
|
553
554
|
})
|
|
554
555
|
: context.derivatives;
|
|
556
|
+
// Per-job-run reader, created lazily on queryAsMember's first call and
|
|
557
|
+
// cached for the rest of this run.
|
|
558
|
+
let memberReader: MemberReader | undefined;
|
|
555
559
|
const jobContext: JobContext = {
|
|
556
560
|
...context,
|
|
557
561
|
// Same union as configDb above — job runners are always constructed
|
|
@@ -597,6 +601,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
597
601
|
}
|
|
598
602
|
return dispatchWriteRef.queryAs(user, qn, payload);
|
|
599
603
|
},
|
|
604
|
+
queryAsMember: (userId: string, qn: string, payload: unknown) => {
|
|
605
|
+
if (!dispatchWriteRef) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
"JobContext.queryAsMember called before dispatcher attached — call attachDispatcher() first",
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
// Lazy per-run reader, mirroring the handler-context reader.
|
|
611
|
+
memberReader ??= dispatchWriteRef.createMemberReader(tenantId);
|
|
612
|
+
return memberReader(userId, qn, payload);
|
|
613
|
+
},
|
|
600
614
|
};
|
|
601
615
|
|
|
602
616
|
await options.onJobStart?.(jobName, jobId, meta);
|