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');
+ });
+});