@cosmicdrift/kumiko-framework 0.209.1 → 0.211.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/__tests__/entity-permalink-open.integration.test.ts +6 -1
- package/src/changes.json +56 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
- package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/cursor.test.ts +26 -1
- package/src/db/__tests__/eagerload.integration.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
- package/src/db/cursor.ts +32 -0
- package/src/db/event-store-executor-read.ts +80 -9
- package/src/db/index.ts +2 -2
- package/src/db/pg-error.ts +7 -0
- package/src/db/queries/backfill-pii.ts +188 -18
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
- package/src/engine/__tests__/factories-personal.test.ts +130 -0
- package/src/engine/__tests__/field-access.test.ts +1 -23
- package/src/engine/__tests__/store-table.test.ts +4 -1
- package/src/engine/boot-validator/pii-retention.ts +22 -54
- package/src/engine/build-config-feature-schema.ts +9 -1
- package/src/engine/factories.ts +108 -30
- package/src/engine/field-access.ts +2 -13
- package/src/engine/index.ts +7 -1
- package/src/engine/types/index.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
- package/src/testing/shared-entities.ts +6 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { checkWriteFieldRoles, filterReadFields
|
|
2
|
+
import { checkWriteFieldRoles, filterReadFields } from "../field-access";
|
|
3
3
|
import type { EntityDefinition } from "../types";
|
|
4
4
|
|
|
5
5
|
const entity: EntityDefinition = {
|
|
@@ -26,28 +26,6 @@ describe("filterReadFields", () => {
|
|
|
26
26
|
expect(filtered["secret"]).toBe("visible");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
test("masks piiEncrypted fields instead of stripping them (kumiko-platform#463)", () => {
|
|
30
|
-
const entityWithPii: EntityDefinition = {
|
|
31
|
-
fields: {
|
|
32
|
-
...entity.fields,
|
|
33
|
-
iban: {
|
|
34
|
-
type: "text",
|
|
35
|
-
piiEncrypted: true,
|
|
36
|
-
tenantOwned: true,
|
|
37
|
-
access: { read: { admin: "all" } },
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
const row = { id: 1, title: "Hello", secret: "hidden", iban: "DE89370400440532013000" };
|
|
42
|
-
|
|
43
|
-
const filteredForEditor = filterReadFields(entityWithPii, row, editor);
|
|
44
|
-
expect(filteredForEditor["secret"]).toBeUndefined();
|
|
45
|
-
expect(filteredForEditor["iban"]).toBe(PII_MASKED_VALUE);
|
|
46
|
-
|
|
47
|
-
const filteredForAdmin = filterReadFields(entityWithPii, row, admin);
|
|
48
|
-
expect(filteredForAdmin["iban"]).toBe("DE89370400440532013000");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
29
|
test("filters each row of an embedded list, keeping it an array", () => {
|
|
52
30
|
const entityWithLines: EntityDefinition = {
|
|
53
31
|
fields: {
|
|
@@ -204,7 +204,10 @@ describe("createRegistry — store tables with PII-annotated fields (#820)", ()
|
|
|
204
204
|
table: "rt_pii_probe",
|
|
205
205
|
fields: {
|
|
206
206
|
userId: createTextField({ required: true }),
|
|
207
|
-
ip: createTextField({
|
|
207
|
+
ip: createTextField({
|
|
208
|
+
personal: { of: "userId" },
|
|
209
|
+
find: "none",
|
|
210
|
+
}),
|
|
208
211
|
},
|
|
209
212
|
});
|
|
210
213
|
const piiMeta = deriveEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ResolvedPiiFlags } from "../types/fields";
|
|
3
3
|
import {
|
|
4
4
|
PII_DIRECT_NAME_HINTS,
|
|
5
5
|
PII_USER_OWNED_NAME_HINTS,
|
|
@@ -24,7 +24,7 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
|
24
24
|
// A field carries a subject binding — pii/userOwned/tenantOwned mark
|
|
25
25
|
// annotated content, subjectRef marks a bare FK into `user` with no
|
|
26
26
|
// annotated content of its own but the same Art.17 obligations (#1645).
|
|
27
|
-
function hasSubjectAnnotation(annot:
|
|
27
|
+
function hasSubjectAnnotation(annot: ResolvedPiiFlags): boolean {
|
|
28
28
|
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.subjectRef);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -43,7 +43,7 @@ function hasSubjectAnnotation(annot: PiiAnnotations): boolean {
|
|
|
43
43
|
//
|
|
44
44
|
// 3. Heuristik-Warnings: Field-Namen die typischerweise PII enthalten
|
|
45
45
|
// (email, name, phone, body, etc.) ohne Annotation → Boot-Warning.
|
|
46
|
-
// Mit `
|
|
46
|
+
// Mit `{ personal: false, reason: "<reason>" }` unterdrückbar (geht in Audit).
|
|
47
47
|
//
|
|
48
48
|
// 4. Retention-Integrity: retention.reference (wenn gesetzt) muss auf
|
|
49
49
|
// ein bestehendes Field zeigen (oder Framework-Timestamp). retention.
|
|
@@ -57,12 +57,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
57
57
|
const fieldsByName = entity.fields;
|
|
58
58
|
|
|
59
59
|
for (const [fieldName, field] of Object.entries(fieldsByName)) {
|
|
60
|
-
//
|
|
61
|
-
// Defs die nicht via "&
|
|
60
|
+
// ResolvedPiiFlags-Properties sind type-level optional. Auf Field-
|
|
61
|
+
// Defs die nicht via "& ResolvedPiiFlags" erweitert sind (Boolean,
|
|
62
62
|
// Money, Reference, Embedded, Tz, LocatedTimestamp, File*, Image*)
|
|
63
63
|
// liefert property-access undefined zur Runtime. Die TS-Compile-
|
|
64
64
|
// Time-Validation hat dort schon abgelehnt → Cast ist safe.
|
|
65
|
-
const annot = field as
|
|
65
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
66
66
|
|
|
67
67
|
const hasPii = Boolean(annot.pii);
|
|
68
68
|
const hasUserOwned = Boolean(annot.userOwned);
|
|
@@ -71,7 +71,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
71
71
|
|
|
72
72
|
if (annotCount > 1) {
|
|
73
73
|
throw new Error(
|
|
74
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (
|
|
74
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`,
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -81,48 +81,16 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
81
81
|
if (annot.lookupable === true) {
|
|
82
82
|
if (field.type !== "text") {
|
|
83
83
|
throw new Error(
|
|
84
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares
|
|
84
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields.`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
if (annotCount === 0) {
|
|
88
88
|
throw new Error(
|
|
89
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares
|
|
89
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`,
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// piiEncrypted (kumiko-platform#231/#456): a declarative alias over
|
|
95
|
-
// the subject-KMS — text only (storage stays the plaintext column
|
|
96
|
-
// with subject ciphertext, no separate envelope path).
|
|
97
|
-
const piiEncryptedFlag = field as { readonly piiEncrypted?: boolean }; // @cast-boundary schema-walk
|
|
98
|
-
if (piiEncryptedFlag.piiEncrypted === true && field.type !== "text") {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } but has type "${field.type}" — piiEncrypted only applies to text fields.`,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
// piiEncrypted wiring (kumiko-platform#457): resolveSubjectForField/
|
|
104
|
-
// collectPiiSubjectFields only ever look at pii/userOwned/tenantOwned
|
|
105
|
-
// — piiEncrypted alone doesn't pick a subject. Without one of the
|
|
106
|
-
// three, the field would silently stay plaintext (no encrypt-on-
|
|
107
|
-
// write happens). Fail at boot like lookupable does, not at first read.
|
|
108
|
-
if (piiEncryptedFlag.piiEncrypted === true && annotCount === 0) {
|
|
109
|
-
throw new Error(
|
|
110
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without a subject annotation (pii / userOwned / tenantOwned) — piiEncrypted alone doesn't encrypt anything, it only adds the access/masking layer on top of the subject-key encryption.`,
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
// piiEncrypted access model (kumiko-platform#460): field-level
|
|
114
|
-
// access.read already exists + is enforced (filterReadFields), but
|
|
115
|
-
// its default without an access config is "visible to everyone" —
|
|
116
|
-
// the wrong default for a field whose entire point is "only the
|
|
117
|
-
// legitimate owner may see the plaintext". Force an explicit choice
|
|
118
|
-
// instead of silently decrypting for every row-reader.
|
|
119
|
-
const accessFlag = field as { readonly access?: FieldAccess }; // @cast-boundary schema-walk
|
|
120
|
-
if (piiEncryptedFlag.piiEncrypted === true && !accessFlag.access?.read) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without { access: { read: [...] } } — without it the decrypted value is visible to anyone who can read the row. Set the roles allowed to see the plaintext.`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
94
|
// sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
|
|
127
95
|
// Log — ohne ciphertext-at-rest würde der Append Klartext in die
|
|
128
96
|
// immutable History schreiben.
|
|
@@ -136,7 +104,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
136
104
|
sensitiveFlags.encrypted !== true
|
|
137
105
|
) {
|
|
138
106
|
throw new Error(
|
|
139
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (
|
|
107
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { sensitive: true } without ciphertext-at-rest. Since #967 the event log stores sensitive fields as table ciphertext — add a subject annotation (personal: "self" / "tenant" / { of: "<field>" }) or { encrypted: true }.`,
|
|
140
108
|
);
|
|
141
109
|
}
|
|
142
110
|
|
|
@@ -152,9 +120,9 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
152
120
|
readonly sortable?: boolean;
|
|
153
121
|
readonly sensitive?: boolean;
|
|
154
122
|
}; // @cast-boundary schema-walk
|
|
155
|
-
if (
|
|
123
|
+
if (annotCount > 0 && flags.sortable === true) {
|
|
156
124
|
throw new Error(
|
|
157
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation
|
|
125
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use find: "exact"; drop sortable or keep the field plaintext (personal: false).`,
|
|
158
126
|
);
|
|
159
127
|
}
|
|
160
128
|
if (flags.sensitive === true && flags.searchable === true) {
|
|
@@ -168,14 +136,14 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
168
136
|
const ownerName = annot.userOwned.ownerField;
|
|
169
137
|
if (!ownerName || typeof ownerName !== "string") {
|
|
170
138
|
throw new Error(
|
|
171
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has
|
|
139
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has personal: { of: ... } without an owner field name`,
|
|
172
140
|
);
|
|
173
141
|
}
|
|
174
142
|
const ownerField = fieldsByName[ownerName];
|
|
175
143
|
if (!ownerField) {
|
|
176
144
|
const known = Object.keys(fieldsByName).sort().join(", ");
|
|
177
145
|
throw new Error(
|
|
178
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references
|
|
146
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" references personal.of "${ownerName}" but no such field exists. Known fields: ${known}`,
|
|
179
147
|
);
|
|
180
148
|
}
|
|
181
149
|
// Text is accepted alongside reference: the ES-framework carries
|
|
@@ -185,7 +153,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
185
153
|
// ownerField (the field's own value IS the owner id) rides on this.
|
|
186
154
|
if (ownerField.type !== "reference" && ownerField.type !== "text") {
|
|
187
155
|
throw new Error(
|
|
188
|
-
`[Feature ${feature.name}]
|
|
156
|
+
`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" must be a reference or text (userId) field, got type "${ownerField.type}"`,
|
|
189
157
|
);
|
|
190
158
|
}
|
|
191
159
|
// Soft-Warning wenn das reference-target nicht offensichtlich user
|
|
@@ -197,7 +165,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
197
165
|
if (targetEntity !== "user") {
|
|
198
166
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
199
167
|
console.warn(
|
|
200
|
-
`[kumiko:boot] [Feature ${feature.name}]
|
|
168
|
+
`[kumiko:boot] [Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`,
|
|
201
169
|
);
|
|
202
170
|
}
|
|
203
171
|
}
|
|
@@ -206,24 +174,24 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
206
174
|
// PII-Heuristik: nur wenn keine Annotation gesetzt UND kein
|
|
207
175
|
// allowPlaintext-Marker. Ergibt false positives auf Geschäftsdaten
|
|
208
176
|
// mit personenartigem Namen (z.B. company.legalName) — Author
|
|
209
|
-
// unterdrückt mit {
|
|
177
|
+
// unterdrückt mit { personal: false, reason: "is_business_data" }.
|
|
210
178
|
const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
|
|
211
179
|
if (noAnnotation) {
|
|
212
180
|
const lower = fieldName.toLowerCase();
|
|
213
181
|
if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
214
182
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
215
183
|
console.warn(
|
|
216
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no
|
|
184
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`,
|
|
217
185
|
);
|
|
218
186
|
} else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
|
|
219
187
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
220
188
|
console.warn(
|
|
221
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no
|
|
189
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
222
190
|
);
|
|
223
191
|
} else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
|
|
224
192
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
225
193
|
console.warn(
|
|
226
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no
|
|
194
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) — without the hook the V3 boot guard throws. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
227
195
|
);
|
|
228
196
|
}
|
|
229
197
|
}
|
|
@@ -254,10 +222,10 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
254
222
|
// blockDelete on an entity with no subject field is the correct
|
|
255
223
|
// "never auto-delete" choice; User-Forget never reaches those rows (#1622).
|
|
256
224
|
const hasSubjectField = Object.values(fieldsByName).some(
|
|
257
|
-
(f) => hasSubjectAnnotation(f as
|
|
225
|
+
(f) => hasSubjectAnnotation(f as ResolvedPiiFlags), // @cast-boundary schema-walk
|
|
258
226
|
);
|
|
259
227
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
260
|
-
const a = f as
|
|
228
|
+
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
261
229
|
return Boolean(a.anonymize);
|
|
262
230
|
});
|
|
263
231
|
if (hasSubjectField && !hasAnonymize) {
|
|
@@ -139,9 +139,17 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
|
|
|
139
139
|
const shortId = `${feature}-${scope}`;
|
|
140
140
|
|
|
141
141
|
screens.push(buildScreen(shortId, scope, feature, ordered, access, declaredTranslationKeys));
|
|
142
|
+
// A tenant/user-home key with an elevated write role (SystemAdmin on a
|
|
143
|
+
// tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
|
|
144
|
+
// audience navs (cascade-default screen + home screen) — both would
|
|
145
|
+
// otherwise carry the identical `${feature}.settings` label. Opt-in
|
|
146
|
+
// scoped override (`${feature}.settings.${scope}`) disambiguates only
|
|
147
|
+
// where a feature actually declares one; every single-scope feature
|
|
148
|
+
// keeps the plain key unchanged.
|
|
149
|
+
const scopedLabel = `${feature}.settings.${scope}`;
|
|
142
150
|
navs.push({
|
|
143
151
|
id: shortId,
|
|
144
|
-
label: `${feature}.settings`,
|
|
152
|
+
label: declaredTranslationKeys.has(scopedLabel) ? scopedLabel : `${feature}.settings`,
|
|
145
153
|
parent: audienceNavShortId(scope),
|
|
146
154
|
screen: shortId,
|
|
147
155
|
icon: ordered[0]?.def.mask?.icon ?? "settings",
|
package/src/engine/factories.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
FieldsMap,
|
|
11
11
|
FileFieldDef,
|
|
12
12
|
FilesFieldDef,
|
|
13
|
+
Findability,
|
|
13
14
|
ImageFieldDef,
|
|
14
15
|
ImagesFieldDef,
|
|
15
16
|
JsonbFieldDef,
|
|
@@ -18,6 +19,11 @@ import type {
|
|
|
18
19
|
MoneyFieldDef,
|
|
19
20
|
MultiSelectFieldDef,
|
|
20
21
|
NumberFieldDef,
|
|
22
|
+
PersonalAnnotations,
|
|
23
|
+
PersonalAnnotationsLongText,
|
|
24
|
+
PersonalAnnotationsNoFind,
|
|
25
|
+
PersonalSubject,
|
|
26
|
+
ResolvedPiiFlags,
|
|
21
27
|
RetentionDef,
|
|
22
28
|
SelectFieldDef,
|
|
23
29
|
TextFieldDef,
|
|
@@ -25,6 +31,56 @@ import type {
|
|
|
25
31
|
TzFieldDef,
|
|
26
32
|
} from "./types";
|
|
27
33
|
|
|
34
|
+
type PersonalOverridesInput = {
|
|
35
|
+
readonly personal?: PersonalSubject | "ref" | false;
|
|
36
|
+
readonly find?: Findability;
|
|
37
|
+
readonly reason?: string;
|
|
38
|
+
readonly anonymize?: () => unknown | Promise<unknown>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Resolves the author-facing `personal`/`find` annotations (kumiko-framework#2250)
|
|
42
|
+
// into the internal ResolvedPiiFlags every factory below merges into its
|
|
43
|
+
// return value. `personal`, `find`, `reason` never survive into the field def.
|
|
44
|
+
function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
45
|
+
overrides: T | undefined,
|
|
46
|
+
): Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags {
|
|
47
|
+
if (!overrides) {
|
|
48
|
+
return {} as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
|
|
49
|
+
}
|
|
50
|
+
const { personal, find, reason, anonymize, ...rest } = overrides;
|
|
51
|
+
|
|
52
|
+
let personalFlags: ResolvedPiiFlags = {};
|
|
53
|
+
if (personal === "self") {
|
|
54
|
+
personalFlags = { pii: true };
|
|
55
|
+
} else if (personal === "tenant") {
|
|
56
|
+
personalFlags = { tenantOwned: true };
|
|
57
|
+
} else if (personal === "ref") {
|
|
58
|
+
personalFlags = { subjectRef: true };
|
|
59
|
+
} else if (personal === false) {
|
|
60
|
+
personalFlags = { allowPlaintext: reason };
|
|
61
|
+
} else if (personal) {
|
|
62
|
+
personalFlags = { userOwned: { ownerField: personal.of } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `searchable`/`sensitive` live on the individual FieldDef, not on
|
|
66
|
+
// ResolvedPiiFlags — "fuzzy"/"secret" resolve into those too.
|
|
67
|
+
let findFlags: ResolvedPiiFlags & { searchable?: boolean; sensitive?: boolean } = {};
|
|
68
|
+
if (find === "exact") {
|
|
69
|
+
findFlags = { lookupable: true };
|
|
70
|
+
} else if (find === "fuzzy") {
|
|
71
|
+
findFlags = { lookupable: true, searchable: true };
|
|
72
|
+
} else if (find === "secret") {
|
|
73
|
+
findFlags = { sensitive: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...rest,
|
|
78
|
+
...personalFlags,
|
|
79
|
+
...findFlags,
|
|
80
|
+
...(anonymize ? { anonymize } : {}),
|
|
81
|
+
} as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
|
|
82
|
+
}
|
|
83
|
+
|
|
28
84
|
// Generic über `R extends true | false` (statt `boolean`) damit
|
|
29
85
|
// `createTextField({ required: true })` literal `required: true` im
|
|
30
86
|
// Return-Type behält. `boolean` würde widenen — EntityTable<E>'s
|
|
@@ -33,7 +89,8 @@ import type {
|
|
|
33
89
|
// degradieren. Default `R = false` matcht den runtime-default. Pattern
|
|
34
90
|
// in jeder required-bearing factory unten.
|
|
35
91
|
export function createTextField<R extends true | false = false>(
|
|
36
|
-
overrides?: Partial<Omit<TextFieldDef, "type" | "required"
|
|
92
|
+
overrides?: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
93
|
+
PersonalAnnotations & { required?: R },
|
|
37
94
|
): TextFieldDef & { required: R } {
|
|
38
95
|
return {
|
|
39
96
|
type: "text",
|
|
@@ -41,7 +98,7 @@ export function createTextField<R extends true | false = false>(
|
|
|
41
98
|
required: false,
|
|
42
99
|
searchable: false,
|
|
43
100
|
sortable: false,
|
|
44
|
-
...overrides,
|
|
101
|
+
...expandPersonalAnnotations(overrides),
|
|
45
102
|
} as TextFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
46
103
|
}
|
|
47
104
|
|
|
@@ -67,12 +124,13 @@ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
|
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
export function createLongTextField<R extends true | false = false>(
|
|
70
|
-
overrides?: Partial<Omit<LongTextFieldDef, "type" | "required"
|
|
127
|
+
overrides?: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
128
|
+
PersonalAnnotationsLongText & { required?: R },
|
|
71
129
|
): LongTextFieldDef & { required: R } {
|
|
72
130
|
return {
|
|
73
131
|
type: "longText",
|
|
74
132
|
required: false,
|
|
75
|
-
...overrides,
|
|
133
|
+
...expandPersonalAnnotations(overrides),
|
|
76
134
|
} as LongTextFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
77
135
|
}
|
|
78
136
|
|
|
@@ -92,13 +150,14 @@ export function createSelectField<
|
|
|
92
150
|
R extends true | false = false,
|
|
93
151
|
>(
|
|
94
152
|
opts: { options: TOptions } & Partial<
|
|
95
|
-
Omit<SelectFieldDef<TOptions>, "type" | "options" | "required">
|
|
96
|
-
> &
|
|
153
|
+
Omit<SelectFieldDef<TOptions>, "type" | "options" | "required" | keyof ResolvedPiiFlags>
|
|
154
|
+
> &
|
|
155
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
97
156
|
): SelectFieldDef<TOptions> & { required: R } {
|
|
98
157
|
return {
|
|
99
158
|
type: "select",
|
|
100
159
|
required: false,
|
|
101
|
-
...opts,
|
|
160
|
+
...expandPersonalAnnotations(opts),
|
|
102
161
|
} as SelectFieldDef<TOptions> & { required: R }; // @cast-boundary engine-payload
|
|
103
162
|
}
|
|
104
163
|
|
|
@@ -124,12 +183,15 @@ export function createSelectField<
|
|
|
124
183
|
* Wann statt `embedded` mit Booleans: bei mehr als ~5 Optionen.
|
|
125
184
|
*/
|
|
126
185
|
export function createMultiSelectField<const TOptions extends readonly string[]>(
|
|
127
|
-
opts: { options: TOptions } & Partial<
|
|
186
|
+
opts: { options: TOptions } & Partial<
|
|
187
|
+
Omit<MultiSelectFieldDef<TOptions>, "type" | "options" | keyof ResolvedPiiFlags>
|
|
188
|
+
> &
|
|
189
|
+
PersonalAnnotationsNoFind,
|
|
128
190
|
): MultiSelectFieldDef<TOptions> {
|
|
129
191
|
return {
|
|
130
192
|
type: "multiSelect",
|
|
131
193
|
required: false,
|
|
132
|
-
...opts,
|
|
194
|
+
...expandPersonalAnnotations(opts),
|
|
133
195
|
};
|
|
134
196
|
}
|
|
135
197
|
|
|
@@ -140,22 +202,24 @@ export function createMultiSelectField<const TOptions extends readonly string[]>
|
|
|
140
202
|
* instead (money-adjacent math)? Use `createDecimalField` (`numeric`).
|
|
141
203
|
*/
|
|
142
204
|
export function createNumberField<R extends true | false = false>(
|
|
143
|
-
overrides?: Partial<Omit<NumberFieldDef, "type" | "required"
|
|
205
|
+
overrides?: Partial<Omit<NumberFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
206
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
144
207
|
): NumberFieldDef & { required: R } {
|
|
145
208
|
return {
|
|
146
209
|
type: "number",
|
|
147
210
|
required: false,
|
|
148
|
-
...overrides,
|
|
211
|
+
...expandPersonalAnnotations(overrides),
|
|
149
212
|
} as NumberFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
150
213
|
}
|
|
151
214
|
|
|
152
215
|
export function createBigIntField<R extends true | false = false>(
|
|
153
|
-
overrides?: Partial<Omit<BigIntFieldDef, "type" | "required"
|
|
216
|
+
overrides?: Partial<Omit<BigIntFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
217
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
154
218
|
): BigIntFieldDef & { required: R } {
|
|
155
219
|
return {
|
|
156
220
|
type: "bigInt",
|
|
157
221
|
required: false,
|
|
158
|
-
...overrides,
|
|
222
|
+
...expandPersonalAnnotations(overrides),
|
|
159
223
|
} as BigIntFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
160
224
|
}
|
|
161
225
|
|
|
@@ -164,8 +228,9 @@ export function createBigIntField<R extends true | false = false>(
|
|
|
164
228
|
// caveat (surfaced as JS number, safe ≤ 2^53).
|
|
165
229
|
export function createDecimalField<R extends true | false = false>(
|
|
166
230
|
config: { precision: number; scale: number } & Partial<
|
|
167
|
-
Omit<DecimalFieldDef, "type" | "precision" | "scale" | "required">
|
|
168
|
-
> &
|
|
231
|
+
Omit<DecimalFieldDef, "type" | "precision" | "scale" | "required" | keyof ResolvedPiiFlags>
|
|
232
|
+
> &
|
|
233
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
169
234
|
): DecimalFieldDef & { required: R } {
|
|
170
235
|
// Fail at definition time, not at the first migration: numeric(p,s) requires
|
|
171
236
|
// integer p ≥ 1 and 0 ≤ s ≤ p (Postgres rejects e.g. numeric(2,4), and the
|
|
@@ -186,7 +251,7 @@ export function createDecimalField<R extends true | false = false>(
|
|
|
186
251
|
return {
|
|
187
252
|
type: "decimal",
|
|
188
253
|
required: false,
|
|
189
|
-
...config,
|
|
254
|
+
...expandPersonalAnnotations(config),
|
|
190
255
|
} as DecimalFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
191
256
|
}
|
|
192
257
|
|
|
@@ -201,12 +266,13 @@ export function createMoneyField<R extends true | false = false>(
|
|
|
201
266
|
|
|
202
267
|
export function createEmbeddedField(
|
|
203
268
|
schema: EmbeddedFieldDef["schema"],
|
|
204
|
-
overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema"
|
|
269
|
+
overrides?: Partial<Omit<EmbeddedFieldDef, "type" | "schema" | keyof ResolvedPiiFlags>> &
|
|
270
|
+
PersonalAnnotationsNoFind,
|
|
205
271
|
): EmbeddedFieldDef {
|
|
206
272
|
return {
|
|
207
273
|
type: "embedded",
|
|
208
274
|
schema,
|
|
209
|
-
...overrides,
|
|
275
|
+
...expandPersonalAnnotations(overrides),
|
|
210
276
|
};
|
|
211
277
|
}
|
|
212
278
|
|
|
@@ -221,12 +287,15 @@ export function createEmbeddedField(
|
|
|
221
287
|
// silently fall back to the single-object column type.
|
|
222
288
|
export function createEmbeddedListField(
|
|
223
289
|
schema: EmbeddedFieldDef["schema"],
|
|
224
|
-
overrides?: Partial<
|
|
290
|
+
overrides?: Partial<
|
|
291
|
+
Omit<EmbeddedFieldDef, "type" | "schema" | "multiple" | keyof ResolvedPiiFlags>
|
|
292
|
+
> &
|
|
293
|
+
PersonalAnnotationsNoFind,
|
|
225
294
|
): EmbeddedFieldDef & { multiple: true } {
|
|
226
295
|
return {
|
|
227
296
|
type: "embedded",
|
|
228
297
|
schema,
|
|
229
|
-
...overrides,
|
|
298
|
+
...expandPersonalAnnotations(overrides),
|
|
230
299
|
multiple: true,
|
|
231
300
|
};
|
|
232
301
|
}
|
|
@@ -235,20 +304,24 @@ export function createEmbeddedListField(
|
|
|
235
304
|
// `{}`, NOT NULL. Hauptnutzer: custom-fields-Bundle (host-entity's
|
|
236
305
|
// `customFields`-Spalte). Andere valid uses: tenant-config-blobs, AI-
|
|
237
306
|
// inferred-metadata, future tags-arrays.
|
|
238
|
-
export function createJsonbField(
|
|
307
|
+
export function createJsonbField(
|
|
308
|
+
overrides?: Partial<Omit<JsonbFieldDef, "type" | keyof ResolvedPiiFlags>> &
|
|
309
|
+
PersonalAnnotationsNoFind,
|
|
310
|
+
): JsonbFieldDef {
|
|
239
311
|
return {
|
|
240
312
|
type: "jsonb",
|
|
241
|
-
...overrides,
|
|
313
|
+
...expandPersonalAnnotations(overrides),
|
|
242
314
|
};
|
|
243
315
|
}
|
|
244
316
|
|
|
245
317
|
export function createDateField<R extends true | false = false>(
|
|
246
|
-
overrides?: Partial<Omit<DateFieldDef, "type" | "required"
|
|
318
|
+
overrides?: Partial<Omit<DateFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
319
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
247
320
|
): DateFieldDef & { required: R } {
|
|
248
321
|
return {
|
|
249
322
|
type: "date",
|
|
250
323
|
required: false,
|
|
251
|
-
...overrides,
|
|
324
|
+
...expandPersonalAnnotations(overrides),
|
|
252
325
|
} as DateFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
253
326
|
}
|
|
254
327
|
|
|
@@ -261,13 +334,14 @@ export function createDateField<R extends true | false = false>(
|
|
|
261
334
|
* das EIN atomares Feld statt eines lose verdrahteten Pairs erzeugt.
|
|
262
335
|
*/
|
|
263
336
|
export function createTimestampField<R extends true | false = false>(
|
|
264
|
-
overrides?: Partial<Omit<TimestampFieldDef, "type" | "required"
|
|
337
|
+
overrides?: Partial<Omit<TimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
338
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
265
339
|
): TimestampFieldDef & { required: R } {
|
|
266
340
|
// Object-Build vermeidet hartcodiertes `required: false` im literal —
|
|
267
341
|
// das würde TS dazu bringen, R auf `boolean` zu widenen statt das
|
|
268
342
|
// literal `true`/`false` aus dem overrides-Argument zu inferieren.
|
|
269
343
|
return {
|
|
270
|
-
...overrides,
|
|
344
|
+
...expandPersonalAnnotations(overrides),
|
|
271
345
|
type: "timestamp",
|
|
272
346
|
required: (overrides?.required ?? false) as R, // @cast-boundary engine-payload
|
|
273
347
|
};
|
|
@@ -278,12 +352,13 @@ export function createTimestampField<R extends true | false = false>(
|
|
|
278
352
|
* via `Intl.supportedValuesOf("timeZone")` geprüft (kommt im Zod-Schritt).
|
|
279
353
|
*/
|
|
280
354
|
export function createTzField<R extends true | false = false>(
|
|
281
|
-
overrides?: Partial<Omit<TzFieldDef, "type" | "required"
|
|
355
|
+
overrides?: Partial<Omit<TzFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
356
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
282
357
|
): TzFieldDef & { required: R } {
|
|
283
358
|
return {
|
|
284
359
|
type: "tz",
|
|
285
360
|
required: false,
|
|
286
|
-
...overrides,
|
|
361
|
+
...expandPersonalAnnotations(overrides),
|
|
287
362
|
} as TzFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
288
363
|
}
|
|
289
364
|
|
|
@@ -318,12 +393,15 @@ export function createTzField<R extends true | false = false>(
|
|
|
318
393
|
* separater Berechnung.
|
|
319
394
|
*/
|
|
320
395
|
export function createLocatedTimestampField<R extends true | false = false>(
|
|
321
|
-
overrides?: Partial<
|
|
396
|
+
overrides?: Partial<
|
|
397
|
+
Omit<LocatedTimestampFieldDef, "type" | "required" | keyof ResolvedPiiFlags>
|
|
398
|
+
> &
|
|
399
|
+
PersonalAnnotationsNoFind & { required?: R },
|
|
322
400
|
): LocatedTimestampFieldDef & { required: R } {
|
|
323
401
|
return {
|
|
324
402
|
type: "locatedTimestamp",
|
|
325
403
|
required: false,
|
|
326
|
-
...overrides,
|
|
404
|
+
...expandPersonalAnnotations(overrides),
|
|
327
405
|
} as LocatedTimestampFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
328
406
|
}
|
|
329
407
|
|
|
@@ -2,13 +2,6 @@ import type { DbRow } from "../db/connection";
|
|
|
2
2
|
import { normalizeAccessEntry, userCanReadFieldRow, userCanWriteFieldRow } from "./ownership";
|
|
3
3
|
import type { EntityDefinition, SessionUser } from "./types";
|
|
4
4
|
|
|
5
|
-
// piiEncrypted fields deliberately break that silence (kumiko-platform#463):
|
|
6
|
-
// the field's whole point is "a legitimate reader may see the plaintext",
|
|
7
|
-
// so an unauthorized reader seeing the field exist-but-masked is the
|
|
8
|
-
// intended signal, not a leak — unlike a secret whose existence itself
|
|
9
|
-
// should stay hidden.
|
|
10
|
-
export const PII_MASKED_VALUE = "••••••";
|
|
11
|
-
|
|
12
5
|
// Field-level read filtering. Returns a copy of `data` with fields stripped
|
|
13
6
|
// if the user's roles don't grant read access OR the ownership-rule for the
|
|
14
7
|
// matching role doesn't accept this concrete row. Fields without access
|
|
@@ -18,8 +11,7 @@ export const PII_MASKED_VALUE = "••••••";
|
|
|
18
11
|
// one place in the ownership system where silence is the right default:
|
|
19
12
|
// reporting "you tried to read X but can't" leaks the field's existence.
|
|
20
13
|
// Writes do the opposite (loud error) because a silent drop there masks
|
|
21
|
-
// save-bugs.
|
|
22
|
-
// PII_MASKED_VALUE above.
|
|
14
|
+
// save-bugs.
|
|
23
15
|
export function filterReadFields(
|
|
24
16
|
entity: EntityDefinition,
|
|
25
17
|
data: Readonly<Record<string, unknown>>,
|
|
@@ -37,10 +29,7 @@ export function filterReadFields(
|
|
|
37
29
|
|
|
38
30
|
const accessMap = normalizeAccessEntry(field.access?.read);
|
|
39
31
|
if (!userCanReadFieldRow(user, accessMap, data)) {
|
|
40
|
-
|
|
41
|
-
result[key] = PII_MASKED_VALUE;
|
|
42
|
-
}
|
|
43
|
-
continue; // entire field stripped (masked instead, for piiEncrypted)
|
|
32
|
+
continue; // entire field stripped
|
|
44
33
|
}
|
|
45
34
|
|
|
46
35
|
// For embedded fields: filter sub-fields with access restrictions.
|
package/src/engine/index.ts
CHANGED
|
@@ -341,6 +341,7 @@ export type {
|
|
|
341
341
|
FieldRenderer,
|
|
342
342
|
FileFieldDef,
|
|
343
343
|
FilesFieldDef,
|
|
344
|
+
Findability,
|
|
344
345
|
HandlerContext,
|
|
345
346
|
HasManyRelation,
|
|
346
347
|
HookMap,
|
|
@@ -357,6 +358,7 @@ export type {
|
|
|
357
358
|
KumikoHandlerResultMap,
|
|
358
359
|
LifecycleHookType,
|
|
359
360
|
ListColumnSpec,
|
|
361
|
+
LongTextFindability,
|
|
360
362
|
ManyToManyRelation,
|
|
361
363
|
MspErrorMode,
|
|
362
364
|
MspErrorPolicy,
|
|
@@ -376,7 +378,10 @@ export type {
|
|
|
376
378
|
NotifyPriority,
|
|
377
379
|
NumberFieldDef,
|
|
378
380
|
OnDeleteStrategy,
|
|
379
|
-
|
|
381
|
+
PersonalAnnotations,
|
|
382
|
+
PersonalAnnotationsLongText,
|
|
383
|
+
PersonalAnnotationsNoFind,
|
|
384
|
+
PersonalSubject,
|
|
380
385
|
PlatformComponent,
|
|
381
386
|
PostDeleteHookFn,
|
|
382
387
|
PostSaveHookFn,
|
|
@@ -392,6 +397,7 @@ export type {
|
|
|
392
397
|
ReferenceDataDef,
|
|
393
398
|
Registry,
|
|
394
399
|
RelationDefinition,
|
|
400
|
+
ResolvedPiiFlags,
|
|
395
401
|
RetentionDef,
|
|
396
402
|
RowAction,
|
|
397
403
|
SaveContext,
|