@cosmicdrift/kumiko-framework 0.360.0 → 0.362.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/dist/api/server.js +14 -1
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/access-declarations.js +29 -18
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/build-app-schema.js +1 -0
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
- package/dist/engine/feature-ast/extractors/round3.js +4 -4
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +1 -0
- package/dist/files/bind-pending-file-refs.js +13 -13
- package/dist/files/bind-referenced-files.write.d.ts +16 -0
- package/dist/files/bind-referenced-files.write.js +109 -0
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-entity.js +1 -1
- package/dist/files/file-routes.d.ts +4 -1
- package/dist/files/file-routes.js +37 -6
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-write-check.d.ts +22 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +6 -6
- package/dist/pipeline/write-origin.js +2 -2
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/card-column-roles.d.ts +5 -0
- package/dist/ui-types/card-column-roles.js +22 -4
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/package.json +4 -4
- package/src/changes.json +134 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
|
|
3
|
+
import { isFileField } from "../engine/types/index.js";
|
|
4
|
+
import { ValidationError } from "../errors/index.js";
|
|
5
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
6
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
7
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
8
|
+
import { findImplicitEntityTable } from "./record-write-check.js";
|
|
9
|
+
const MAX_PAGE_SIZE = 500;
|
|
10
|
+
const DEFAULT_PAGE_SIZE = 200;
|
|
11
|
+
const payloadSchema = z.object({
|
|
12
|
+
entityType: z.string().min(1),
|
|
13
|
+
entityIds: z.array(z.uuid()).min(1).max(MAX_PAGE_SIZE).optional(),
|
|
14
|
+
afterId: z.uuid().optional(),
|
|
15
|
+
limit: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
|
|
16
|
+
});
|
|
17
|
+
function invalidEntityType() {
|
|
18
|
+
return new ValidationError({
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
path: "entityType",
|
|
22
|
+
code: "invalid_value",
|
|
23
|
+
i18nKey: "errors.validation.invalid_value",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// The entity must have an implicit table and declared file fields, and the files
|
|
29
|
+
// feature must be mounted; anything else is a caller error, not an empty page.
|
|
30
|
+
function resolveFileBearingEntity(registry, entityType) {
|
|
31
|
+
const entity = registry.getEntity(entityType);
|
|
32
|
+
const table = findImplicitEntityTable(registry, entityType);
|
|
33
|
+
if (entity === undefined || table === undefined)
|
|
34
|
+
throw invalidEntityType();
|
|
35
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
36
|
+
throw invalidEntityType();
|
|
37
|
+
const fileFieldNames = Object.entries(entity.fields)
|
|
38
|
+
.filter(([, fieldDef]) => isFileField(fieldDef))
|
|
39
|
+
.map(([fieldName]) => fieldName);
|
|
40
|
+
if (fileFieldNames.length === 0)
|
|
41
|
+
throw invalidEntityType();
|
|
42
|
+
return { entity, table, fileFieldNames };
|
|
43
|
+
}
|
|
44
|
+
// Either the named records or one id-ordered page after afterId.
|
|
45
|
+
function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
|
|
46
|
+
return db.selectMany(table, {
|
|
47
|
+
...(entity.softDelete === true && { isDeleted: false }),
|
|
48
|
+
...(entityIds !== undefined
|
|
49
|
+
? { id: entityIds }
|
|
50
|
+
: afterId !== undefined && { id: { gt: afterId } }),
|
|
51
|
+
}, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
|
|
52
|
+
}
|
|
53
|
+
// Unbound, same tenant, uploaded by the record's creator (or the system), and either
|
|
54
|
+
// unclaimed or pending for exactly this entity field.
|
|
55
|
+
function isBindableToRecord(fileRef, target) {
|
|
56
|
+
const uploader = fileRef["insertedById"];
|
|
57
|
+
return (typeof uploader === "string" &&
|
|
58
|
+
(uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
|
|
59
|
+
fileRef["tenantId"] === target.tenantId &&
|
|
60
|
+
fileRef["entityId"] === null &&
|
|
61
|
+
(fileRef["entityType"] === null ||
|
|
62
|
+
(fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
|
|
63
|
+
}
|
|
64
|
+
// The binding follows only the record's declared file fields; the caller names
|
|
65
|
+
// no file ids. The record's creator must have uploaded the file themselves (or the
|
|
66
|
+
// system generated it), otherwise a foreign reference would pull someone else's
|
|
67
|
+
// private upload into the referencing user's record and make it readable there.
|
|
68
|
+
export const bindReferencedFilesWrite = defineWriteHandler({
|
|
69
|
+
name: "bindReferencedFiles",
|
|
70
|
+
schema: payloadSchema,
|
|
71
|
+
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
72
|
+
// Maintenance backfill run by ops or system jobs, not a task an assistant performs.
|
|
73
|
+
agent: { expose: false },
|
|
74
|
+
description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
|
|
75
|
+
handler: async (event, ctx) => {
|
|
76
|
+
const { entityType, entityIds, limit } = event.payload;
|
|
77
|
+
const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
|
|
78
|
+
const records = await loadRecordPage(ctx.db, source, event.payload);
|
|
79
|
+
const executor = getFileRefExecutor();
|
|
80
|
+
let bound = 0;
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
for (const record of records) {
|
|
83
|
+
for (const fieldName of fileFieldNames) {
|
|
84
|
+
for (const fileRefId of fileRefIdsOf(record[fieldName])) {
|
|
85
|
+
const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
|
|
86
|
+
if (row === null ||
|
|
87
|
+
!isBindableToRecord(row, {
|
|
88
|
+
record,
|
|
89
|
+
entityType,
|
|
90
|
+
fieldName,
|
|
91
|
+
tenantId: event.user.tenantId,
|
|
92
|
+
})) {
|
|
93
|
+
skipped += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const version = typeof row["version"] === "number" ? row["version"] : undefined;
|
|
97
|
+
const result = await executor.update({ id: fileRefId, version, changes: { entityType, entityId: record.id, fieldName } }, event.user, ctx.db);
|
|
98
|
+
if (!result.isSuccess)
|
|
99
|
+
return result;
|
|
100
|
+
bound += 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const lastRecord = records[records.length - 1];
|
|
105
|
+
const pageIsFull = entityIds === undefined && records.length === limit;
|
|
106
|
+
const nextAfterId = pageIsFull ? (lastRecord?.id ?? null) : null;
|
|
107
|
+
return { isSuccess: true, data: { bound, skipped, nextAfterId } };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export declare function disposeRemovedFileRefs(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
2
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
4
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
5
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
+
function isBoundTo(row, entityName, entityId, fieldName) {
|
|
7
|
+
return (row["entityType"] === entityName &&
|
|
8
|
+
row["entityId"] === entityId &&
|
|
9
|
+
row["fieldName"] === fieldName);
|
|
10
|
+
}
|
|
11
|
+
// An update that drops or replaces a file id leaves the fileRef readable to
|
|
12
|
+
// everyone who may read the record. Deleting it goes through the same executor
|
|
13
|
+
// path as DELETE /files/:id (soft delete, bytes kept until retention), so
|
|
14
|
+
// record readers get 404 afterwards. Only fileRefs bound to exactly this
|
|
15
|
+
// record and field are touched: an id that is pending, shared or bound
|
|
16
|
+
// elsewhere is not this save's to dispose. The authority is the caller's write
|
|
17
|
+
// right on the record (the fileRef entity declares no write access of its own).
|
|
18
|
+
export async function disposeRemovedFileRefs(args) {
|
|
19
|
+
const { registry, result, user, db } = args;
|
|
20
|
+
// skip: a create has no earlier files that could be removed
|
|
21
|
+
if (result.isNew)
|
|
22
|
+
return;
|
|
23
|
+
// skip: saves of unnamed aggregates declare no file fields
|
|
24
|
+
if (result.entityName === undefined)
|
|
25
|
+
return;
|
|
26
|
+
const entityName = result.entityName;
|
|
27
|
+
const entity = registry.getEntity(entityName);
|
|
28
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
29
|
+
if (entity === undefined)
|
|
30
|
+
return;
|
|
31
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
32
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
33
|
+
return;
|
|
34
|
+
const entityId = String(result.id);
|
|
35
|
+
const executor = getFileRefExecutor();
|
|
36
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
37
|
+
if (!isFileField(fieldDef) || !(fieldName in result.changes))
|
|
38
|
+
continue;
|
|
39
|
+
const kept = new Set(fileRefIdsOf(result.data[fieldName]));
|
|
40
|
+
for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
|
|
41
|
+
if (kept.has(fileRefId))
|
|
42
|
+
continue;
|
|
43
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
44
|
+
if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
|
|
45
|
+
continue;
|
|
46
|
+
const removed = await executor.delete({ id: fileRefId }, user, db);
|
|
47
|
+
if (!removed.isSuccess)
|
|
48
|
+
throw reraiseAsKumikoError(removed.error);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { fileRefEntity } from "./file-ref-entity.js";
|
|
|
4
4
|
export type FilesFeatureOptions = {
|
|
5
5
|
readonly accessGuard?: FileAccessGuard;
|
|
6
6
|
readonly privilegedRoles?: readonly string[];
|
|
7
|
+
readonly unclaimedUploadReaders?: readonly string[];
|
|
7
8
|
readonly maxUploadSize?: string;
|
|
8
9
|
};
|
|
9
10
|
export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
|
package/dist/files/feature.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineFeature } from "../engine/index.js";
|
|
2
|
+
import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
|
|
2
3
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
3
4
|
export { fileRefEntity } from "./file-ref-entity.js";
|
|
4
5
|
// files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
|
|
@@ -21,6 +22,7 @@ export function createFilesFeature(options = {}) {
|
|
|
21
22
|
recommended: false,
|
|
22
23
|
});
|
|
23
24
|
r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
|
|
25
|
+
r.writeHandler(bindReferencedFilesWrite);
|
|
24
26
|
return { routeOptions: options };
|
|
25
27
|
});
|
|
26
28
|
}
|
|
@@ -17,7 +17,7 @@ import { createBigIntField, createEntity, createTextField } from "../engine/inde
|
|
|
17
17
|
// reference: "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). The other
|
|
18
18
|
// fields (storageKey, mimeType, entityType, entityId, fieldName) are
|
|
19
19
|
// technical references or system metadata, not subject content.
|
|
20
|
-
export
|
|
20
|
+
export { FILE_REF_ENTITY_NAME } from "./types.js";
|
|
21
21
|
export const fileRefEntity = createEntity({
|
|
22
22
|
table: "file_refs",
|
|
23
23
|
softDelete: true,
|
|
@@ -3,6 +3,7 @@ import type { DbConnection } from "../db/connection.js";
|
|
|
3
3
|
import { type Registry, type SessionUser, type TenantId } from "../engine/types/index.js";
|
|
4
4
|
import type { FileProviderResolver } from "./provider-resolver.js";
|
|
5
5
|
import type { RecordReadCheck } from "./record-read-check.js";
|
|
6
|
+
import { type RecordWriteCheck } from "./record-write-check.js";
|
|
6
7
|
export type FileAccessDecision = "allow" | "deny";
|
|
7
8
|
export type FileRef = {
|
|
8
9
|
id: string;
|
|
@@ -27,9 +28,11 @@ export type FileRoutesOptions = {
|
|
|
27
28
|
readonly registry?: Registry;
|
|
28
29
|
readonly maxUploadSize?: string;
|
|
29
30
|
readonly privilegedRoles?: readonly string[];
|
|
31
|
+
readonly unclaimedUploadReaders?: readonly string[];
|
|
30
32
|
readonly accessGuard?: FileAccessGuard;
|
|
31
33
|
readonly canReadRecord?: RecordReadCheck;
|
|
34
|
+
readonly canWriteRecord?: RecordWriteCheck;
|
|
32
35
|
};
|
|
33
36
|
export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
|
|
34
|
-
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
37
|
+
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
|
|
35
38
|
export declare function createFileRoutes(options: FileRoutesOptions): Hono;
|
|
@@ -12,6 +12,7 @@ import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from
|
|
|
12
12
|
import { createFileContext } from "./file-handle.js";
|
|
13
13
|
import { fileRefEntity } from "./file-ref-entity.js";
|
|
14
14
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
15
|
+
import { RecordWriteDecisions } from "./record-write-check.js";
|
|
15
16
|
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
16
17
|
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
17
18
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
@@ -22,16 +23,23 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
|
|
|
22
23
|
// Default guard: on attached files, allow the uploader or a privileged role,
|
|
23
24
|
// and for reads also everyone who may read the record. Delete stays with the
|
|
24
25
|
// uploader and privileged roles: reading a record is no right to remove its
|
|
25
|
-
// files.
|
|
26
|
-
//
|
|
27
|
-
|
|
26
|
+
// files. Pending uploads (entityType set, record not yet created) are the
|
|
27
|
+
// uploader's own until bound; so are uploads without an entityType, which no
|
|
28
|
+
// record can ever claim.
|
|
29
|
+
function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
|
|
28
30
|
return async ({ fileRef, user, operation }) => {
|
|
29
|
-
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
30
|
-
return "allow";
|
|
31
31
|
if (fileRef.insertedById === user.id)
|
|
32
32
|
return "allow";
|
|
33
33
|
if (privilegedRoles.some((role) => user.roles.includes(role)))
|
|
34
34
|
return "allow";
|
|
35
|
+
const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
|
|
36
|
+
if (isUnclaimed &&
|
|
37
|
+
operation === "read" &&
|
|
38
|
+
unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
|
|
39
|
+
return "allow";
|
|
40
|
+
}
|
|
41
|
+
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
42
|
+
return "deny";
|
|
35
43
|
if (operation !== "read" || canReadRecord === undefined)
|
|
36
44
|
return "deny";
|
|
37
45
|
const recordReadable = await canReadRecord({
|
|
@@ -75,7 +83,8 @@ export function readFilesRouteOptions(registry) {
|
|
|
75
83
|
export function createFileRoutes(options) {
|
|
76
84
|
const { db } = options;
|
|
77
85
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
78
|
-
const guard = options.accessGuard ??
|
|
86
|
+
const guard = options.accessGuard ??
|
|
87
|
+
createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
|
|
79
88
|
const executor = getFileRefExecutor();
|
|
80
89
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
81
90
|
const api = new Hono();
|
|
@@ -135,6 +144,28 @@ export function createFileRoutes(options) {
|
|
|
135
144
|
// store it in the text entityId column.
|
|
136
145
|
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
137
146
|
const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
|
|
147
|
+
if (entityId !== undefined) {
|
|
148
|
+
if (entityType === undefined) {
|
|
149
|
+
return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
|
|
150
|
+
}
|
|
151
|
+
// Before any bytes are written: a rejected attach must leave no file behind. An
|
|
152
|
+
// unregistered entityType attaches to nothing; storeFileAsFileRef rejects it with 400.
|
|
153
|
+
const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
|
|
154
|
+
? RecordWriteDecisions.writable
|
|
155
|
+
: options.canWriteRecord
|
|
156
|
+
? await options.canWriteRecord({ entityType, entityId, fieldName, user })
|
|
157
|
+
: RecordWriteDecisions.forbidden;
|
|
158
|
+
switch (decision) {
|
|
159
|
+
case RecordWriteDecisions.writable:
|
|
160
|
+
break;
|
|
161
|
+
case RecordWriteDecisions.notFound:
|
|
162
|
+
return c.json({ error: "not_found" }, 404);
|
|
163
|
+
case RecordWriteDecisions.forbidden:
|
|
164
|
+
return c.json({ error: "forbidden: no write access to this record" }, 403);
|
|
165
|
+
case RecordWriteDecisions.locked:
|
|
166
|
+
return c.json({ error: "record_locked: the record is locked" }, 409);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
138
169
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
139
170
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
140
171
|
const outcome = await storeFileAsFileRef({
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
2
|
+
import type { Logger } from "@cosmicdrift/kumiko-types/logger";
|
|
3
|
+
import { deleteMany, type WhereObject } from "../db/query.js";
|
|
4
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
5
|
+
export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
6
|
+
type PurgeLog = Pick<Logger, "warn"> | undefined;
|
|
7
|
+
export declare function purgeExpiredFileRefsWithBytes(args: {
|
|
8
|
+
readonly db: TenantDb;
|
|
9
|
+
readonly table: Parameters<typeof deleteMany>[1];
|
|
10
|
+
readonly where: WhereObject;
|
|
11
|
+
readonly files: FileContext | undefined;
|
|
12
|
+
readonly log: PurgeLog;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { deleteMany, selectMany } from "../db/query.js";
|
|
2
|
+
import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
|
|
3
|
+
export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
4
|
+
// Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
|
|
5
|
+
// rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
|
|
6
|
+
// row whose bytes could not be removed stays and the next run retries it.
|
|
7
|
+
export async function purgeExpiredFileRefsWithBytes(args) {
|
|
8
|
+
const { db, table, where, files, log } = args;
|
|
9
|
+
if (!files) {
|
|
10
|
+
log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const rows = await selectMany(db, table, where, {
|
|
14
|
+
orderBy: [{ col: "deletedAt", direction: "asc" }],
|
|
15
|
+
limit: EXPIRED_FILE_REF_PURGE_BATCH,
|
|
16
|
+
});
|
|
17
|
+
const purgedIds = [];
|
|
18
|
+
const store = { list: files.list, delete: (key) => files.ref(key).delete() };
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
try {
|
|
21
|
+
const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
|
|
22
|
+
if (failedKeys.length === 0) {
|
|
23
|
+
purgedIds.push(row.id);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (purgedIds.length > 0)
|
|
33
|
+
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import type { Registry, SessionUser, TenantId } from "../engine/types/index.js";
|
|
3
|
+
import type { RecordReadCheck } from "./record-read-check.js";
|
|
4
|
+
export declare const RecordWriteDecisions: {
|
|
5
|
+
readonly writable: "writable";
|
|
6
|
+
readonly notFound: "not_found";
|
|
7
|
+
readonly forbidden: "forbidden";
|
|
8
|
+
readonly locked: "locked";
|
|
9
|
+
};
|
|
10
|
+
export type RecordWriteDecision = (typeof RecordWriteDecisions)[keyof typeof RecordWriteDecisions];
|
|
11
|
+
export type RecordWriteCheck = (args: {
|
|
12
|
+
readonly entityType: string;
|
|
13
|
+
readonly entityId: string;
|
|
14
|
+
readonly fieldName: string | undefined;
|
|
15
|
+
readonly user: SessionUser;
|
|
16
|
+
}) => Promise<RecordWriteDecision>;
|
|
17
|
+
export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
|
|
18
|
+
export declare function createRecordWriteCheck(args: {
|
|
19
|
+
readonly registry: Registry;
|
|
20
|
+
readonly canReadRecord: RecordReadCheck;
|
|
21
|
+
readonly tenantDbFor: (tenantId: TenantId) => TenantDb;
|
|
22
|
+
}): RecordWriteCheck;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
3
|
+
import { toKebab } from "../engine/qualified-name.js";
|
|
4
|
+
import { isRecordLocked } from "../engine/screen-helpers.js";
|
|
5
|
+
export const RecordWriteDecisions = {
|
|
6
|
+
writable: "writable",
|
|
7
|
+
notFound: "not_found",
|
|
8
|
+
forbidden: "forbidden",
|
|
9
|
+
locked: "locked",
|
|
10
|
+
};
|
|
11
|
+
export function findImplicitEntityTable(registry, entityType) {
|
|
12
|
+
for (const projection of registry.getAllProjections().values()) {
|
|
13
|
+
if (projection.isImplicit === true && projection.source === entityType) {
|
|
14
|
+
return projection.table;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
function isEntityUpdateWrite(qn, entityName) {
|
|
20
|
+
return qn.endsWith(`:write:${toKebab(entityName)}:update`);
|
|
21
|
+
}
|
|
22
|
+
// "May this user attach a file to the record?" asks what saving the record would ask: the
|
|
23
|
+
// entity's update handler, the row-level and field-level write rules against the stored row,
|
|
24
|
+
// and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
|
|
25
|
+
// the upload never confirms what exists.
|
|
26
|
+
export function createRecordWriteCheck(args) {
|
|
27
|
+
const { registry, canReadRecord, tenantDbFor } = args;
|
|
28
|
+
function findUpdateHandlerAccess(entityType) {
|
|
29
|
+
for (const [qn, handler] of registry.getAllWriteHandlers()) {
|
|
30
|
+
if (registry.getHandlerEntity(qn) === entityType && isEntityUpdateWrite(qn, entityType)) {
|
|
31
|
+
return handler.access;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return async ({ entityType, entityId, fieldName, user }) => {
|
|
37
|
+
if (!(await canReadRecord({ entityType, entityId, user }))) {
|
|
38
|
+
return RecordWriteDecisions.notFound;
|
|
39
|
+
}
|
|
40
|
+
const entity = registry.getEntity(entityType);
|
|
41
|
+
const table = findImplicitEntityTable(registry, entityType);
|
|
42
|
+
if (!entity || !table)
|
|
43
|
+
return RecordWriteDecisions.notFound;
|
|
44
|
+
// The stored row, not the detail output: the detail query can hide ownership fields.
|
|
45
|
+
const row = await tenantDbFor(user.tenantId).fetchOne(table, {
|
|
46
|
+
id: entityId,
|
|
47
|
+
...(entity.softDelete === true && { isDeleted: false }),
|
|
48
|
+
});
|
|
49
|
+
if (!row)
|
|
50
|
+
return RecordWriteDecisions.notFound;
|
|
51
|
+
if (!hasAccess(user, findUpdateHandlerAccess(entityType)))
|
|
52
|
+
return RecordWriteDecisions.forbidden;
|
|
53
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
|
|
54
|
+
return RecordWriteDecisions.forbidden;
|
|
55
|
+
}
|
|
56
|
+
const fieldWriteRules = fieldName === undefined ? undefined : entity.fields[fieldName]?.access?.write;
|
|
57
|
+
if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
|
|
58
|
+
return RecordWriteDecisions.forbidden;
|
|
59
|
+
}
|
|
60
|
+
if (isRecordLocked(entity.lockedWhen, row))
|
|
61
|
+
return RecordWriteDecisions.locked;
|
|
62
|
+
return RecordWriteDecisions.writable;
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/files/types.d.ts
CHANGED
package/dist/files/types.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
|
|
2
|
+
// a module that calls createEntity at load time.
|
|
3
|
+
export const FILE_REF_ENTITY_NAME = "fileRef";
|
|
1
4
|
export function parseMaxSize(maxSize) {
|
|
2
5
|
const match = maxSize.match(/^(\d+)(kb|mb|gb)$/i);
|
|
3
6
|
if (!match)
|
|
@@ -162,6 +162,12 @@ function pushRelatedListSectionKeys(out, section) {
|
|
|
162
162
|
for (const label of Object.values(section.groupBy?.labels ?? {}))
|
|
163
163
|
pushKey(out, label);
|
|
164
164
|
}
|
|
165
|
+
function pushListEmptyStateKeys(out, emptyState) {
|
|
166
|
+
pushKey(out, emptyState?.title);
|
|
167
|
+
pushKey(out, emptyState?.description);
|
|
168
|
+
if (emptyState?.action !== undefined)
|
|
169
|
+
pushRowActionKeys(out, emptyState.action);
|
|
170
|
+
}
|
|
165
171
|
function pushExpandableRowKeys(out, section) {
|
|
166
172
|
pushRelatedListSectionKeys(out, section);
|
|
167
173
|
for (const action of section.actions ?? [])
|
|
@@ -192,6 +198,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
192
198
|
}
|
|
193
199
|
pushKey(out, list.createLabel);
|
|
194
200
|
pushKey(out, list.searchPlaceholder);
|
|
201
|
+
pushKey(out, list.description);
|
|
202
|
+
pushListEmptyStateKeys(out, list.emptyState);
|
|
195
203
|
for (const action of list.rowActions ?? [])
|
|
196
204
|
pushRowActionKeys(out, action);
|
|
197
205
|
for (const action of list.toolbarActions ?? [])
|
|
@@ -210,6 +218,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
|
|
|
210
218
|
}
|
|
211
219
|
pushKey(out, list.createLabel);
|
|
212
220
|
pushKey(out, list.searchPlaceholder);
|
|
221
|
+
pushKey(out, list.description);
|
|
222
|
+
pushListEmptyStateKeys(out, list.emptyState);
|
|
213
223
|
for (const action of list.rowActions ?? [])
|
|
214
224
|
pushRowActionKeys(out, action);
|
|
215
225
|
for (const action of list.toolbarActions ?? [])
|
|
@@ -33,6 +33,7 @@ export type PiiEventBackfillResult = {
|
|
|
33
33
|
readonly updatedEvents: number;
|
|
34
34
|
readonly encryptedFields: number;
|
|
35
35
|
readonly erasedFields: number;
|
|
36
|
+
readonly rekeyedFields: number;
|
|
36
37
|
readonly failures: readonly PiiBackfillFailure[];
|
|
37
38
|
readonly queuedTables: readonly string[];
|
|
38
39
|
readonly rebuild: PiiRebuildRun | null;
|
|
@@ -68,8 +68,14 @@ export function piiAnnotationEntries(registry) {
|
|
|
68
68
|
}
|
|
69
69
|
return entries.sort();
|
|
70
70
|
}
|
|
71
|
+
// Bump when the backfill gains a transform stage: completed states keyed by the
|
|
72
|
+
// old fingerprint must run one more full pass. Kept out of piiAnnotationEntries,
|
|
73
|
+
// whose emptiness gates no_pii.
|
|
74
|
+
const PII_BACKFILL_TRANSFORM_VERSION = "owner-rekey-v1";
|
|
71
75
|
export function piiAnnotationFingerprint(registry) {
|
|
72
|
-
return createHash("sha256")
|
|
76
|
+
return createHash("sha256")
|
|
77
|
+
.update([...piiAnnotationEntries(registry), PII_BACKFILL_TRANSFORM_VERSION].join("\n"))
|
|
78
|
+
.digest("hex");
|
|
73
79
|
}
|
|
74
80
|
async function queueRebuildTables(db, tableNames, migrationId) {
|
|
75
81
|
for (const tableName of tableNames) {
|
|
@@ -125,6 +131,7 @@ async function runOneBatch(ctx, progress, retryEventIds) {
|
|
|
125
131
|
...(retryEventIds && { eventIds: retryEventIds }),
|
|
126
132
|
batchSize: ctx.batchSize,
|
|
127
133
|
resolveOwnerFromProjection: true,
|
|
134
|
+
rekeyTenantKeyedOwnerFields: true,
|
|
128
135
|
scanCache: progress.scanCache,
|
|
129
136
|
});
|
|
130
137
|
const batchTouchedEventTypes = new Set(batch.touchedEventTypes);
|
|
@@ -151,6 +158,7 @@ function recordBatch(progress, step) {
|
|
|
151
158
|
progress.totals.updatedEvents += batch.updatedEvents;
|
|
152
159
|
progress.totals.encryptedFields += batch.encryptedFields;
|
|
153
160
|
progress.totals.erasedFields += batch.erasedFields;
|
|
161
|
+
progress.totals.rekeyedFields += batch.rekeyedFields;
|
|
154
162
|
for (const failure of batch.failures) {
|
|
155
163
|
// The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
|
|
156
164
|
if (progress.failedIdsSeen.has(failure.eventId))
|
|
@@ -218,7 +226,13 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
|
|
|
218
226
|
const progress = {
|
|
219
227
|
scanCursor,
|
|
220
228
|
scanCache: {},
|
|
221
|
-
totals: {
|
|
229
|
+
totals: {
|
|
230
|
+
scannedEvents: 0,
|
|
231
|
+
updatedEvents: 0,
|
|
232
|
+
encryptedFields: 0,
|
|
233
|
+
erasedFields: 0,
|
|
234
|
+
rekeyedFields: 0,
|
|
235
|
+
},
|
|
222
236
|
failures: [],
|
|
223
237
|
queuedTables: new Set(),
|
|
224
238
|
failedIdsSeen: new Set(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { deleteMany, fetchOne, updateMany } from "../db/query.js";
|
|
1
|
+
import { deleteMany, fetchOne, selectMany, updateMany } from "../db/query.js";
|
|
2
2
|
import { OnDeleteStrategies, SystemHookNames, SystemHookPriorities } from "../engine/constants.js";
|
|
3
|
-
import {
|
|
3
|
+
import { isRecordLocked } from "../engine/screen-helpers.js";
|
|
4
|
+
import { ConflictError, FrameworkReasons, RecordLockedError } from "../errors/index.js";
|
|
4
5
|
export function createCascadeDeleteHook(registry, tables) {
|
|
5
6
|
return {
|
|
6
7
|
name: SystemHookNames.cascadeDelete,
|
|
@@ -47,6 +48,20 @@ export function createCascadeDeleteHook(registry, tables) {
|
|
|
47
48
|
});
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
// The raw delete/update below bypasses the executor, so a locked child must
|
|
52
|
+
// block the parent delete here instead of changing with it. The error names
|
|
53
|
+
// the parent only: the caller may delete the parent without reading the child.
|
|
54
|
+
if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
|
|
55
|
+
const childLock = registry.getEntity(relation.target)?.lockedWhen;
|
|
56
|
+
if (childLock !== undefined) {
|
|
57
|
+
const children = await selectMany(db, targetTable, {
|
|
58
|
+
[relation.foreignKey]: payload.id,
|
|
59
|
+
});
|
|
60
|
+
if (children.some((child) => isRecordLocked(childLock, child))) {
|
|
61
|
+
throw new RecordLockedError({ entityName, entityId: payload.id });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
50
65
|
if (strategy === OnDeleteStrategies.cascade) {
|
|
51
66
|
await deleteMany(db, targetTable, { [relation.foreignKey]: payload.id });
|
|
52
67
|
}
|
|
@@ -8,7 +8,9 @@ import { checkWriteFieldOwnership, maskWriteOnlyFields } from "../engine/field-a
|
|
|
8
8
|
import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
9
9
|
import { HookPhases } from "../engine/types/index.js";
|
|
10
10
|
import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, writeFailure, } from "../errors/index.js";
|
|
11
|
+
import { assertFileRefsBelongToTenant } from "../files/assert-file-refs-belong-to-tenant.js";
|
|
11
12
|
import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
|
|
13
|
+
import { disposeRemovedFileRefs } from "../files/dispose-removed-file-refs.js";
|
|
12
14
|
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
13
15
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
14
16
|
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
@@ -112,12 +114,10 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
112
114
|
// keeps projection state consistent with what the hooks observe.
|
|
113
115
|
await runProjections(result, handlerContext.registry, runner);
|
|
114
116
|
if (result.kind === "save") {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
db: handlerContext.db,
|
|
120
|
-
});
|
|
117
|
+
const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
|
|
118
|
+
await assertFileRefsBelongToTenant(fileRefArgs);
|
|
119
|
+
await bindPendingFileRefs(fileRefArgs);
|
|
120
|
+
await disposeRemovedFileRefs(fileRefArgs);
|
|
121
121
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
122
122
|
afterCommitHooks.push(async () => {
|
|
123
123
|
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
|
|
@@ -39,11 +39,11 @@ function declaresPublicIntake(access) {
|
|
|
39
39
|
// Only the public dispatcher entry points compute a root; every nested call inherits
|
|
40
40
|
// it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
|
|
41
41
|
export function rootWriteOrigin(registry, type, user) {
|
|
42
|
-
const
|
|
42
|
+
const rootHandler = registry.getWriteHandler(type) ?? registry.getQueryHandler(type);
|
|
43
43
|
return {
|
|
44
44
|
rootHandler: type,
|
|
45
45
|
anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
|
|
46
|
-
publicIntake:
|
|
46
|
+
publicIntake: rootHandler !== undefined && declaresPublicIntake(rootHandler.access),
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
// Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
|