diff --git a/.changeset/25693-unclaimed-file-organization-scope.md b/.changeset/25693-unclaimed-file-organization-scope.md new file mode 100644 index 00000000000..9edeac8b840 --- /dev/null +++ b/.changeset/25693-unclaimed-file-organization-scope.md @@ -0,0 +1,21 @@ +--- +'@objectstack/service-storage': patch +--- + +fix(storage)!: an unclaimed file is served only within its organization + +The two download doors (`GET /api/v1/storage/files/:fileId/url` and `GET /api/v1/storage/files/:fileId`) now serve a file with neither an attachments scope nor a field owner (an upload no record has claimed yet, or one whose record let it go) only within its organization. The signed-in check comes first, as before. The download authorizer then judges the file as every other organization-scoped row is read: the organization wall's verdict for the tenancy posture in force, and the driver's tenant scope for the reach. A caller outside that reach is refused `403 FILE_DOWNLOAD_DENIED`, the status and code the doors already give a file the caller may not read, with a message that names no record. No error code is added. + +What does not change: the uploader is still served their own upload, as for every other private file. Under the `single` posture every signed-in caller is served as before. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-derived verdicts. An anonymous caller still gets `401 AUTH_REQUIRED`. A kernel with no auth service still serves these files without a check. + +Clause-②: no (narrowing) — the download doors serve an unclaimed file to fewer callers under a walled posture (`isolated`, `group`); nothing is widened. + +Metadata-protocol: none (how two storage download doors authorize a request; nothing a metadata author writes changes) + + + +**BREAKING** for a walled deployment (`isolated` or `group` posture) whose users download unclaimed files from outside the file's organization, shipped as `patch` because the metadata protocol does not move. + +- **Before.** The download doors did not apply the organization wall to an unclaimed file. +- **Now.** A signed-in caller is served an unclaimed file when they uploaded it, or when the file is in their organization's reach under the posture in force (their active organization under `isolated`, any organization they belong to under `group`). Anyone else gets `403 FILE_DOWNLOAD_DENIED`. A caller with no organization under a walled posture is in no organization's reach. +- **Fix.** Download the file while acting in its organization. Have a record claim it (a file field or an attachment), so that the record's access decides. Or, for a file that must be readable by everyone, mark its stored file record `acl: 'public_read'`. diff --git a/packages/services/service-storage/src/storage-routes.test.ts b/packages/services/service-storage/src/storage-routes.test.ts index 956c88fcbee..c0471011639 100644 --- a/packages/services/service-storage/src/storage-routes.test.ts +++ b/packages/services/service-storage/src/storage-routes.test.ts @@ -481,12 +481,14 @@ describe('Storage REST Routes', () => { expect(authorizeFileRead).not.toHaveBeenCalled(); }); - it('never gates a file with neither an attachments scope nor a field owner', async () => { + it('[#25693] asks the authorizer about a file with neither an attachments scope nor a field owner, and answers its deny 403 FILE_DOWNLOAD_DENIED', async () => { const { server, store: s, authorizeFileRead } = serverWith('deny'); await commit(s, { id: 'a5', scope: 'user', key: 'user/a5.png' }); const res = await hit(server, '/api/v1/storage/files/:fileId', 'a5'); - expect(res._status).toBe(302); - expect(authorizeFileRead).not.toHaveBeenCalled(); + expect(res._status).toBe(403); + expect(res._json?.error?.code).toBe('FILE_DOWNLOAD_DENIED'); + expect(res._headers.Location).toBeUndefined(); + expect(authorizeFileRead).toHaveBeenCalledOnce(); }); it('stays open when no authorizer is wired (back-compat)', async () => { @@ -542,34 +544,45 @@ describe('Storage REST Routes', () => { expect(authorizeFileRead).not.toHaveBeenCalled(); }); + /** Seconds of life the minted local capability URL carries. */ + const ttlOf = (url: string) => + adapter.verifyToken(url.split('/').pop()!, 'get').exp - Math.floor(Date.now() / 1000); + /** * DUAL-MODE REGRESSION. A pre-cutover field holds an inline blob or an - * external URL, never a sys_file id — so no legacy file is ever claimed, - * `ref_object` stays unset, and this change gates nothing that used to - * be open. An unclaimed upload behaves the same way. + * external URL, never a sys_file id — so no legacy file is ever claimed + * and `ref_object` stays unset. Such a file is the UNCLAIMED class, not + * the field-owned one: [#25693] the authorizer judges it as unclaimed, + * and an allowed download keeps the presigned TTL that class was always + * minted with rather than the field-owned class's short one. */ - it('does not gate an unclaimed file (no ref_object) — legacy fields keep working', async () => { - const { server, store: s, authorizeFileRead } = serverWith('deny'); + it('judges an unclaimed file (no ref_object) as the unclaimed class, not the field-owned one', async () => { + const { server, store: s, authorizeFileRead } = serverWith('allow', { downloadTtl: 120, presignedTtl: 1800 }); await commit(s, { id: 'fo5', scope: 'user', key: 'user/fo5.png' }); - const res = await hit(server, '/api/v1/storage/files/:fileId', 'fo5'); - expect(res._status).toBe(302); - expect(authorizeFileRead).not.toHaveBeenCalled(); + const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'fo5'); + expect(res._status).toBe(200); + expect(ttlOf(res._json.data.url)).toBeGreaterThan(1700); + expect(authorizeFileRead).toHaveBeenCalledOnce(); }); - it('does not gate a file whose ownership was released', async () => { - const { server, store: s, authorizeFileRead } = serverWith('deny'); + it('judges a file whose ownership was released as the unclaimed class', async () => { + const { server, store: s, authorizeFileRead } = serverWith('allow', { downloadTtl: 120, presignedTtl: 1800 }); await commit(s, owned('fo6', { ref_object: null, ref_id: null, ref_field: null })); - const res = await hit(server, '/api/v1/storage/files/:fileId', 'fo6'); - expect(res._status).toBe(302); - expect(authorizeFileRead).not.toHaveBeenCalled(); + const res = await hit(server, '/api/v1/storage/files/:fileId/url', 'fo6'); + expect(res._status).toBe(200); + expect(ttlOf(res._json.data.url)).toBeGreaterThan(1700); + expect(authorizeFileRead).toHaveBeenCalledOnce(); }); }); }); // ── [#22431] The unclaimed class: neither an attachments scope nor a field // owner. It has no parent record to derive access from, so the download - // asks for one thing only — a signed-in caller — unless the file declares - // `acl: 'public_read'` (ADR-0104's one anonymous download). + // asks for a signed-in caller first — unless the file declares + // `acl: 'public_read'` (ADR-0104's one anonymous download). [#25693] A + // signed-in caller is then judged by the authorizer, which serves such a + // file only within its organization (pinned on a real engine in + // `unclaimed-file-organization-scope.test.ts`). describe('a file with neither an attachments scope nor a field owner needs a signed-in caller (#22431)', () => { const DOORS = ['/api/v1/storage/files/:fileId/url', '/api/v1/storage/files/:fileId'] as const; @@ -624,7 +637,7 @@ describe('Storage REST Routes', () => { } expect(minted).not.toHaveBeenCalled(); expect(resolveSession).toHaveBeenCalledTimes(2); - // Not the parent-governed authorizer's file: there is no parent record. + // Refused at the session gate, before the authorizer is asked. expect(authorizeFileRead).not.toHaveBeenCalled(); minted.mockRestore(); }); @@ -656,8 +669,9 @@ describe('Storage REST Routes', () => { } }); - it('serves a signed-in caller as before: a 302 and a URL carrying the presigned TTL', async () => { - const { server, store: s, authorizeFileRead } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800 }); + it('serves a signed-in caller the authorizer allows: a 302 and a URL carrying the presigned TTL', async () => { + const authorizeFileRead = vi.fn(async () => 'allow' as const); + const { server, store: s } = serverWith(async () => ({ userId: 'user-7' }), { presignedTtl: 1800, authorizeFileRead }); await commit(s, { id: 'u4' }); const url = await hit(server, DOORS[0], 'u4'); expect(url._status).toBe(200); @@ -668,7 +682,26 @@ describe('Storage REST Routes', () => { const redirect = await hit(server, DOORS[1], 'u4'); expect(redirect._status).toBe(302); expect(redirect._headers.Location).toContain('/_local/raw/'); - expect(authorizeFileRead).not.toHaveBeenCalled(); + // [#25693] Asked once per door, after the session gate passed. + expect(authorizeFileRead).toHaveBeenCalledTimes(2); + }); + + it('[#25693] refuses a signed-in caller the authorizer denies 403 FILE_DOWNLOAD_DENIED at both doors, minting no URL', async () => { + const resolveSession = vi.fn(async () => ({ userId: 'user-7', organizationId: 'org-2' })); + const { server, store: s, authorizeFileRead } = serverWith(resolveSession); + await commit(s, { id: 'u4d' }); + const minted = vi.spyOn(adapter, 'getPresignedDownload'); + for (const door of DOORS) { + const res = await hit(server, door, 'u4d'); + expect(res._status, door).toBe(403); + expect(res._json?.success, door).toBe(false); + expect(res._json?.error?.code, door).toBe('FILE_DOWNLOAD_DENIED'); + expect(res._headers.Location, door).toBeUndefined(); + } + expect(minted).not.toHaveBeenCalled(); + expect(resolveSession).toHaveBeenCalledTimes(2); + expect(authorizeFileRead).toHaveBeenCalledTimes(2); + minted.mockRestore(); }); it("keeps an acl: 'public_read' file anonymous without asking for a session", async () => { @@ -705,7 +738,9 @@ describe('Storage REST Routes', () => { }); it('keeps a kernel with no session resolver open, and says so once', async () => { - const { server, store: s, logger } = serverWith(undefined); + // A bare kernel: no auth service, so neither a session resolver nor an + // authorizer is bound (`composeStorageRoutes`). + const { server, store: s, logger } = serverWith(undefined, { authorizeFileRead: undefined }); await commit(s, { id: 'u6' }); expect((await hit(server, DOORS[1], 'u6'))._status).toBe(302); expect((await hit(server, DOORS[0], 'u6'))._status).toBe(200); diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index dab71a45aac..b34c59e7fdc 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -292,9 +292,12 @@ export interface StorageRoutesOptions { * with neither an attachments scope nor a field owner (an upload no record * has claimed — an avatar or an organization logo stored as a URL, a pick * not yet saved, a released file). Such a file has no parent record to - * derive access from, so the one thing its download requires is a - * signed-in caller; none ⇒ `401 AUTH_REQUIRED`, the answer the upload - * routes give. A file marked `acl: 'public_read'` stays anonymous + * derive access from, so its download first requires a signed-in caller; + * none ⇒ `401 AUTH_REQUIRED`, the answer the upload routes give. [#25693] + * A signed-in caller is then judged by + * {@link StorageRoutesOptions.authorizeFileRead} when one is wired: the + * file is served only within its organization. A file marked + * `acl: 'public_read'` stays anonymous * (ADR-0104). A browser's `` / `` reaches this resolver * with the session cookie its sign-in set, which reads the same as a bearer * header, so a signed-in page keeps rendering these files. Absent: these @@ -302,11 +305,11 @@ export interface StorageRoutesOptions { */ resolveSession?: (req: IHttpRequest) => Promise; /** - * Authorize a DOWNLOAD of a parent-governed file (#2970 item 2, extended by - * ADR-0104 D3 wave 2). When wired, the download endpoints + * Authorize a DOWNLOAD of a private file (#2970 item 2, extended by + * ADR-0104 D3 wave 2 and #25693). When wired, the download endpoints * (`/files/:fileId` and `/files/:fileId/url`) consult this for - * `scope==='attachments'` files AND for field-owned files (`ref_object` set), - * excluding `public_read` ones: + * `scope==='attachments'` files, for field-owned files (`ref_object` set) + * AND for the unclaimed files below, excluding `public_read` ones: * - `unauthenticated` → 401 (no session) * - `deny` → 403 (session, but cannot read the parent record the file * belongs to / is attached to, and is not the owner) @@ -316,14 +319,17 @@ export interface StorageRoutesOptions { * `SERVICE_UNAVAILABLE` rather than flattened into the `deny` 403 — the * store was unreadable, so no verdict was ever reached. Every other throw * still fails closed to `deny`. - * A file with neither an attachments scope nor a field owner is not this - * authorizer's to judge — it has no parent record — and needs only a - * signed-in caller ({@link StorageRoutesOptions.resolveSession}, #22431). + * [#25693] A file with neither an attachments scope nor a field owner has + * no parent record. It first needs a signed-in caller + * ({@link StorageRoutesOptions.resolveSession}, #22431), and then this + * authorizer judges it too: such a file is served only within its + * organization, as every other organization-scoped row is read. Its + * `deny` answers `403 FILE_DOWNLOAD_DENIED`, naming no record. * Any file explicitly marked `acl: 'public_read'` keeps the stable * anonymous capability URL (ADR-0104: the opt-in for genuinely public * embedding, which no sign-in precedes). * When absent (bare kernels, tests), parent-governed downloads stay open - * (back-compat). + * (back-compat), and an unclaimed file needs only the signed-in caller. */ authorizeFileRead?: (file: FileRecord, req: IHttpRequest) => Promise; /** @@ -505,9 +511,11 @@ export function registerStorageFamily( // (`ref_object`/`ref_id`, ADR-0104 D3 wave 2). // [#22431] Every OTHER file — neither an attachments scope nor a field // owner: an upload no record has claimed — has no parent record to derive - // access from, and needs a signed-in caller instead + // access from, and needs a signed-in caller first // (`requireDownloadSession` below). It used to be the one class still - // served as an anonymous capability URL by default. + // served as an anonymous capability URL by default. [#25693] The same + // authorizer then judges it within its organization, as every other + // organization-scoped row is read (`StorageRoutesOptions.authorizeFileRead`). // `acl: 'public_read'` opts any file back out to the stable anonymous // capability URL — the explicit declaration for genuinely public // embedding, which no sign-in precedes (ADR-0104). A signed-in browser @@ -528,10 +536,12 @@ export function registerStorageFamily( if (file.acl === 'public_read') return currentLimits().presignedTtl; const fieldOwned = !!file.ref_object && file.ref_id != null && file.ref_id !== ''; const gated = file.scope === 'attachments' || fieldOwned; - if (!gated) { - if (!(await requireDownloadSession(req, res))) return false; - return currentLimits().presignedTtl; - } + // [#22431] The unclaimed class asks for a signed-in caller first, with the + // session gate's own 401. [#25693] Then, like every other private file, it + // is judged by the download authorizer below: it is served only within its + // organization (see `StorageRoutesOptions.authorizeFileRead`). It keeps the + // presigned TTL it was always minted with. + if (!gated && !(await requireDownloadSession(req, res))) return false; if (!opts.authorizeFileRead) { return currentLimits().presignedTtl; } @@ -581,12 +591,17 @@ export function registerStorageFamily( if (verdict === 'deny') { if (fieldOwned) { sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to the record this file belongs to'); - } else { + } else if (gated) { sendError(res, 403, 'ATTACHMENT_DOWNLOAD_DENIED', 'You do not have access to a record this file is attached to'); + } else { + // [#25693] An unclaimed file belongs to no record, so the refusal names + // none: the status and code the door gives every other file the caller + // may not read, and nothing about whose file it is. + sendError(res, 403, 'FILE_DOWNLOAD_DENIED', 'You do not have access to this file'); } return false; } - return downloadTtl; + return gated ? downloadTtl : currentLimits().presignedTtl; }; // ── Download servability (#10246) ──────────────────────────────────── diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 303a0b586f5..1b929b2e9d0 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -32,7 +32,11 @@ import { // [#23367] The API-key lifecycle policy source, and its branded refusal. apiKeyLifecyclePolicySourceOf, isApiKeyLifecycleRefusalError, + // [#25693] The organization wall's one verdict, asked for an unclaimed + // file's download (`isWithinCallerOrganization`). + computeTenantLayer0Verdict, } from '@objectstack/core'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IHttpServer, IDataEngine, @@ -1448,6 +1452,10 @@ async function resolveAdmissionTenancyPosture( * to exactly ONE record, so its readable-by set is that record's and nothing * more. A shared model would have had to union field references too, silently * widening access whenever one file id was copied into a more public record. + * + * [#25693] A file with no parent at all — neither an attachments scope nor a + * field owner — is judged here too, after the same owner shortcut: it is + * served only within its organization ({@link isWithinCallerOrganization}). */ function buildFileReadAuthorizer( registry: StorageGateRegistry, @@ -1534,6 +1542,16 @@ function buildFileReadAuthorizer( return readable.has(ownerId) ? 'allow' : 'deny'; } + // [#25693] Unclaimed (neither an attachments scope nor a field owner): + // no parent record to derive access from, so the file is read as every + // other organization-scoped row is — within the caller's organization, + // by the organization wall's own verdict ({@link isWithinCallerOrganization}). + if (file.scope !== 'attachments') { + return (await isWithinCallerOrganization(engine, file.id, context, tenancyServiceResolver(registry))) + ? 'allow' + : 'deny'; + } + // Otherwise: readable via any parent record this file is attached to. const links = (await (engine as any).find('sys_attachment', { where: { file_id: file.id }, @@ -1583,6 +1601,75 @@ function buildFileReadAuthorizer( }; } +/** + * [#25693] Is this unclaimed file within the caller's organization — the + * download door's verdict for a file no record owns? + * + * The rule: such a file is served only within its organization, as every + * other organization-scoped row is read. It is decided in the two places that + * decide it for every other row, and in no third place: + * + * - **the organization wall's verdict** (`computeTenantLayer0Verdict`, + * ADR-0131 D8), asked over the caller's resolved context for the posture in + * force. A `deny` — a walled posture whose caller carries no organization + * scope — is answered here, because a by-id system read cannot carry it: + * the engine projects that verdict onto the driver as no scope at all. + * The facts are the engine's own (no platform-admin exemption: neither + * the engine nor this door holds the permission sets that grant one); + * - **the driver's tenant term**: the file's row is read by id under the + * caller's active organization (`tenantId`) and, for the `group` posture, + * their organization access set (`accessible_org_ids`), so the engine's + * `buildDriverOptions` scopes the read exactly as it scopes any other. + * The file is within reach when that read finds it. + * + * ⛔ No organization is compared here. Whether the posture walls at all + * (`single` does not, so every signed-in caller is served as before), what a + * row with no organization means (ADR-0131: out of every organization's + * reach) and whether `group` reads a union are the platform's answers. + * + * The context's organization is the VETTED one: `resolveRequestExecutionContext` + * drops a session's organization claim its user no longer holds a membership + * for under a walled posture, so a claim that outlived its membership reaches + * no organization here. + * + * Throws what the posture read throws (`AuthzStoreUnavailableError`, relayed + * by the door as its `503`); a failed row read throws into the authorizer's + * fail-closed `deny`. + */ +async function isWithinCallerOrganization( + engine: IDataEngine, + fileId: string, + context: ExecutionContext, + tenancy: TenancyServiceResolver | undefined, +): Promise { + const tenantId = + typeof context.tenantId === 'string' && context.tenantId.length > 0 ? context.tenantId : undefined; + const accessibleOrgIds = Array.isArray(context.accessible_org_ids) ? context.accessible_org_ids : undefined; + const posture = tenancy ? await classifyAdmissionTenancyPosture(tenancy) : undefined; + const wall = computeTenantLayer0Verdict({ + tenancyPosture: posture ?? 'single', + organizationId: tenantId, + accessibleOrgIds, + objectHasOrgIdField: undefined, + tenancyDisabled: false, + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); + if (wall.kind === 'deny') return false; + const row = await engine.findOne( + 'sys_file', + { where: { id: fileId }, fields: ['id'] }, + { + context: { + isSystem: true, + ...(tenantId !== undefined ? { tenantId } : {}), + ...(accessibleOrgIds !== undefined ? { accessible_org_ids: accessibleOrgIds } : {}), + }, + }, + ); + return row != null; +} + /** * The field-owned verdict of ADR-0104 D3 — "may this caller read the files * these `ownerObject` records' fields own?" — answered as the subset of diff --git a/packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts b/packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts new file mode 100644 index 00000000000..baa22dce6f6 --- /dev/null +++ b/packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts @@ -0,0 +1,402 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25693] An unclaimed file is served only within its organization. + * + * A file with neither an attachments scope nor a field owner (an upload no + * record has claimed, or one whose owner let go) has no parent record to + * derive access from. The download door asks for a signed-in caller first + * (#22431), and then judges the file as every other organization-scoped row + * is read: the organization wall's verdict for the posture in force, and the + * driver's tenant term for the reach (`isWithinCallerOrganization`, + * `storage-service-plugin.ts`). No organization comparison is written in + * `@objectstack/service-storage`, so the posture alone decides who is in + * reach. + * + * ## What is real + * + * One `ObjectQL` engine over `SqlDriver` (sqlite `:memory:`, the project's + * ruled test backend) under the posture each block names, with the real + * `StorageServicePlugin` booted and its door mounted exactly as it ships + * (`start()` → `composeStorageRoutes`): the session gate, the download + * authorizer and its `resolveRequestExecutionContext`, the real `sys_file` / + * `sys_attachment` objects and a real local storage adapter. Each case calls + * both download doors (`/files/:fileId/url` and `/files/:fileId`) and, where it + * is served, follows the minted URL to the bytes. + * + * ## The stand-ins, and only these + * + * - Authentication: an `auth` service whose session read answers the user + * and the active organization two test headers name. + * - The permission store `resolveAuthzContext` reads for the door's caller: + * `sys_member` answers the fixture's memberships (which is how a session's + * organization claim is vetted under a walled posture), and every other + * object this fixture never registered answers empty (no grants). + * - The `tenancy` service: the posture, and the engine is given the same one. + * + * `@objectstack/objectql` resolves through its `exports` to `dist/` here + * (`KNOWN_UNALIASED_TEST_IMPORTS`); the subject of these pins is this + * package's own source, imported relatively. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { IStorageService, RouteHandler } from '@objectstack/spec/contracts'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { StorageServicePlugin } from './storage-service-plugin.js'; + +type Posture = 'single' | 'group' | 'isolated'; + +const BASE = '/api/v1/storage'; +const URL_DOOR = `${BASE}/files/:fileId/url`; +const REDIRECT_DOOR = `${BASE}/files/:fileId`; +const RAW_DOOR = `${BASE}/_local/raw/:token`; + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +/** Member of `org_a`; uploaded every unclaimed file below. */ +const UPLOADER = 'u_uploader'; +/** Member of `org_a`; not the uploader. */ +const COLLEAGUE = 'u_colleague'; +/** Member of `org_b` only. */ +const OUTSIDER = 'u_outsider'; +/** A member of no organization. */ +const NO_ORGANIZATION = 'u_no_org'; +/** Member of `org_b` only, whose session still names `org_a`. */ +const FORMER_MEMBER = 'u_former'; +/** Member of both organizations. */ +const BOTH = 'u_both'; + +const MEMBERSHIPS: Array<{ user_id: string; organization_id: string }> = [ + { user_id: UPLOADER, organization_id: ORG_A }, + { user_id: COLLEAGUE, organization_id: ORG_A }, + { user_id: OUTSIDER, organization_id: ORG_B }, + { user_id: FORMER_MEMBER, organization_id: ORG_B }, + { user_id: BOTH, organization_id: ORG_A }, + { user_id: BOTH, organization_id: ORG_B }, +]; + +/** Unclaimed: the default `user` scope, no field owner. */ +const F_UNCLAIMED = 'f_unclaimed_aa'; +/** Unclaimed, opted into anonymous download. */ +const F_PUBLIC = 'f_public_ab'; +/** Attachments scope, attached to an `org_a` record, uploaded by someone else. */ +const F_ATTACHED = 'f_attached_ac'; + +const silentLogger = () => ({ + info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn(), + trace: vi.fn(), fatal: vi.fn(), child() { return this; }, +}); + +/** The fixture's ONE where-matcher for `sys_member`: equality on the keys the resolver reads. */ +function membershipsWhere(where: unknown): Array> { + const w = (where ?? {}) as Record; + for (const key of Object.keys(w)) { + if (key !== 'user_id' && key !== 'organization_id') { + throw new Error(`fixture sys_member matcher: unsupported key '${key}'`); + } + } + return MEMBERSHIPS.filter((m) => Object.entries(w).every(([k, v]) => (m as Record)[k] === v)); +} + +function makeHttpServer() { + const routes = new Map(); + const put = (m: string) => (path: string, handler: RouteHandler) => { routes.set(`${m}:${path}`, handler); }; + return { + routes, + server: { + get: put('GET'), post: put('POST'), put: put('PUT'), + delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }, + }; +} + +interface Harness { + engine: ObjectQL; + routes: Map; + rootDir: string; +} + +async function boot(posture: Posture): Promise { + const rootDir = join(tmpdir(), `os-25693-${posture}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + + const engine = new ObjectQL({ logger: silentLogger() } as any); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }) as any, true); + await engine.init(); + engine.setTenancyPostureProvider(() => posture); + + // The data door the plugin is handed: the real engine, except for the + // permission store `resolveAuthzContext` reads, which this fixture never + // registers — `sys_member` answers the fixture's memberships, everything + // else answers empty. + const dataEngine = new Proxy(engine, { + get(target, prop) { + if (prop === 'find') { + return (object: string, query?: EngineQueryOptions) => { + if (target.getSchema(object)) return target.find(object, query); + return Promise.resolve(object === 'sys_member' ? membershipsWhere(query?.where) : []); + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + const services = new Map(); + const hooks: Array<() => Promise | void> = []; + const http = makeHttpServer(); + const ctx = { + logger: silentLogger(), + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string): T => { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name) as T; + }, + getServiceAsync: async (name: string): Promise => { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name) as T; + }, + hook: (event: string, fn: () => Promise | void) => { + if (event === 'kernel:ready') hooks.push(fn); + }, + }; + ctx.registerService('objectql', dataEngine); + ctx.registerService('http-server', http.server); + ctx.registerService('tenancy', { posture }); + // The plugin registers the real `sys_file` / `sys_attachment` through this. + ctx.registerService('manifest', { register: (manifest: any) => engine.registerApp(manifest) }); + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const userId = headers.get('x-test-user'); + if (!userId) return undefined; + const activeOrganizationId = headers.get('x-test-org') ?? undefined; + return { user: { id: userId }, session: { activeOrganizationId } }; + }, + }, + }); + + const plugin = new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }); + await plugin.init(ctx as any); + engine.registerApp({ + id: 'com.objectstack.test.unclaimed-file-organization-scope', + name: 'Unclaimed file organization scope', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [{ name: 'xorg_case', label: 'Case', fields: { title: { name: 'title', type: 'text' } } }], + } as never); + await engine.syncSchemas(); + + // Every row below is written for `org_a`, the way the upload door writes it: + // the acting organization as the system insert's `tenantId`. + const inA = { context: { isSystem: true, tenantId: ORG_A } }; + const fileRow = (id: string, extra: Record = {}) => ({ + id, + key: `user/${id}.txt`, + name: `${id}.txt`, + size: 12, + mime_type: 'text/plain', + scope: 'user', + acl: 'private', + status: 'committed', + owner_id: UPLOADER, + ...extra, + }); + await engine.insert('sys_file', fileRow(F_UNCLAIMED) as any, inA); + await engine.insert('sys_file', fileRow(F_PUBLIC, { acl: 'public_read' }) as any, inA); + await engine.insert( + 'sys_file', + fileRow(F_ATTACHED, { key: `attachments/${F_ATTACHED}.txt`, scope: 'attachments', owner_id: 'u_someone_else' }) as any, + inA, + ); + await engine.insert('xorg_case', { id: 'case_a', title: 'A' } as any, inA); + await engine.insert('sys_attachment', { + id: 'att_a', file_id: F_ATTACHED, parent_object: 'xorg_case', parent_id: 'case_a', + file_name: `${F_ATTACHED}.txt`, mime_type: 'text/plain', size: 12, + } as any, inA); + + await plugin.start(ctx as any); + for (const h of hooks) await h(); + + const storage = ctx.getService('storage'); + for (const id of [F_UNCLAIMED, F_PUBLIC]) await storage.upload(`user/${id}.txt`, Buffer.from(`bytes of ${id}`)); + await storage.upload(`attachments/${F_ATTACHED}.txt`, Buffer.from(`bytes of ${F_ATTACHED}`)); + + return { engine, routes: http.routes, rootDir }; +} + +async function teardown(h: Harness | undefined): Promise { + if (!h) return; + try { await h.engine.destroy(); } catch { /* already torn down */ } + await fs.rm(h.rootDir, { recursive: true, force: true }); +} + +interface DoorAnswer { + url: { status: number; code: string | undefined; body: unknown }; + redirect: { status: number; code: string | undefined; location: string | undefined }; + /** The bytes behind the minted URL, when one was minted. */ + bytes?: string; +} + +function makeRes() { + const res: any = { + _status: 200, _json: undefined, _headers: {} as Record, _sent: undefined, + json(data: any) { res._json = data; return res; }, + send(data: any) { res._sent = data; return res; }, + status(code: number) { res._status = code; return res; }, + header(name: string, value: string) { res._headers[name] = value; return res; }, + }; + return res; +} + +/** What both download doors tell `caller` (or an anonymous request) about one file. */ +async function ask( + h: Harness, + fileId: string, + caller?: { user: string; org?: string }, +): Promise { + const headers: Record = {}; + if (caller) { + headers['x-test-user'] = caller.user; + if (caller.org) headers['x-test-org'] = caller.org; + } + const call = async (route: string, params: Record) => { + const res = makeRes(); + await h.routes.get(`GET:${route}`)!({ params, query: {}, headers, method: 'GET', path: route } as any, res); + return res; + }; + const url = await call(URL_DOOR, { fileId }); + const redirect = await call(REDIRECT_DOOR, { fileId }); + const answer: DoorAnswer = { + url: { status: url._status, code: url._json?.error?.code, body: url._json }, + redirect: { status: redirect._status, code: redirect._json?.error?.code, location: redirect._headers.Location }, + }; + const minted: string | undefined = url._json?.data?.url; + if (minted) { + const raw = await call(RAW_DOOR, { token: minted.split('/').pop()! }); + answer.bytes = Buffer.isBuffer(raw._sent) ? raw._sent.toString('utf8') : String(raw._sent); + } + return answer; +} + +/** Both doors refused with one status and code, no URL minted, nothing about the file disclosed. */ +function expectRefused(a: DoorAnswer, status: number, code: string, label: string) { + expect(a.url.status, `${label}: url door`).toBe(status); + expect(a.url.code, `${label}: url door`).toBe(code); + expect(a.redirect.status, `${label}: redirect door`).toBe(status); + expect(a.redirect.code, `${label}: redirect door`).toBe(code); + expect(a.redirect.location, `${label}: redirect door`).toBeUndefined(); + expect(a.bytes, label).toBeUndefined(); + const text = JSON.stringify(a.url.body); + for (const secret of [ORG_A, UPLOADER, '.txt']) expect(text.includes(secret), `${label}: ${secret}`).toBe(false); +} + +/** Both doors served, and the minted URL carries the file's own bytes. */ +function expectServed(a: DoorAnswer, fileId: string, label: string) { + expect(a.url.status, `${label}: url door`).toBe(200); + expect(a.redirect.status, `${label}: redirect door`).toBe(302); + expect(a.redirect.location, `${label}: redirect door`).toContain('/_local/raw/'); + expect(a.bytes, label).toBe(`bytes of ${fileId}`); +} + +describe('[#25693] an unclaimed file is served only within its organization — isolated posture', () => { + let h: Harness; + beforeAll(async () => { h = await boot('isolated'); }); + afterAll(async () => { await teardown(h); }); + + it('PREMISE: the file is unclaimed, committed and written for org_a', async () => { + const row = await h.engine.findOne('sys_file', { where: { id: F_UNCLAIMED } }, { context: { isSystem: true, tenantId: ORG_A } }); + expect(row).toMatchObject({ scope: 'user', acl: 'private', status: 'committed', owner_id: UPLOADER, organization_id: ORG_A }); + expect(row?.ref_object ?? null).toBeNull(); + }); + + it('is refused to a caller acting in another organization: 403 FILE_DOWNLOAD_DENIED at both doors', async () => { + expectRefused(await ask(h, F_UNCLAIMED, { user: OUTSIDER, org: ORG_B }), 403, 'FILE_DOWNLOAD_DENIED', 'other organization'); + }); + + it('is refused to a signed-in caller who acts in no organization', async () => { + expectRefused(await ask(h, F_UNCLAIMED, { user: NO_ORGANIZATION }), 403, 'FILE_DOWNLOAD_DENIED', 'no organization'); + }); + + it("is refused to a caller whose session names the file's organization without a membership in it", async () => { + expectRefused(await ask(h, F_UNCLAIMED, { user: FORMER_MEMBER, org: ORG_A }), 403, 'FILE_DOWNLOAD_DENIED', 'claim without membership'); + }); + + it('answers the refusal the door gives a parent-governed file the caller may not read: same status, code family, no record named', async () => { + const unclaimed = await ask(h, F_UNCLAIMED, { user: OUTSIDER, org: ORG_B }); + const attached = await ask(h, F_ATTACHED, { user: OUTSIDER, org: ORG_B }); + expect(unclaimed.url.status).toBe(attached.url.status); + expect(attached.url.code).toBe('ATTACHMENT_DOWNLOAD_DENIED'); + expect(unclaimed.url.code).toBe('FILE_DOWNLOAD_DENIED'); + }); + + it('is served to a member of its organization who did not upload it', async () => { + expectServed(await ask(h, F_UNCLAIMED, { user: COLLEAGUE, org: ORG_A }), F_UNCLAIMED, 'colleague'); + }); + + it('is served to its uploader acting in its organization', async () => { + expectServed(await ask(h, F_UNCLAIMED, { user: UPLOADER, org: ORG_A }), F_UNCLAIMED, 'uploader'); + }); + + it('is served to its uploader acting elsewhere: the owner rule every private file follows', async () => { + expectServed(await ask(h, F_UNCLAIMED, { user: UPLOADER, org: ORG_B }), F_UNCLAIMED, 'uploader elsewhere'); + expectServed(await ask(h, F_UNCLAIMED, { user: UPLOADER }), F_UNCLAIMED, 'uploader in no organization'); + }); + + it('is refused to an anonymous caller 401 AUTH_REQUIRED, as before', async () => { + expectRefused(await ask(h, F_UNCLAIMED), 401, 'AUTH_REQUIRED', 'anonymous'); + }); + + it("CONTROL: an acl: 'public_read' file is still served to anyone, signed in or not", async () => { + expectServed(await ask(h, F_PUBLIC, { user: OUTSIDER, org: ORG_B }), F_PUBLIC, 'public, other organization'); + expectServed(await ask(h, F_PUBLIC), F_PUBLIC, 'public, anonymous'); + }); + + it('CONTROL: an attached file keeps its parent-derived verdict', async () => { + expectRefused(await ask(h, F_ATTACHED, { user: OUTSIDER, org: ORG_B }), 403, 'ATTACHMENT_DOWNLOAD_DENIED', 'attached, other organization'); + expectServed(await ask(h, F_ATTACHED, { user: COLLEAGUE, org: ORG_A }), F_ATTACHED, 'attached, colleague'); + }); +}); + +describe('[#25693] the posture in force decides the reach — group posture', () => { + let h: Harness; + beforeAll(async () => { h = await boot('group'); }); + afterAll(async () => { await teardown(h); }); + + it("is served to a member of the file's organization acting in another of their organizations", async () => { + expectServed(await ask(h, F_UNCLAIMED, { user: BOTH, org: ORG_B }), F_UNCLAIMED, 'member of both'); + }); + + it('is refused to a caller who holds no membership in its organization', async () => { + expectRefused(await ask(h, F_UNCLAIMED, { user: OUTSIDER, org: ORG_B }), 403, 'FILE_DOWNLOAD_DENIED', 'other organization'); + }); +}); + +describe('[#25693] the single posture is unchanged — one tenant, no wall', () => { + let h: Harness; + beforeAll(async () => { h = await boot('single'); }); + afterAll(async () => { await teardown(h); }); + + it('is served to every signed-in caller, whatever organization the session names', async () => { + expectServed(await ask(h, F_UNCLAIMED, { user: OUTSIDER, org: ORG_B }), F_UNCLAIMED, 'session naming another organization'); + expectServed(await ask(h, F_UNCLAIMED, { user: NO_ORGANIZATION }), F_UNCLAIMED, 'no organization'); + expectServed(await ask(h, F_UNCLAIMED, { user: COLLEAGUE, org: ORG_A }), F_UNCLAIMED, 'colleague'); + }); + + it('is still refused to an anonymous caller 401 AUTH_REQUIRED', async () => { + expectRefused(await ask(h, F_UNCLAIMED), 401, 'AUTH_REQUIRED', 'anonymous'); + }); +});