@cosmicdrift/kumiko-framework 0.251.0 → 0.252.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/crypto/__tests__/subject-key-record.test.ts +99 -0
- package/src/crypto/pii-field-encryption.ts +3 -0
- package/src/crypto/subject-resolver.ts +84 -37
- package/src/db/blind-index-cleanup.ts +38 -0
- package/src/db/event-store-executor-context.ts +1 -0
- package/src/db/index.ts +5 -1
- package/src/db/queries/backfill-pii.ts +4 -2
- package/src/engine/__tests__/build-app-schema.test.ts +1 -0
- package/src/engine/boot-validator/pii-retention.ts +7 -2
- package/src/engine/factories.ts +6 -0
- package/src/search/purge-subject.ts +101 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.252.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.252.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.252.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
4
|
+
import {
|
|
5
|
+
type KmsContext,
|
|
6
|
+
subjectIdFromKey,
|
|
7
|
+
subjectIdToKey,
|
|
8
|
+
subjectKeyForRecord,
|
|
9
|
+
} from "../kms-adapter";
|
|
10
|
+
import {
|
|
11
|
+
decryptPiiFieldValues,
|
|
12
|
+
encryptPiiFieldValues,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
PII_CIPHERTEXT_PREFIX,
|
|
15
|
+
} from "../pii-field-encryption";
|
|
16
|
+
import {
|
|
17
|
+
collectPiiSubjectFields,
|
|
18
|
+
resolveSubjectForField,
|
|
19
|
+
SubjectResolutionError,
|
|
20
|
+
} from "../subject-resolver";
|
|
21
|
+
|
|
22
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
23
|
+
const KMS_CTX: KmsContext = { requestId: "test" };
|
|
24
|
+
|
|
25
|
+
const recordEntity = createEntity({
|
|
26
|
+
fields: {
|
|
27
|
+
body: createTextField({ required: true, personal: { of: "id" }, find: "none" }),
|
|
28
|
+
},
|
|
29
|
+
table: "subject_key_record_probe",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("record subject key round-trip", () => {
|
|
33
|
+
test("subjectIdToKey serializes record:<entity>:<id>", () => {
|
|
34
|
+
expect(subjectIdToKey({ kind: "record", entity: "notesHistory", id: UUID_A })).toBe(
|
|
35
|
+
`record:notesHistory:${UUID_A}`,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("subjectIdFromKey round-trips all three kinds", () => {
|
|
40
|
+
const cases = [
|
|
41
|
+
{ kind: "user" as const, userId: UUID_A },
|
|
42
|
+
{ kind: "tenant" as const, tenantId: UUID_A },
|
|
43
|
+
{ kind: "record" as const, entity: "notesHistory", id: UUID_A },
|
|
44
|
+
];
|
|
45
|
+
for (const subject of cases) {
|
|
46
|
+
expect(subjectIdFromKey(subjectIdToKey(subject))).toEqual(subject);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a record key never collides with a user key and vice versa", () => {
|
|
51
|
+
expect(subjectIdFromKey("record:a:b")).toEqual({ kind: "record", entity: "a", id: "b" });
|
|
52
|
+
expect(subjectIdFromKey("user:record:a:b")).toEqual({ kind: "user", userId: "record:a:b" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('subjectKeyForRecord rejects an entity name containing ":"', () => {
|
|
56
|
+
expect(() => subjectKeyForRecord("bad:name", UUID_A)).toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("ciphertext round-trip for a record-owned field", async () => {
|
|
60
|
+
const kms = new InMemoryKmsAdapter();
|
|
61
|
+
const fields = collectPiiSubjectFields(recordEntity);
|
|
62
|
+
const row = { id: UUID_A, body: "free-text support note" };
|
|
63
|
+
|
|
64
|
+
const stored = await encryptPiiFieldValues(row, recordEntity, fields, kms, KMS_CTX, {
|
|
65
|
+
entityName: "recordProbe",
|
|
66
|
+
});
|
|
67
|
+
expect(isPiiCiphertext(stored["body"])).toBe(true);
|
|
68
|
+
expect(String(stored["body"])).toStartWith(
|
|
69
|
+
`${PII_CIPHERTEXT_PREFIX}record:recordProbe:${UUID_A}:`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
|
|
73
|
+
expect(read["body"]).toBe("free-text support note");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("resolveSubjectForField: recordOwned fail-closed", () => {
|
|
78
|
+
test("recordOwned field without opts.entityName throws", () => {
|
|
79
|
+
expect(() => resolveSubjectForField(recordEntity, "body", { id: UUID_A })).toThrow(
|
|
80
|
+
SubjectResolutionError,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("recordOwned field with row missing id throws", () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
resolveSubjectForField(recordEntity, "body", {}, { entityName: "recordProbe" }),
|
|
87
|
+
).toThrow(SubjectResolutionError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("recordOwned field resolves to the record subject", () => {
|
|
91
|
+
const subject = resolveSubjectForField(
|
|
92
|
+
recordEntity,
|
|
93
|
+
"body",
|
|
94
|
+
{ id: UUID_A },
|
|
95
|
+
{ entityName: "recordProbe" },
|
|
96
|
+
);
|
|
97
|
+
expect(subject).toEqual({ kind: "record", entity: "recordProbe", id: UUID_A });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -159,6 +159,8 @@ export interface EncryptPiiOptions {
|
|
|
159
159
|
// Row to resolve subjects from when `row` is a partial (update changes may
|
|
160
160
|
// carry a pii field without its ownerField — the merged row still has it).
|
|
161
161
|
readonly subjectSource?: Record<string, unknown>;
|
|
162
|
+
// Canonical registry entity name — required to resolve recordOwned fields.
|
|
163
|
+
readonly entityName?: string;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
export async function encryptPiiFieldValues(
|
|
@@ -186,6 +188,7 @@ export async function encryptPiiFieldValues(
|
|
|
186
188
|
}
|
|
187
189
|
const subject = resolveSubjectForField(entity, name, subjectSource, {
|
|
188
190
|
...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
|
|
191
|
+
...(opts.entityName !== undefined && { entityName: opts.entityName }),
|
|
189
192
|
});
|
|
190
193
|
// skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
|
|
191
194
|
if (subject === null) continue;
|
|
@@ -19,18 +19,83 @@ export interface ResolveSubjectOptions {
|
|
|
19
19
|
// Write-time tenant scope — consulted for tenantOwned fields when the row
|
|
20
20
|
// itself carries no tenantId column.
|
|
21
21
|
readonly tenantId?: TenantId;
|
|
22
|
+
// Canonical source is the registry entity name (executor `entityName` /
|
|
23
|
+
// event `aggregate_type`) — required to resolve recordOwned fields.
|
|
24
|
+
readonly entityName?: string;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
function nonEmptyString(value: unknown): string | null {
|
|
25
28
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
function rowIdAsString(row: Record<string, unknown>): string | null {
|
|
32
|
+
const id = row["id"];
|
|
33
|
+
return nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveRecordSubject(
|
|
37
|
+
fieldName: string,
|
|
38
|
+
row: Record<string, unknown>,
|
|
39
|
+
opts: ResolveSubjectOptions,
|
|
40
|
+
): SubjectId {
|
|
41
|
+
const entityName = nonEmptyString(opts.entityName);
|
|
42
|
+
if (entityName === null) {
|
|
43
|
+
throw new SubjectResolutionError(
|
|
44
|
+
fieldName,
|
|
45
|
+
"record subject needs the entity name; caller did not supply one",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const recordId = rowIdAsString(row);
|
|
49
|
+
if (recordId === null) {
|
|
50
|
+
throw new SubjectResolutionError(fieldName, "row has no id to use as the record subject");
|
|
51
|
+
}
|
|
52
|
+
return { kind: "record", entity: entityName, id: recordId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveUserOwnedSubject(
|
|
56
|
+
ownerField: string,
|
|
57
|
+
fieldName: string,
|
|
58
|
+
row: Record<string, unknown>,
|
|
59
|
+
): SubjectId {
|
|
60
|
+
const userId = nonEmptyString(row[ownerField]);
|
|
61
|
+
if (userId === null) {
|
|
62
|
+
throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row`);
|
|
63
|
+
}
|
|
64
|
+
return { kind: "user", userId };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveTenantSubject(
|
|
68
|
+
fieldName: string,
|
|
69
|
+
row: Record<string, unknown>,
|
|
70
|
+
opts: ResolveSubjectOptions,
|
|
71
|
+
): SubjectId {
|
|
72
|
+
const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
|
|
73
|
+
if (tenantId === undefined) {
|
|
74
|
+
throw new SubjectResolutionError(
|
|
75
|
+
fieldName,
|
|
76
|
+
"row has no tenantId column and no write-time tenantId was provided",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return { kind: "tenant", tenantId };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveSelfPiiSubject(fieldName: string, row: Record<string, unknown>): SubjectId {
|
|
83
|
+
// pii: true = the entity itself is the subject (user.email belongs to
|
|
84
|
+
// that user row). Serial ids are stringified — subject keys are text.
|
|
85
|
+
const userId = rowIdAsString(row);
|
|
86
|
+
if (userId === null) {
|
|
87
|
+
throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
|
|
88
|
+
}
|
|
89
|
+
return { kind: "user", userId };
|
|
90
|
+
}
|
|
91
|
+
|
|
28
92
|
/**
|
|
29
93
|
* Maps a pii-annotated field to the subject whose key encrypts it.
|
|
30
94
|
* Returns null for fields without any PII annotation (stored plaintext).
|
|
31
95
|
*
|
|
32
96
|
* Precedence for multi-annotated fields mirrors the erase triggers:
|
|
33
|
-
*
|
|
97
|
+
* recordOwned (record-forget) > userOwned (user-forget) > tenantOwned
|
|
98
|
+
* (tenant-destroy) > pii (self).
|
|
34
99
|
*/
|
|
35
100
|
export function resolveSubjectForField(
|
|
36
101
|
entity: EntityDefinition,
|
|
@@ -41,53 +106,39 @@ export function resolveSubjectForField(
|
|
|
41
106
|
const field = entity.fields[fieldName];
|
|
42
107
|
if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
|
|
43
108
|
|
|
109
|
+
if ("recordOwned" in field && field.recordOwned === true) {
|
|
110
|
+
return resolveRecordSubject(fieldName, row, opts);
|
|
111
|
+
}
|
|
112
|
+
|
|
44
113
|
if ("userOwned" in field && field.userOwned !== undefined) {
|
|
45
|
-
|
|
46
|
-
const userId = nonEmptyString(row[ownerField]);
|
|
47
|
-
if (userId === null) {
|
|
48
|
-
throw new SubjectResolutionError(
|
|
49
|
-
fieldName,
|
|
50
|
-
`owner field "${ownerField}" is empty on the row`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return { kind: "user", userId };
|
|
114
|
+
return resolveUserOwnedSubject(field.userOwned.ownerField, fieldName, row);
|
|
54
115
|
}
|
|
55
116
|
|
|
56
117
|
if ("tenantOwned" in field && field.tenantOwned === true) {
|
|
57
|
-
|
|
58
|
-
if (tenantId === undefined) {
|
|
59
|
-
throw new SubjectResolutionError(
|
|
60
|
-
fieldName,
|
|
61
|
-
"row has no tenantId column and no write-time tenantId was provided",
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
return { kind: "tenant", tenantId };
|
|
118
|
+
return resolveTenantSubject(fieldName, row, opts);
|
|
65
119
|
}
|
|
66
120
|
|
|
67
121
|
if (isSelfPiiField(field)) {
|
|
68
|
-
|
|
69
|
-
// that user row). Serial ids are stringified — subject keys are text.
|
|
70
|
-
const id = row["id"];
|
|
71
|
-
const userId = nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
|
|
72
|
-
if (userId === null) {
|
|
73
|
-
throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
|
|
74
|
-
}
|
|
75
|
-
return { kind: "user", userId };
|
|
122
|
+
return resolveSelfPiiSubject(fieldName, row);
|
|
76
123
|
}
|
|
77
124
|
|
|
78
125
|
return null;
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
|
|
129
|
+
return (
|
|
130
|
+
("userOwned" in field && field.userOwned !== undefined) ||
|
|
131
|
+
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
132
|
+
("recordOwned" in field && field.recordOwned === true) ||
|
|
133
|
+
isSelfPiiField(field)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
81
137
|
// The field names an encrypt engine must process for an entity — precomputed
|
|
82
138
|
// once at executor build time, like the sensitiveFields set.
|
|
83
139
|
export function collectPiiSubjectFields(entity: EntityDefinition): readonly string[] {
|
|
84
140
|
return Object.entries(entity.fields)
|
|
85
|
-
.filter(
|
|
86
|
-
([, field]) =>
|
|
87
|
-
("userOwned" in field && field.userOwned !== undefined) ||
|
|
88
|
-
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
89
|
-
isSelfPiiField(field),
|
|
90
|
-
)
|
|
141
|
+
.filter(([, field]) => hasSubjectAnnotation(field))
|
|
91
142
|
.map(([name]) => name);
|
|
92
143
|
}
|
|
93
144
|
|
|
@@ -95,11 +146,7 @@ export function collectPiiSubjectFields(entity: EntityDefinition): readonly stri
|
|
|
95
146
|
export function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[] {
|
|
96
147
|
return Object.entries(entity.fields)
|
|
97
148
|
.filter(([, field]) => {
|
|
98
|
-
|
|
99
|
-
("userOwned" in field && field.userOwned !== undefined) ||
|
|
100
|
-
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
101
|
-
isSelfPiiField(field);
|
|
102
|
-
if (!subject) return false;
|
|
149
|
+
if (!hasSubjectAnnotation(field)) return false;
|
|
103
150
|
if ("sensitive" in field && field.sensitive === true) return false;
|
|
104
151
|
return "searchable" in field && field.searchable === true;
|
|
105
152
|
})
|
|
@@ -15,6 +15,7 @@ import { collectLookupableFields } from "../crypto/blind-index";
|
|
|
15
15
|
import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
|
|
16
16
|
import { isSelfPiiField } from "../crypto/is-self-pii-field";
|
|
17
17
|
import type { FeatureDefinition } from "../engine/types";
|
|
18
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
18
19
|
import { toSnakeCase } from "../utils/case";
|
|
19
20
|
import type { DbRunner } from "./connection";
|
|
20
21
|
import { resolveTableName } from "./entity-table-meta";
|
|
@@ -105,3 +106,40 @@ export async function subjectRowExistsInTenant(
|
|
|
105
106
|
}
|
|
106
107
|
return false;
|
|
107
108
|
}
|
|
109
|
+
|
|
110
|
+
function findEntityByExactName(
|
|
111
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
112
|
+
entityName: string,
|
|
113
|
+
): EntityDefinition | undefined {
|
|
114
|
+
for (const feature of features.values()) {
|
|
115
|
+
const entity = feature.entities?.[entityName];
|
|
116
|
+
if (entity) return entity;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Tighter than subjectRowExistsInTenant: the record subject names its own
|
|
122
|
+
// entity, so this is a single-table lookup instead of a self-PII scan
|
|
123
|
+
// across the whole registry.
|
|
124
|
+
//
|
|
125
|
+
// Invariant: sound only because `id` is a single-column PK per entity
|
|
126
|
+
// table (globally unique, unspoofable via a client-supplied explicitId
|
|
127
|
+
// at create) — a composite PK or shared table would break this (#2348).
|
|
128
|
+
export async function recordRowExistsInTenant(
|
|
129
|
+
db: DbRunner,
|
|
130
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
131
|
+
entityName: string,
|
|
132
|
+
recordId: string,
|
|
133
|
+
tenantId: string,
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
const entity = findEntityByExactName(features, entityName);
|
|
136
|
+
if (!entity) return false;
|
|
137
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
138
|
+
if (!(await tableExists(db, tableName))) return false;
|
|
139
|
+
const rows = await executeRawQueryRead(
|
|
140
|
+
db,
|
|
141
|
+
`SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
|
|
142
|
+
[recordId, tenantId],
|
|
143
|
+
);
|
|
144
|
+
return rows.length > 0;
|
|
145
|
+
}
|
|
@@ -265,6 +265,7 @@ export function buildExecutorContext(
|
|
|
265
265
|
if (hasPiiFields && kms) {
|
|
266
266
|
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
267
267
|
tenantId: user.tenantId,
|
|
268
|
+
entityName,
|
|
268
269
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
269
270
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
270
271
|
});
|
package/src/db/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
nullBlindIndexesForSubject,
|
|
4
|
+
recordRowExistsInTenant,
|
|
5
|
+
subjectRowExistsInTenant,
|
|
6
|
+
} from "./blind-index-cleanup";
|
|
3
7
|
export { collectTableMetas } from "./collect-table-metas";
|
|
4
8
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
5
9
|
export { seedConfigValues } from "./config-seed";
|
|
@@ -329,6 +329,7 @@ export async function backfillEventPiiEncryption(
|
|
|
329
329
|
projectionOwnerRow,
|
|
330
330
|
// @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
331
331
|
row.tenant_id as TenantId,
|
|
332
|
+
row.aggregate_type,
|
|
332
333
|
);
|
|
333
334
|
if (resolution.kind === "unannotated") continue;
|
|
334
335
|
if (resolution.kind === "unresolved") {
|
|
@@ -408,9 +409,10 @@ function resolveOwnerSubject(
|
|
|
408
409
|
subjectSource: Record<string, unknown>,
|
|
409
410
|
projectionOwnerRow: Record<string, unknown> | undefined,
|
|
410
411
|
tenantId: TenantId,
|
|
412
|
+
entityName: string,
|
|
411
413
|
): OwnerSubjectResolution {
|
|
412
414
|
try {
|
|
413
|
-
const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId });
|
|
415
|
+
const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId, entityName });
|
|
414
416
|
return subject === null
|
|
415
417
|
? { kind: "unannotated" }
|
|
416
418
|
: { kind: "resolved", subject, viaProjection: false };
|
|
@@ -419,7 +421,7 @@ function resolveOwnerSubject(
|
|
|
419
421
|
if (projectionOwnerRow) {
|
|
420
422
|
try {
|
|
421
423
|
const augmented = { ...subjectSource, ...projectionOwnerRow };
|
|
422
|
-
const subject = resolveSubjectForField(entity, field, augmented, { tenantId });
|
|
424
|
+
const subject = resolveSubjectForField(entity, field, augmented, { tenantId, entityName });
|
|
423
425
|
if (subject !== null) return { kind: "resolved", subject, viaProjection: true };
|
|
424
426
|
} catch (e2) {
|
|
425
427
|
if (!(e2 instanceof SubjectResolutionError)) throw e2;
|
|
@@ -428,6 +428,7 @@ describe("buildAppSchema", () => {
|
|
|
428
428
|
"pii", // PII classification, drives crypto/storage — not client-relevant
|
|
429
429
|
"userOwned", // same: subject-key annotation, server/crypto-only
|
|
430
430
|
"tenantOwned", // same: subject-key annotation, server/crypto-only
|
|
431
|
+
"recordOwned", // same: subject-key annotation, server/crypto-only
|
|
431
432
|
"anonymize", // retention-cleanup callback, never serializable to JSON
|
|
432
433
|
"allowPlaintext", // PII-audit reason string, not a renderer concern
|
|
433
434
|
"lookupable", // blind-index equality lookup, server-side query concern
|
|
@@ -24,7 +24,7 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
|
24
24
|
// Excludes subjectRef: its `personal: "ref"` union member structurally
|
|
25
25
|
// forbids `anonymize` (packages/types/src/fields.ts) — #2336.
|
|
26
26
|
function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
|
|
27
|
-
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned);
|
|
27
|
+
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
@@ -66,7 +66,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
66
66
|
const hasPii = Boolean(annot.pii);
|
|
67
67
|
const hasUserOwned = Boolean(annot.userOwned);
|
|
68
68
|
const hasTenantOwned = Boolean(annot.tenantOwned);
|
|
69
|
-
const
|
|
69
|
+
const hasRecordOwned = Boolean(annot.recordOwned);
|
|
70
|
+
const annotCount =
|
|
71
|
+
(hasPii ? 1 : 0) +
|
|
72
|
+
(hasUserOwned ? 1 : 0) +
|
|
73
|
+
(hasTenantOwned ? 1 : 0) +
|
|
74
|
+
(hasRecordOwned ? 1 : 0);
|
|
70
75
|
|
|
71
76
|
if (annotCount > 1) {
|
|
72
77
|
throw new Error(
|
package/src/engine/factories.ts
CHANGED
|
@@ -38,6 +38,10 @@ type PersonalOverridesInput = {
|
|
|
38
38
|
readonly anonymize?: () => unknown | Promise<unknown>;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
// `personal: { of: "id" }` means the row itself is the subject, not a
|
|
42
|
+
// referenced user — resolves to `recordOwned` instead of `userOwned`.
|
|
43
|
+
const RECORD_OWNER_FIELD = "id";
|
|
44
|
+
|
|
41
45
|
// Resolves the author-facing `personal`/`find` annotations (kumiko-framework#2250)
|
|
42
46
|
// into the internal ResolvedPiiFlags every factory below merges into its
|
|
43
47
|
// return value. `personal`, `find`, `reason` never survive into the field def.
|
|
@@ -58,6 +62,8 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
|
58
62
|
personalFlags = { subjectRef: true };
|
|
59
63
|
} else if (personal === false) {
|
|
60
64
|
personalFlags = { allowPlaintext: reason };
|
|
65
|
+
} else if (personal && personal.of === RECORD_OWNER_FIELD) {
|
|
66
|
+
personalFlags = { recordOwned: true };
|
|
61
67
|
} else if (personal) {
|
|
62
68
|
personalFlags = { userOwned: { ownerField: personal.of } };
|
|
63
69
|
}
|
|
@@ -22,45 +22,121 @@ import type { EntityId, TenantId } from "../engine/types/identifiers";
|
|
|
22
22
|
import { toSnakeCase } from "../utils/case";
|
|
23
23
|
import type { SearchAdapter } from "./types";
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
function finalizePredicateParts(
|
|
26
|
+
parts: readonly string[],
|
|
27
|
+
params: readonly unknown[],
|
|
28
|
+
): { sql: string; params: unknown[] } | null {
|
|
29
|
+
if (parts.length === 0) return null;
|
|
30
|
+
return { sql: parts.join(" OR "), params: [...params] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function userOwnershipPredicates(
|
|
27
34
|
entity: EntityDefinition,
|
|
28
35
|
searchableFields: readonly string[],
|
|
29
|
-
|
|
36
|
+
userId: string,
|
|
30
37
|
nextParam: () => number,
|
|
31
38
|
): { sql: string; params: unknown[] } | null {
|
|
32
39
|
const parts: string[] = [];
|
|
33
40
|
const params: unknown[] = [];
|
|
34
41
|
let selfIdN: number | undefined;
|
|
35
|
-
let tenantIdN: number | undefined;
|
|
36
42
|
const ownerFieldN = new Map<string, number>();
|
|
37
43
|
|
|
38
44
|
for (const fieldName of searchableFields) {
|
|
39
45
|
const field = entity.fields[fieldName];
|
|
40
46
|
if (!field) continue;
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
parts.push(`${quoteIdent(col)} = $${n}`);
|
|
50
|
-
}
|
|
51
|
-
} else if (isSelfPiiField(field) && selfIdN === undefined) {
|
|
52
|
-
selfIdN = nextParam();
|
|
53
|
-
params.push(subject.userId);
|
|
54
|
-
parts.push(`${quoteIdent("id")} = $${selfIdN}`);
|
|
47
|
+
if ("userOwned" in field && field.userOwned !== undefined) {
|
|
48
|
+
const col = toSnakeCase(field.userOwned.ownerField);
|
|
49
|
+
let n = ownerFieldN.get(col);
|
|
50
|
+
if (n === undefined) {
|
|
51
|
+
n = nextParam();
|
|
52
|
+
ownerFieldN.set(col, n);
|
|
53
|
+
params.push(userId);
|
|
54
|
+
parts.push(`${quoteIdent(col)} = $${n}`);
|
|
55
55
|
}
|
|
56
|
-
} else if (
|
|
56
|
+
} else if (isSelfPiiField(field) && selfIdN === undefined) {
|
|
57
|
+
selfIdN = nextParam();
|
|
58
|
+
params.push(userId);
|
|
59
|
+
parts.push(`${quoteIdent("id")} = $${selfIdN}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return finalizePredicateParts(parts, params);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tenantOwnershipPredicates(
|
|
66
|
+
entity: EntityDefinition,
|
|
67
|
+
searchableFields: readonly string[],
|
|
68
|
+
tenantId: string,
|
|
69
|
+
nextParam: () => number,
|
|
70
|
+
): { sql: string; params: unknown[] } | null {
|
|
71
|
+
const parts: string[] = [];
|
|
72
|
+
const params: unknown[] = [];
|
|
73
|
+
let tenantIdN: number | undefined;
|
|
74
|
+
|
|
75
|
+
for (const fieldName of searchableFields) {
|
|
76
|
+
const field = entity.fields[fieldName];
|
|
77
|
+
if (!field) continue;
|
|
78
|
+
if ("tenantOwned" in field && field.tenantOwned === true && tenantIdN === undefined) {
|
|
57
79
|
tenantIdN = nextParam();
|
|
58
|
-
params.push(
|
|
80
|
+
params.push(tenantId);
|
|
59
81
|
parts.push(`${quoteIdent("tenant_id")} = $${tenantIdN}`);
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
return finalizePredicateParts(parts, params);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordOwnershipPredicates(
|
|
88
|
+
entity: EntityDefinition,
|
|
89
|
+
searchableFields: readonly string[],
|
|
90
|
+
entityName: string,
|
|
91
|
+
subjectEntity: string,
|
|
92
|
+
subjectId: string,
|
|
93
|
+
nextParam: () => number,
|
|
94
|
+
): { sql: string; params: unknown[] } | null {
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
const params: unknown[] = [];
|
|
97
|
+
let recordIdN: number | undefined;
|
|
98
|
+
|
|
99
|
+
for (const fieldName of searchableFields) {
|
|
100
|
+
const field = entity.fields[fieldName];
|
|
101
|
+
if (!field) continue;
|
|
102
|
+
const isMatchingRecordField =
|
|
103
|
+
entityName === subjectEntity && "recordOwned" in field && field.recordOwned === true;
|
|
104
|
+
if (isMatchingRecordField && recordIdN === undefined) {
|
|
105
|
+
recordIdN = nextParam();
|
|
106
|
+
params.push(subjectId);
|
|
107
|
+
parts.push(`${quoteIdent("id")} = $${recordIdN}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return finalizePredicateParts(parts, params);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build OR predicates for rows owned by `subject` (id / ownerField / tenant_id). */
|
|
114
|
+
function ownershipPredicates(
|
|
115
|
+
entity: EntityDefinition,
|
|
116
|
+
searchableFields: readonly string[],
|
|
117
|
+
entityName: string,
|
|
118
|
+
subject: SubjectId,
|
|
119
|
+
nextParam: () => number,
|
|
120
|
+
): { sql: string; params: unknown[] } | null {
|
|
121
|
+
switch (subject.kind) {
|
|
122
|
+
case "user":
|
|
123
|
+
return userOwnershipPredicates(entity, searchableFields, subject.userId, nextParam);
|
|
124
|
+
case "tenant":
|
|
125
|
+
return tenantOwnershipPredicates(entity, searchableFields, subject.tenantId, nextParam);
|
|
126
|
+
case "record":
|
|
127
|
+
return recordOwnershipPredicates(
|
|
128
|
+
entity,
|
|
129
|
+
searchableFields,
|
|
130
|
+
entityName,
|
|
131
|
+
subject.entity,
|
|
132
|
+
subject.id,
|
|
133
|
+
nextParam,
|
|
134
|
+
);
|
|
135
|
+
default: {
|
|
136
|
+
const exhaustiveCheck: never = subject;
|
|
137
|
+
throw new Error(`Unhandled subject kind: ${JSON.stringify(exhaustiveCheck)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
64
140
|
}
|
|
65
141
|
|
|
66
142
|
type MatchedRow = { id: string; tenant_id: string };
|
|
@@ -99,6 +175,7 @@ function buildSubjectPredicate(
|
|
|
99
175
|
entity: EntityDefinition,
|
|
100
176
|
fields: readonly string[],
|
|
101
177
|
likePattern: string,
|
|
178
|
+
entityName: string,
|
|
102
179
|
subject: SubjectId | undefined,
|
|
103
180
|
): { sql: string; params: unknown[] } {
|
|
104
181
|
let paramIdx = 0;
|
|
@@ -113,7 +190,7 @@ function buildSubjectPredicate(
|
|
|
113
190
|
);
|
|
114
191
|
|
|
115
192
|
if (subject) {
|
|
116
|
-
const owned = ownershipPredicates(entity, fields, subject, nextParam);
|
|
193
|
+
const owned = ownershipPredicates(entity, fields, entityName, subject, nextParam);
|
|
117
194
|
if (owned) {
|
|
118
195
|
params.push(...owned.params);
|
|
119
196
|
orParts.push(`(${owned.sql})`);
|
|
@@ -144,7 +221,7 @@ export async function purgeSearchDocumentsForSubject(
|
|
|
144
221
|
// a mounted feature without its migration must not abort the purge
|
|
145
222
|
// (and the audit event after it).
|
|
146
223
|
if (!(await tableExists(db, tableName))) continue;
|
|
147
|
-
const predicate = buildSubjectPredicate(entity, fields, likePattern, subject);
|
|
224
|
+
const predicate = buildSubjectPredicate(entity, fields, likePattern, entityName, subject);
|
|
148
225
|
const rows = await collectMatchingRowsForEntity(
|
|
149
226
|
db,
|
|
150
227
|
tableName,
|