Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/22431-unclaimed-download-signed-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-storage': minor
---

fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the two storage download routes, not a metadata change: no spec key, export, option, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers those routes serve for one file class: a caller with no session is now refused a file that has neither an attachments scope nor a field owner, while a signed-in caller is served as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default.

- **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller.
- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `<img src>` and `<a href>` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log.

What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row.
17 changes: 11 additions & 6 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ record, and issue a **short-lived signed URL**:

| Code | Status | When |
| --- | --- | --- |
| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file, or a credential the organization wall refuses (see below) |
| `AUTH_REQUIRED` | 401 | Anonymous download of any file not marked `acl: 'public_read'` (see below), or a credential the organization wall refuses |
| `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to |

**The 401 is the generic "unauthenticated" answer, and it has always covered
Expand All @@ -132,10 +132,15 @@ anonymous case does: the response is byte-identical to sending no credential at
all, and the reason is written to the server log instead. The check itself lives
in the shared API-key admission path, not in the attachments gate.

The gate is scoped to attachments files on purpose: **non-attachments files**
(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous
capability URL, because they are embedded in `<img src>` which cannot carry a
bearer token. Their discovery is already gated by access to the owning record.
The parent-record check applies to files that have a parent: attachments-scope
files, and files a record's `file` / `image` field owns, which are judged against
that one record (`FILE_DOWNLOAD_DENIED`, 403, when it cannot be read). A file with
**neither** — an upload no record has claimed, such as an avatar or an
organization logo stored as a URL — has no parent to check, so its download
requires only a signed-in caller (`AUTH_REQUIRED`, 401, otherwise). A browser's
`<img src>` sends the session cookie set at sign-in, so a signed-in page keeps
rendering these files. Only a file marked `acl: 'public_read'` is served to a
caller with no session: mark a file that way when it must render before sign-in.

The upload entry points (presigned / chunked) likewise require a session when
an auth service is wired, and stamp `owner_id` on the new `sys_file`.
Expand Down Expand Up @@ -174,7 +179,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
| List / read | inherits parent read visibility | *(filtered out)* |
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |

## See also

Expand Down
180 changes: 180 additions & 0 deletions packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#22431] A file with neither an attachments scope nor a field owner — an
// upload no record has claimed, which is how an avatar or an organization logo
// stored as a URL lives — needs a signed-in caller at both download doors,
// over a REAL showcase boot. `acl: 'public_read'` stays the one anonymous
// download (ADR-0104).
//
// The package suite (`service-storage/src/storage-routes.test.ts`) pins the
// gate over a hand-wired resolver. This file is where the composed one runs:
// the plugin's own `kernel:ready` mount binds the kernel's `auth` service as
// the resolver, so the answer a caller gets here is the deployment's answer.
//
// The half that decides whether the change is safe to ship is the COOKIE case.
// A browser renders these files through `<img src>` / `<a href>`, which can
// carry no bearer header — only the session cookie the sign-in set. So the
// signed-in reader is asserted twice: once with the bearer a script sends,
// once with nothing but that cookie, and both must reach the bytes.
//
// Not eligible for the shared showcase project: it boots its own plugins.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, promises as fs } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { StorageServicePlugin } from '@objectstack/service-storage';
import { showcaseAppDefaultSecurity } from './showcase-security.js';

const SYS = { isSystem: true } as const;
const BYTES = 'unclaimed';

/** Strip the origin off an absolute adapter URL so it can be re-injected. */
const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, '');

describe('[#22431] a download of a file with no attachments scope and no field owner needs a signed-in caller', () => {
let stack: VerifyStack;
let rootDir: string;
let ql: any;
let token: string;
let cookie: string;
/** Uploaded with no scope named — the shape the console's upload adapter sends. */
let unclaimed: string;
let attached: string;

const bearer = () => ({ Authorization: `Bearer ${token}` });

/** The real three-step presigned upload; `scope` omitted unless named. */
const upload = async (name: string, scope?: string): Promise<string> => {
const presign = await stack.api('/storage/upload/presigned', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...bearer() },
body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: BYTES.length, ...(scope ? { scope } : {}) }),
});
expect(presign.status, 'presign').toBe(200);
const { data } = (await presign.json()) as any;
const put = await stack.raw(toPath(String(data.uploadUrl)), {
method: 'PUT',
headers: data.headers ?? { 'content-type': 'text/plain' },
body: BYTES,
});
expect(put.status, 'raw PUT').toBeLessThan(300);
const complete = await stack.api('/storage/upload/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...bearer() },
body: JSON.stringify({ fileId: data.fileId }),
});
expect(complete.status, 'complete').toBe(200);
return String(data.fileId);
};

/** Both doors for one caller: the JSON door and the redirect door. */
const doors = async (fileId: string, headers: Record<string, string> = {}) => ({
url: await stack.api(`/storage/files/${fileId}/url`, { headers }),
redirect: await stack.api(`/storage/files/${fileId}`, { headers, redirect: 'manual' } as RequestInit),
});

const expectRefused = async (res: Response, label: string) => {
expect(res.status, label).toBe(401);
const body = (await res.json()) as any;
expect(body.success, label).toBe(false);
expect(body.error?.code, label).toBe('AUTH_REQUIRED');
};

/** A 302 whose target, followed with NO credential, serves the uploaded bytes. */
const expectBytesBehindRedirect = async (res: Response, label: string) => {
expect(res.status, label).toBe(302);
const location = res.headers.get('location');
expect(location, `${label}: a 302 with no Location`).toBeTruthy();
const bytes = await stack.raw(toPath(String(location)));
expect(bytes.status, label).toBe(200);
expect(await bytes.text(), label).toBe(BYTES);
};

beforeAll(async () => {
rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-'));
stack = await bootStack(showcaseStack, {
security: showcaseAppDefaultSecurity(),
extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })],
});
ql = await stack.kernel.getServiceAsync('objectql');
token = await stack.signIn();

// The browser transport: the session cookie the sign-in response sets.
const signIn = await stack.api('/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
});
expect(signIn.status).toBe(200);
cookie = signIn.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.filter((pair) => pair.includes('session_token='))
.join('; ');
expect(cookie, 'the sign-in sets a session cookie').toContain('session_token=');

unclaimed = await upload('unclaimed.txt');
attached = await upload('attached.txt', 'attachments');
}, 120_000);

afterAll(async () => {
await stack?.stop();
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
});

it('the file under test is unclaimed: no attachments scope, no field owner, not public_read', async () => {
const row = await ql.findOne('sys_file', { where: { id: unclaimed }, context: SYS });
expect(row?.scope).not.toBe('attachments');
expect(row?.ref_object ?? null).toBeNull();
expect(row?.acl ?? 'private').not.toBe('public_read');
});

it('an anonymous caller is refused 401 AUTH_REQUIRED at both download doors', async () => {
const { url, redirect } = await doors(unclaimed);
await expectRefused(url, 'the URL door');
await expectRefused(redirect, 'the redirect door');
expect(redirect.headers.get('location'), 'no capability URL leaks on the refusal').toBeNull();
});

it('a signed-in caller with a bearer token is served as before', async () => {
const { url, redirect } = await doors(unclaimed, bearer());
expect(url.status).toBe(200);
const body = (await url.json()) as any;
const bytes = await stack.raw(toPath(String(body.data.url)));
expect(await bytes.text()).toBe(BYTES);
await expectBytesBehindRedirect(redirect, 'bearer, redirect door');
});

it('a signed-in browser is served through its session cookie alone — what <img src> carries', async () => {
const { url, redirect } = await doors(unclaimed, { cookie });
expect(url.status, 'the URL door, cookie only').toBe(200);
await expectBytesBehindRedirect(redirect, 'cookie only, redirect door');
});

it("acl: 'public_read' keeps the file anonymous, and only that declaration does", async () => {
await ql.update('sys_file', { acl: 'public_read' }, { where: { id: unclaimed }, context: SYS });
try {
const { url, redirect } = await doors(unclaimed);
expect(url.status, 'public_read, anonymous URL door').toBe(200);
await expectBytesBehindRedirect(redirect, 'public_read, anonymous redirect door');
} finally {
await ql.update('sys_file', { acl: 'private' }, { where: { id: unclaimed }, context: SYS });
}
await expectRefused((await doors(unclaimed)).redirect, 'back to private');
});

it('controls: an anonymous upload and an anonymous attachments-scope download stay refused', async () => {
const presign = await stack.api('/storage/upload/presigned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'anon.txt', mimeType: 'text/plain', size: 1 }),
});
await expectRefused(presign, 'anonymous upload');
const { url, redirect } = await doors(attached);
await expectRefused(url, 'attachments-scope, URL door');
await expectRefused(redirect, 'attachments-scope, redirect door');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,19 @@ describe('storage error envelope (#3675)', () => {
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a2' } });
},
},
{
// #22431: a file with neither an attachments scope nor a field owner
// needs a signed-in caller — the same pair as the two 401s above.
name: 'anonymous download of a file with neither an attachments scope nor a field owner',
status: 401,
code: 'AUTH_REQUIRED',
run: async () => {
const store = new StorageMetadataStore(null);
await committedAttachment(store, 'u1', { scope: 'user', key: 'user/u1.png' });
const routes = mount(await tmpAdapter(), store, { resolveSession: async () => null });
return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'u1' } });
},
},
{
name: 'raw upload against an adapter with no token support',
status: 501,
Expand Down
Loading
Loading