|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#26672] A file field's read verdict does not depend on whether the caller's |
| 5 | + * projection lists `id`. |
| 6 | + * |
| 7 | + * ## The defect |
| 8 | + * |
| 9 | + * A reader refused `sys_file` read (every member whose application grants no |
| 10 | + * `sys_file` read) is served a file field by the parent-derived verdict of |
| 11 | + * #22637: a file whose `ref_object` / `ref_id` name the record being read |
| 12 | + * hydrates when the download door's field-owned verdict allows this reader. |
| 13 | + * That verdict is judged per RECORD, by the record's `id`. A projection that |
| 14 | + * left `id` out (`GET /data/:object?select=attachment`) handed the hydration |
| 15 | + * rows with no `id`, so no file could be matched to its owner and every one |
| 16 | + * answered `{ id, metadataRefused: true }` — while `select=id,attachment` |
| 17 | + * answered the full `{ id, name, size, mimeType, url }` to the same reader. |
| 18 | + * |
| 19 | + * ## What is pinned, on the composed REST harness |
| 20 | + * |
| 21 | + * `RestServer` → `ObjectStackProtocolImplementation` → `ObjectQL` → a real |
| 22 | + * `SqlDriver` on better-sqlite3, through the three read doors that reach the |
| 23 | + * hydration: `GET /data/:object` (`select`), `POST /data/:object/query` |
| 24 | + * (`fields`) and `GET /data/:object/:id` (`select`, the `findOne` door). |
| 25 | + * |
| 26 | + * - The permitted reader gets the full value with or without `id`, and no |
| 27 | + * `id` key the caller did not name. |
| 28 | + * - CONTROL the same projection with `id` answers as it did. |
| 29 | + * - CONTROL a reader the field-owned verdict refuses is still refused, with |
| 30 | + * or without `id`: nothing an unauthorised caller sees is widened. |
| 31 | + * |
| 32 | + * ## The stand-ins, and only these |
| 33 | + * |
| 34 | + * - The security layer's `sys_file` refusal: an engine middleware that |
| 35 | + * throws `PERMISSION_DENIED` for every non-system `sys_file` read, the |
| 36 | + * shape plugin-security's `file-field-hydration-refused.test.ts` measures |
| 37 | + * on the real `SecurityPlugin`. |
| 38 | + * - The field-owned verdict the storage plugin hands the engine: one that |
| 39 | + * allows the reader and nobody else. The real verdict's agreement with the |
| 40 | + * download door is pinned in service-storage's |
| 41 | + * `field-owned-file-hydration-door-agreement.test.ts`. |
| 42 | + * |
| 43 | + * `@objectstack/objectql` resolves through its `exports` to `dist/` here, so |
| 44 | + * an ablation of the engine is observed only after a rebuild of that package. |
| 45 | + */ |
| 46 | + |
| 47 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 48 | +import { ObjectQL } from '@objectstack/objectql'; |
| 49 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 50 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 51 | +import { RestServer } from './rest-server'; |
| 52 | +import { installDefaultOrganization } from './default-organization.testkit.js'; |
| 53 | + |
| 54 | +const OBJECT = 'rq_filed_doc'; |
| 55 | +const SYS = { isSystem: true }; |
| 56 | +const READER = 'u_reader'; |
| 57 | +const STRANGER = 'u_stranger'; |
| 58 | +const FILE_ID = 'f_26672_owned'; |
| 59 | + |
| 60 | +const hydrated = { |
| 61 | + id: FILE_ID, name: 'contract.pdf', size: 2048, mimeType: 'application/pdf', url: `/api/v1/storage/files/${FILE_ID}`, |
| 62 | +}; |
| 63 | +const refused = { id: FILE_ID, metadataRefused: true }; |
| 64 | + |
| 65 | +function createMockServer() { |
| 66 | + const noop = () => {}; |
| 67 | + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; |
| 68 | +} |
| 69 | + |
| 70 | +function makeRes() { |
| 71 | + const res: any = { |
| 72 | + write: () => true, end: () => {}, |
| 73 | + header: () => res, |
| 74 | + status: (code: number) => { res._status = code; return res; }, |
| 75 | + json: (body: any) => { res._json = body; return res; }, |
| 76 | + }; |
| 77 | + return res; |
| 78 | +} |
| 79 | + |
| 80 | +const refusal = () => |
| 81 | + Object.assign(new Error('You do not have permission to perform this action.'), { |
| 82 | + name: 'PermissionDeniedError', code: 'PERMISSION_DENIED', status: 403, statusCode: 403, |
| 83 | + }); |
| 84 | + |
| 85 | +const rowsOf = (res: any): Array<Record<string, unknown>> => res._json?.records ?? res._json?.data ?? res._json; |
| 86 | +const recordOf = (res: any): Record<string, unknown> => res._json?.record ?? res._json; |
| 87 | + |
| 88 | +describe('[#26672] a file field reads the same with or without `id` in the projection', () => { |
| 89 | + let engine: ObjectQL; |
| 90 | + let caller = READER; |
| 91 | + let list: (query: Record<string, unknown>) => Promise<any>; |
| 92 | + let query: (body: Record<string, unknown>) => Promise<any>; |
| 93 | + let getById: (id: string, query: Record<string, unknown>) => Promise<any>; |
| 94 | + |
| 95 | + beforeAll(async () => { |
| 96 | + engine = new ObjectQL(); |
| 97 | + engine.registerDriver( |
| 98 | + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, |
| 99 | + true, |
| 100 | + ); |
| 101 | + await engine.init(); |
| 102 | + engine.registry.registerObject({ |
| 103 | + name: OBJECT, |
| 104 | + label: 'Filed doc', |
| 105 | + fields: { |
| 106 | + title: { name: 'title', type: 'text' }, |
| 107 | + attachment: { name: 'attachment', type: 'file' }, |
| 108 | + }, |
| 109 | + } as any); |
| 110 | + // The storage service's `sys_file`, as far as the hydration reads it. |
| 111 | + engine.registry.registerObject({ |
| 112 | + name: 'sys_file', |
| 113 | + label: 'System File', |
| 114 | + fields: { |
| 115 | + key: { name: 'key', type: 'text' }, |
| 116 | + name: { name: 'name', type: 'text' }, |
| 117 | + mime_type: { name: 'mime_type', type: 'text' }, |
| 118 | + size: { name: 'size', type: 'number' }, |
| 119 | + status: { name: 'status', type: 'text' }, |
| 120 | + ref_object: { name: 'ref_object', type: 'text' }, |
| 121 | + ref_id: { name: 'ref_id', type: 'text' }, |
| 122 | + ref_field: { name: 'ref_field', type: 'text' }, |
| 123 | + }, |
| 124 | + } as any); |
| 125 | + await engine.syncSchemas(); |
| 126 | + await installDefaultOrganization(engine); |
| 127 | + |
| 128 | + await engine.insert('sys_file', { |
| 129 | + id: FILE_ID, key: `files/${FILE_ID}.pdf`, name: 'contract.pdf', mime_type: 'application/pdf', size: 2048, |
| 130 | + status: 'committed', ref_object: OBJECT, ref_id: 'd1', ref_field: 'attachment', |
| 131 | + } as any, { context: SYS } as any); |
| 132 | + await engine.insert(OBJECT, { id: 'd1', title: 'Signed', attachment: FILE_ID } as any, { context: SYS } as any); |
| 133 | + |
| 134 | + engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => { |
| 135 | + if (!opCtx.context?.isSystem) throw refusal(); |
| 136 | + return next(); |
| 137 | + }, { object: 'sys_file' }); |
| 138 | + engine.registerFieldOwnedFileReadAuthorizer(async (_object, ownerIds, context) => |
| 139 | + new Set(context?.userId === READER ? ownerIds : [])); |
| 140 | + |
| 141 | + const protocol = new ObjectStackProtocolImplementation(engine as any); |
| 142 | + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); |
| 143 | + (rest as any).resolveExecCtx = async () => ({ userId: caller }); |
| 144 | + rest.registerRoutes(); |
| 145 | + const routes = rest.getRoutes(); |
| 146 | + const route = (method: string, path: string) => { |
| 147 | + const found = routes.find((r: any) => r.method === method && r.path === path); |
| 148 | + expect(found).toBeDefined(); |
| 149 | + return found!; |
| 150 | + }; |
| 151 | + const listRoute = route('GET', '/api/v1/data/:object'); |
| 152 | + const queryRoute = route('POST', '/api/v1/data/:object/query'); |
| 153 | + const getRoute = route('GET', '/api/v1/data/:object/:id'); |
| 154 | + list = async (q) => { |
| 155 | + const res = makeRes(); |
| 156 | + await listRoute.handler({ params: { object: OBJECT }, query: q } as any, res); |
| 157 | + return res; |
| 158 | + }; |
| 159 | + query = async (body) => { |
| 160 | + const res = makeRes(); |
| 161 | + await queryRoute.handler({ params: { object: OBJECT }, body } as any, res); |
| 162 | + return res; |
| 163 | + }; |
| 164 | + getById = async (id, q) => { |
| 165 | + const res = makeRes(); |
| 166 | + await getRoute.handler({ params: { object: OBJECT, id }, query: q } as any, res); |
| 167 | + return res; |
| 168 | + }; |
| 169 | + }); |
| 170 | + |
| 171 | + afterAll(async () => { |
| 172 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 173 | + }); |
| 174 | + |
| 175 | + const as = (user: string) => { caller = user; }; |
| 176 | + |
| 177 | + it('the refusal it starts from: the reader may not read sys_file directly', async () => { |
| 178 | + const direct = await engine.find('sys_file', { context: { userId: READER } }).then(() => null, (e: any) => e); |
| 179 | + expect({ code: direct?.code, status: direct?.statusCode ?? direct?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); |
| 180 | + }); |
| 181 | + |
| 182 | + it('GET /data/:object?select=<file field>: a permitted reader gets the full value, and no `id` it did not name', async () => { |
| 183 | + as(READER); |
| 184 | + const res = await list({ select: 'attachment' }); |
| 185 | + expect(res._status ?? 200).toBe(200); |
| 186 | + expect(rowsOf(res)).toEqual([{ attachment: hydrated }]); |
| 187 | + }); |
| 188 | + |
| 189 | + it('POST /data/:object/query with fields: [<file field>]: the same', async () => { |
| 190 | + as(READER); |
| 191 | + const res = await query({ fields: ['attachment'] }); |
| 192 | + expect(res._status ?? 200).toBe(200); |
| 193 | + expect(rowsOf(res)).toEqual([{ attachment: hydrated }]); |
| 194 | + }); |
| 195 | + |
| 196 | + it('GET /data/:object/:id?select=<file field> (the findOne door): the same', async () => { |
| 197 | + as(READER); |
| 198 | + const res = await getById('d1', { select: 'attachment' }); |
| 199 | + expect(res._status ?? 200).toBe(200); |
| 200 | + const record = recordOf(res); |
| 201 | + expect(record.attachment).toEqual(hydrated); |
| 202 | + expect(record).not.toHaveProperty('id'); |
| 203 | + }); |
| 204 | + |
| 205 | + it('CONTROL select=id,<file field> answers as it did', async () => { |
| 206 | + as(READER); |
| 207 | + expect(rowsOf(await list({ select: 'id,attachment' }))).toEqual([{ id: 'd1', attachment: hydrated }]); |
| 208 | + expect(rowsOf(await query({ fields: ['id', 'attachment'] }))).toEqual([{ id: 'd1', attachment: hydrated }]); |
| 209 | + expect(recordOf(await getById('d1', { select: 'id,attachment' }))).toMatchObject({ id: 'd1', attachment: hydrated }); |
| 210 | + }); |
| 211 | + |
| 212 | + it('CONTROL a reader the field-owned verdict refuses is still refused, with or without `id`', async () => { |
| 213 | + as(STRANGER); |
| 214 | + expect(rowsOf(await list({ select: 'attachment' }))).toEqual([{ attachment: refused }]); |
| 215 | + expect(rowsOf(await list({ select: 'id,attachment' }))).toEqual([{ id: 'd1', attachment: refused }]); |
| 216 | + expect(rowsOf(await query({ fields: ['attachment'] }))).toEqual([{ attachment: refused }]); |
| 217 | + expect(rowsOf(await query({ fields: ['id', 'attachment'] }))).toEqual([{ id: 'd1', attachment: refused }]); |
| 218 | + const byId = recordOf(await getById('d1', { select: 'attachment' })); |
| 219 | + expect(byId.attachment).toEqual(refused); |
| 220 | + expect(byId).not.toHaveProperty('id'); |
| 221 | + expect(recordOf(await getById('d1', { select: 'id,attachment' })).attachment).toEqual(refused); |
| 222 | + }); |
| 223 | +}); |
0 commit comments