Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/27399-sys-file-storage-owned-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-storage': patch
---

fix(service-storage): `sys_file`'s storage-owned columns (`key`, `mime_type`, `size`, `status`, `etag`) can no longer be set or changed by a write that does not come from the storage service

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) A data-door write to five storage-owned sys_file columns is no longer applied: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and no write is refused (an update keeps the stored values, a create starts pending). There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->

A file field refuses a file whose upload never completed by reading `sys_file.status`, and judges its `accept` / `maxSize` by `mime_type` / `size`. Those columns were plain writable fields, so a caller allowed to write `sys_file` rows (an administrator, on a stock boot) could `PATCH /api/v1/data/sys_file/ID` a presigned upload that never received its bytes to `committed`, and a record's file field then claimed it; or rewrite `size` / `mime_type` after commit so the field's constraints judged values that do not describe the stored bytes.

- **Update.** The five columns are now `readonly` on `sys_file`. A non-system update, by id or by predicate, keeps the values the storage service wrote and lands the rest of the payload; the write answers as before and the engine logs the dropped column at `warn`, as it does for every read-only column. A caller that passes `strictReadonlyWrites` is refused with `ERR_READONLY_FIELD_REJECTED`, as for every read-only column.
- **Create.** A non-system create of a `sys_file` row keeps the caller's other columns, drops any value it gives these five, and starts the row `pending`, with one `warn` naming the dropped columns. A file field refuses such a file, and the lifecycle's `pending` retention collects it. The create is not refused.
- **Unchanged.** The storage service's own writes all run under the system context and still move every column: presign, PUT and `/upload/complete`, the chunked upload, a file field's claim, release and copy, and the attachment tombstone. Every other `sys_file` column (`name`, `acl`, `scope`, ownership) is written through the data door as before.

To change what a file is, upload it again through the storage upload doors (`POST /api/v1/storage/upload/presigned`, then `/upload/complete`).
2 changes: 2 additions & 0 deletions content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ One of the largest consumers of the flag;
| 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it | `refuseUnlessHumanCaller` |
| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them | `requireUserContext` |
| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either | `referencingCallerOf` |
| 47g | The `sys_file` storage-owned columns guard returns early on create: a system insert keeps the `key`, `mime_type`, `size`, `status` and `etag` it writes | service-storage | Get: the storage service's own inserts (the upload doors, the field-file copy and backfill) write those five columns. Lose: nothing a user held — a non-system create has them dropped and starts `pending`, and on update the same columns are `readonly`, so the UPDATE `readonly` strip keeps them for every non-system caller | `installSysFileStorageOwnedColumnsGuard` |

### 5. Actions, metadata plane, provenance, the organization wall

Expand Down Expand Up @@ -659,6 +660,7 @@ and CI refuses one that differs from what the tree produces.
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentReadVisibility` | 1 | 44 |
| `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | 1 | 47e |
| `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` | 1 | 47f |
| `packages/services/service-storage/src/sys-file-storage-owned-columns.ts#installSysFileStorageOwnedColumnsGuard` | 1 | 47g |

{/* END GENERATED: system-context-census */}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#11076] REFUSAL PIN — a non-admin cannot write `sys_file.mime_type` / `size`
// through the data API; a platform admin still can.
// through the data API; a platform admin's write reaches the row, and (since
// #27399) the two columns still keep what the storage service wrote.
//
// ── What this file is, and what it deliberately is NOT ──────────────────────
//
Expand All @@ -18,25 +19,24 @@
// explicit-allow platform baseline `member_default` (#5491) NAMES no `sys_file`
// grant, and object access comes from OWDs plus profile / permission-set
// declarations only — so the refusal is produced by the baseline's SILENCE
// about this object. `sys_file` reinforces that from the schema side: it
// declares no ADR-0103 `managedBy` bucket at all (seven sibling platform
// objects in `platform-objects/src/audit` do — `sys_attachment` names
// `platform` outright, the rest name `engine-owned` / `append-only` / `config`),
// and neither `mime_type` nor `size` carries `readonly`, though `id` on the same
// object does. So nothing on the object itself is holding this line.
// about this object. `sys_file` declares no ADR-0103 `managedBy` bucket at all
// (seven sibling platform objects in `platform-objects/src/audit` do —
// `sys_attachment` names `platform` outright, the rest name `engine-owned` /
// `append-only` / `config`). [#27399] Since then `mime_type` and `size` (with
// `key`, `status` and `etag`) are `readonly` storage-owned columns, so a
// non-system UPDATE keeps the stored values whoever sends it; the 403 below is
// still the RBAC answer, which comes before any column is judged.
//
// An absence has no gate. A baseline change that hands members a `sys_file`
// grant — the shape a "while we are here" widening naturally takes — would
// today move a declared security property (the storage service's accept /
// maxSize re-check reads these columns) with every suite still green. This file
// is what reddens instead.
//
// ⛔ It does NOT decide whether `sys_file` SHOULD be engine-owned, or whether
// these columns should become readonly / system-managed. That is an un-ruled
// platform posture call — it removes an affordance from app authors — and this
// pin is written to survive every outcome of it: if the posture tightens the
// refusal stays a refusal, and if it never does, this file is what tells you
// when the absence stops holding.
// ⛔ It does NOT decide whether `sys_file` SHOULD be engine-owned. The columns'
// own posture was decided on #27399 (triage: read-only to the data door), and
// this pin survived it as it was written to: the refusal stays a refusal, and
// only the contrast control's read-back moved.
//
// ── Three assertions, and why each one is load-bearing ──────────────────────
//
Expand All @@ -45,7 +45,9 @@
// server rejects for everyone.
//
// The PLATFORM-ADMIN CONTRAST CONTROL, on the SAME row and the SAME two
// columns, is part of the pin and not optional. Without it the refusal cases
// columns, is part of the pin and not optional: the admin's write answers 200
// and LANDS (a writable column in the same payload changes), so the request
// reaches the row. Without it the refusal cases
// pass just as well when the fixture never reaches the endpoint at all —
// which is how a refusal pin rots into a tautology. This lane has the scar:
// a `get-session` pin that answered `200` for anonymous (#10954).
Expand Down Expand Up @@ -232,16 +234,21 @@ describe('[#11076] `sys_file.mime_type` / `size` are not writable by a non-admin
// row and the SAME two columns they did. Without it, all four cases above pass
// unchanged if the fixture stops reaching the endpoint — a request that never
// arrives is refused by nothing and looks exactly like a gate holding.
it('a platform admin still succeeds on the same row and the same columns', async () => {
it('a platform admin\'s write on the same row and the same columns reaches it, and the storage-owned columns keep their values', async () => {
const ADMIN_WRITE = { mime_type: 'text/x-admin-11076', size: 777 } as const;
expect(ADMIN_WRITE, 'the control must actually move the columns').not.toEqual({ ...SEEDED });
expect(ADMIN_WRITE, 'the control must actually try to move the columns').not.toEqual({ ...SEEDED });
const ADMIN_NAME = 'pin-admin-11076.bin';

const answer = await write(adminTok, { ...ADMIN_WRITE });
const answer = await write(adminTok, { ...ADMIN_WRITE, name: ADMIN_NAME });
expect(answer.status, `admin status — body: ${answer.body}`).toBe(200);

// Persisted, read back the same way the refusals were: the control proves a
// write of these columns REACHES and LANDS, so the 403s above are a verdict
// about the caller and not about the request.
expect(await stored()).toEqual({ ...ADMIN_WRITE });
// Read back the same way the refusals were. The writable column in the
// same payload LANDED, so the request reached the row and the 403s above
// are a verdict about the caller, not about the request. [#27399] The two
// storage-owned columns keep what the storage service wrote: the engine's
// readonly strip takes a non-system caller's value off, an admin's too.
const row = await ql.findOne(OBJECT, { where: { id: ROW_ID }, context: SYS });
expect(row?.name, 'the admin write reached and landed').toBe(ADMIN_NAME);
expect(await stored()).toEqual({ ...SEEDED });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ const UPLOAD_SCOPE_LABELS: Record<UploadScope, string> = {
attachments: 'Attachments',
};

/**
* The `sys_file` columns the storage service owns: where the bytes live
* (`key`), what the backend was told they are (`mime_type`, `size`, `etag`) and
* how far the upload got (`status`). A file field refuses a file whose upload
* never completed by reading `status`, and judges `accept` / `maxSize` by
* reading `mime_type` / `size`, so a caller who could write them could make a
* file with no bytes read as uploaded, or one the field forbids read as
* allowed.
*
* Each is declared `readonly` below, so on an UPDATE the engine's static
* readonly strip takes a non-system caller's value back out (the PATCH answers
* as before and the column keeps what the service wrote). On an INSERT the
* engine leaves a `sys_` object's columns to the object's own guard, and that
* guard reads this list: `installSysFileStorageOwnedColumnsGuard`
* (`sys-file-storage-owned-columns.ts`). Only the service's own writes, all
* under the system context, move these columns: the upload doors through
* `StorageMetadataStore`, the claim, release and copy of a file field, and the
* attachment tombstone.
*/
export const SYS_FILE_STORAGE_OWNED_COLUMNS = ['key', 'mime_type', 'size', 'status', 'etag'] as const;

/**
* System File Object
*
Expand Down Expand Up @@ -57,10 +78,12 @@ export const SystemFile = ObjectSchema.create({
readonly: true,
}),

// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
key: Field.text({
label: 'Storage Key',
required: true,
searchable: true,
readonly: true,
}),

name: Field.text({
Expand All @@ -69,12 +92,16 @@ export const SystemFile = ObjectSchema.create({
searchable: true,
}),

// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
mime_type: Field.text({
label: 'MIME Type',
readonly: true,
}),

// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
size: Field.number({
label: 'Size (bytes)',
readonly: true,
}),

// A logical key prefix and a lifecycle discriminator — never an access
Expand Down Expand Up @@ -104,18 +131,22 @@ export const SystemFile = ObjectSchema.create({
],
}),

// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
status: Field.select({
label: 'Status',
required: true,
readonly: true,
options: [
{ label: 'Pending Upload', value: 'pending' },
{ label: 'Committed', value: 'committed' },
{ label: 'Deleted', value: 'deleted' },
],
}),

// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
etag: Field.text({
label: 'ETag',
readonly: true,
}),

owner_id: Field.text({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -432,16 +432,22 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>

const hookEvents: string[] = [];
const globalHookEvents: string[] = [];
const sysFileHookEvents: string[] = [];
const middlewares: Array<{ object?: string }> = [];
ctx.registerService('objectql', {
registerHook: (event: string, _fn: unknown, opts: any) => {
// File-reference ownership (ADR-0104 D3 wave 2) registers GLOBALLY —
// any object may declare a file-class field. Everything else is
// scoped to sys_attachment.
// any object may declare a file-class field. The create-side guard of
// sys_file's storage-owned columns is scoped to sys_file. Everything
// else is scoped to sys_attachment.
if (opts?.object === undefined) {
globalHookEvents.push(event);
return;
}
if (opts?.object === 'sys_file') {
sysFileHookEvents.push(event);
return;
}
expect(opts?.object).toBe('sys_attachment');
hookEvents.push(event);
},
Expand Down Expand Up @@ -488,6 +494,9 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
'beforeUpdate',
'beforeUpdate',
]);
// The storage-owned columns' create-side guard
// (sys-file-storage-owned-columns.ts): one `beforeInsert`, nothing else.
expect(sysFileHookEvents).toEqual(['beforeInsert']);
// Field-reference ownership: claim/copy on write, release on delete
// (file-reference-lifecycle.ts). Registered without an object filter.
expect(globalHookEvents.sort()).toEqual([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { registerStorageFamily } from './storage-routes.js';
import type { FileReadVerdict, StorageGatedSessionReader, StorageUploadSession } from './storage-routes.js';
import { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard, findFileHolder, findHeldFiles } from './attachment-lifecycle.js';
import { installFileReferenceHooks, type FileReferenceEngine } from './file-reference-lifecycle.js';
import { installSysFileStorageOwnedColumnsGuard } from './sys-file-storage-owned-columns.js';
import type { FileReferenceReadAuthorizer } from './referenceable-file.js';
import {
installAttachmentAccessHooks,
Expand Down Expand Up @@ -488,6 +489,10 @@ export class StorageServicePlugin implements Plugin {
// lifecycle sweep. Both degrade silently on bare kernels.
if (engine && typeof (engine as any).registerHook === 'function') {
installAttachmentLifecycleHooks(engine as any, ctx.logger);
// The create-side guard of sys_file's storage-owned columns: the engine
// strips them off a non-system UPDATE (they are `readonly`) but leaves a
// `sys_` INSERT to the object's own guard.
installSysFileStorageOwnedColumnsGuard(engine as any, ctx.logger);
// [#25692] The download door's verdict on one file for one caller,
// bound once from the live registry and handed to BOTH write doors
// that name a `sys_file` id — a file-class field and a
Expand Down
Loading
Loading