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/22046-upload-doors-uploader-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-storage': minor
---

fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at three storage upload doors, 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 doors act for: a caller who is not the file's uploader is now refused, while the uploader is answered as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors 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.

`POST /api/v1/storage/upload/complete`, `POST /api/v1/storage/upload/chunked/:uploadId/complete` and `GET /api/v1/storage/upload/chunked/:uploadId/progress` now act only for the user who started the upload. The rule is declared once and each of the three doors consults it: the signed-in user must be the file's `owner_id`, which both upload-start doors already stamp from the session. A chunked upload reaches its uploader through its file.

- **Refused now:** every other caller, with `403 PERMISSION_DENIED`. The answer is the same whichever organization the caller acts in, and it comes before anything is written or returned. There is no administrator exception. A file with no recorded uploader, or a chunked upload whose file is gone, is refused the same way rather than given a guessed owner.
- **Unchanged:** the uploader's own calls, who may start an upload, who may download a file (the download doors' own authorization), the organization the doors stamp and scope by, and the not-found answers. A deployment with no `auth` service, whose upload routes run without a session resolver, keeps them open as before.

What changes for you: complete and poll an upload as the same signed-in user who started it. An upload left pending with no recorded uploader cannot be committed; start it again.
2 changes: 2 additions & 0 deletions packages/plugins/organizations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/service-storage": "workspace:*",
"@objectstack/verify": "workspace:*",
"@types/node": "^26.6.3",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #22046 (ruling B and its addendum) — the storage upload doors' ownership
* rule, measured on a BOOTED organization wall.
*
* `@objectstack/service-storage` declares one ownership rule (`isFileUploader`)
* that three upload doors consult after their by-id read and before any write
* or disclosure: the commit door, the chunked-completion door and the progress
* door. Its own suite pins the rule per door. What that suite cannot boot is
* the organization wall, and the ruling's addendum asks for exactly that: per
* door, a caller acting in ANOTHER organization, naming an upload it did not
* start, is answered `403 PERMISSION_DENIED` with the same body as a caller in
* the uploader's own organization — and the by-id read that precedes the rule
* discloses nothing and writes nothing across the wall.
*
* ## Why this file lives in THIS package
*
* The wall is raised by this package's `OrganizationsPlugin` (the `org-scoping`
* registrar the `tenancy` service probes). ADR-0132's entitlement boundary
* forbids every other workspace package from declaring
* `@objectstack/organizations` (`no-framework-dependents.pin.test.ts`), so the
* storage package — and the dogfood package, whose multi-organization blocks
* skip for the same reason — cannot mount it. The boundary is asymmetric: this
* package may depend on framework packages freely, so the measurement comes
* here rather than the plugin going there. It is the smallest package that
* boots the wall.
*
* ## What is real
*
* `bootStack` (`@objectstack/verify`): an `ObjectKernel` over in-process
* SQLite, the Hono app every request below is injected through, the real
* `AuthPlugin` (better-auth sessions, organizations, invitations) and
* `SecurityPlugin`. THIS package's `OrganizationsPlugin` and the real
* `StorageServicePlugin` are mounted the way a host mounts them. The walled
* posture is REQUESTED through `OS_TENANCY_POSTURE=isolated` and read back off
* the `tenancy` service the boot resolved — not assumed. The three callers are
* made through the product's own doors: the seeded platform owner, a colleague
* invited into the owner's organization who accepted, and an outsider who
* created an organization of their own. Nothing is stubbed.
*
* The rows each door names are read back through the engine as SYSTEM, whole,
* before and after every refused call, and compared column for column — that is
* "writes nothing". "Discloses nothing" is the refusal body: one code, one
* status, and nothing about the row.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import { StorageServicePlugin } from '@objectstack/service-storage';
import { OrganizationsPlugin } from './organizations-plugin.js';

const APP = {
manifest: {
id: 'com.example.uploadwall',
namespace: 'uploadwall',
version: '0.0.1',
type: 'app',
name: 'Upload Door Wall Fixture',
},
objects: [],
};

const SYS = { context: { isSystem: true } } as const;

const COLLEAGUE_EMAIL = 'upload-wall-colleague@verify.test';
const OUTSIDER_EMAIL = 'upload-wall-outsider@verify.test';

interface Answer {
status: number;
body: any;
}

describe('the upload doors on a booted organization wall (#22046)', () => {
const restoreEnv: Array<[string, string | undefined]> = [];
let rootDir: string;
let stack: VerifyStack;
let ql: any;
/** The seeded platform owner — every upload below is theirs. */
let uploader: string;
/** Invited into the uploader's organization; not the uploader. */
let colleague: string;
/** Acting in an organization of their own. */
let outsider: string;
let uploaderOrg: string;
let outsiderOrg: string;

const answer = async (res: Response): Promise<Answer> => ({
status: res.status,
body: await res.json().catch(() => undefined),
});
const activeOrganization = async (token: string): Promise<string | null> => {
const res = await stack.apiAs(token, 'GET', '/auth/get-session');
return ((await res.json()) as any)?.session?.activeOrganizationId ?? null;
};
const stored = (object: string, id: string) => ql.findOne(object, { where: { id } }, SYS);

const presign = async (): Promise<string> => {
const res = await answer(
await stack.apiAs(uploader, 'POST', '/storage/upload/presigned', {
filename: 'wall-plan.txt',
mimeType: 'text/plain',
size: 5,
}),
);
expect(res.status).toBe(200);
return res.body.data.fileId;
};
const startChunked = async (): Promise<{ uploadId: string; fileId: string }> => {
const res = await answer(
await stack.apiAs(uploader, 'POST', '/storage/upload/chunked', {
filename: 'wall-chunked.bin',
mimeType: 'application/octet-stream',
totalSize: 10,
}),
);
expect(res.status).toBe(200);
return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId };
};

/** The one refusal: code and status, and nothing about the row. */
const expectRefused = (res: Answer, secrets: string[], label: string) => {
expect(res.status, label).toBe(403);
expect(res.body?.success, label).toBe(false);
expect(res.body?.error?.code, label).toBe('PERMISSION_DENIED');
expect(res.body?.data, label).toBeUndefined();
const text = JSON.stringify(res.body);
for (const s of secrets) expect(text.includes(s), `${label}: ${s}`).toBe(false);
};

beforeAll(async () => {
for (const [name, value] of [
['OS_TENANCY_POSTURE', 'isolated'],
// A walled deployment must declare what a new user joins, or this
// package's own boot gate refuses it.
['OS_AUTH_MEMBERSHIP_POLICY', 'invite-only'],
] as const) {
restoreEnv.push([name, process.env[name]]);
process.env[name] = value;
}
rootDir = mkdtempSync(join(tmpdir(), 'upload-wall-'));
stack = await bootStack(APP as never, {
extraPlugins: [
new OrganizationsPlugin(),
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
],
});
ql = await stack.kernel.getServiceAsync('objectql');

uploader = await stack.signIn();
uploaderOrg = (await activeOrganization(uploader))!;

colleague = await stack.signUp(COLLEAGUE_EMAIL);
const invited = await answer(
await stack.apiAs(uploader, 'POST', '/auth/organization/invite-member', {
email: COLLEAGUE_EMAIL,
role: 'member',
organizationId: uploaderOrg,
}),
);
expect(invited.status).toBe(200);
const accepted = await stack.apiAs(colleague, 'POST', '/auth/organization/accept-invitation', {
invitationId: invited.body.id,
});
expect(accepted.status).toBe(200);
expect((await stack.apiAs(colleague, 'POST', '/auth/organization/set-active', { organizationId: uploaderOrg })).status).toBe(200);

outsider = await stack.signUp(OUTSIDER_EMAIL);
const created = await answer(
await stack.apiAs(outsider, 'POST', '/auth/organization/create', { name: 'Upload Wall Other', slug: 'upload-wall-other' }),
);
expect(created.status).toBe(200);
outsiderOrg = created.body.id;
}, 120_000);

afterAll(async () => {
await stack?.stop();
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
for (const [name, value] of restoreEnv.reverse()) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});

it('PREMISE: the wall is up, and the three callers sit where the pins say they do', async () => {
const tenancy: any = await stack.kernel.getServiceAsync('tenancy');
expect(tenancy.posture).toBe('isolated');
expect(tenancy.isolationActive).toBe(true);
expect(tenancy.degraded).toBe(false);

expect(uploaderOrg).toBeTruthy();
expect(await activeOrganization(colleague)).toBe(uploaderOrg);
expect(await activeOrganization(outsider)).toBe(outsiderOrg);
expect(outsiderOrg).not.toBe(uploaderOrg);

// The rows the doors act on are stamped into the uploader's organization —
// the wall the outsider is on the far side of.
const fileId = await presign();
const row = await stored('sys_file', fileId);
expect(row.organization_id).toBe(uploaderOrg);
expect(row.owner_id).toBeTruthy();
const { uploadId } = await startChunked();
expect((await stored('sys_upload_session', uploadId)).organization_id).toBe(uploaderOrg);
});

describe('the commit door', () => {
const commit = (token: string, fileId: string, eTag: string) =>
stack.apiAs(token, 'POST', '/storage/upload/complete', { fileId, eTag });

it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and the row is unchanged', async () => {
const fileId = await presign();
const before = await stored('sys_file', fileId);
expectRefused(await answer(await commit(colleague, fileId, 'etag-colleague')), ['wall-plan', fileId, uploaderOrg], 'colleague');
expect(await stored('sys_file', fileId)).toEqual(before);
});

it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and the row is unchanged', async () => {
const fileId = await presign();
const before = await stored('sys_file', fileId);
const cross = await answer(await commit(outsider, fileId, 'etag-outsider'));
expectRefused(cross, ['wall-plan', fileId, uploaderOrg], 'outsider');
expect(await stored('sys_file', fileId)).toEqual(before);
expect(cross).toEqual(await answer(await commit(colleague, fileId, 'etag-colleague')));
});

it('POSITIVE: the uploader commits', async () => {
const fileId = await presign();
const own = await answer(await stack.apiAs(uploader, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-uploader' }));
expect(own.status).toBe(200);
expect(own.body.data.fileId).toBe(fileId);
const row = await stored('sys_file', fileId);
expect(row.status).toBe('committed');
expect(row.etag).toBe('etag-uploader');
});
});

describe('the chunked-completion door', () => {
const complete = (token: string, uploadId: string) =>
stack.apiAs(token, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] });

it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and neither row moves', async () => {
const { uploadId, fileId } = await startChunked();
const sessionBefore = await stored('sys_upload_session', uploadId);
const fileBefore = await stored('sys_file', fileId);
expectRefused(await answer(await complete(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague');
expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore);
expect(await stored('sys_file', fileId)).toEqual(fileBefore);
});

it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and neither row moves', async () => {
const { uploadId, fileId } = await startChunked();
const sessionBefore = await stored('sys_upload_session', uploadId);
const fileBefore = await stored('sys_file', fileId);
const cross = await answer(await complete(outsider, uploadId));
expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider');
expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore);
expect(await stored('sys_file', fileId)).toEqual(fileBefore);
expect(cross).toEqual(await answer(await complete(colleague, uploadId)));
});

it('POSITIVE: the uploader completes', async () => {
const { uploadId, fileId } = await startChunked();
const own = await answer(await stack.apiAs(uploader, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] }));
expect(own.status).toBe(200);
expect(own.body.data.fileId).toBe(fileId);
expect((await stored('sys_upload_session', uploadId)).status).toBe('completed');
expect((await stored('sys_file', fileId)).status).toBe('committed');
});
});

describe('the progress door', () => {
const progress = (token: string, uploadId: string) =>
stack.apiAs(token, 'GET', `/storage/upload/chunked/${uploadId}/progress`);

it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, shown nothing, and the row is unchanged', async () => {
const { uploadId, fileId } = await startChunked();
const before = await stored('sys_upload_session', uploadId);
expectRefused(await answer(await progress(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague');
expect(await stored('sys_upload_session', uploadId)).toEqual(before);
});

it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, shown nothing, and the row is unchanged', async () => {
const { uploadId, fileId } = await startChunked();
const before = await stored('sys_upload_session', uploadId);
const cross = await answer(await progress(outsider, uploadId));
expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider');
expect(await stored('sys_upload_session', uploadId)).toEqual(before);
expect(cross).toEqual(await answer(await progress(colleague, uploadId)));
});

it('POSITIVE: the uploader reads its progress', async () => {
const { uploadId, fileId } = await startChunked();
const own = await answer(await stack.apiAs(uploader, 'GET', `/storage/upload/chunked/${uploadId}/progress`));
expect(own.status).toBe(200);
expect(own.body.data.fileId).toBe(fileId);
expect(own.body.data.filename).toBe('wall-chunked.bin');
});
});
});
21 changes: 20 additions & 1 deletion packages/plugins/organizations/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default defineConfig({
// fix (#7668/#7778); registering the package in that gate's unaliased
// ledger is explicitly NOT — the ledger is shrink-only.
//
// The three entries, and why each one is a VALUE reach rather than a type
// Some of the entries, and why each one is a VALUE reach rather than a type
// reach (a `import type` is erased before anything resolves and needs no
// alias):
// - `@objectstack/plugin-auth` — `organizations-plugin.ts` imports
Expand All @@ -27,6 +27,11 @@ export default defineConfig({
// `walled-default-org-self-registrant.pin.test.ts`, whose subject is
// exactly which principal the default-org bootstrap treats as the
// declared owner.
// - `@objectstack/service-storage` and `@objectstack/verify` — the
// `StorageServicePlugin` and `bootStack` that
// `storage-upload-door-ownership.wall.test.ts` boots the walled stack
// with (#22046). The storage upload doors ARE that file's subject, so a
// `dist/` copy behind the source would measure an older door.
//
// ANCHORED regex, array form, deliberately: a bare string `find` matches by
// PREFIX, so with a FILE replacement it would also swallow a subpath and
Expand Down Expand Up @@ -88,6 +93,14 @@ export default defineConfig({
find: /^@objectstack\/driver-sqlite-wasm$/,
replacement: path.resolve(__dirname, '../../drivers/driver-sqlite-wasm/src/index.ts'),
},
{
find: /^@objectstack\/service-storage$/,
replacement: path.resolve(__dirname, '../../services/service-storage/src/index.ts'),
},
{
find: /^@objectstack\/verify$/,
replacement: path.resolve(__dirname, '../../verify/src/index.ts'),
},
],
},
test: {
Expand All @@ -100,5 +113,11 @@ export default defineConfig({
disableConsoleIntercept: true,
environment: 'node',
include: ['src/**/*.test.ts'],
// #13517: quiet the registry's per-item registration chatter — the
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
// default. Enforced by scripts/check-registry-log-declared.mjs, since
// `storage-upload-door-ownership.wall.test.ts` boots a stack through
// `@objectstack/verify`'s `bootStack`.
env: { OS_REGISTRY_LOG: 'warn' },
},
});
Loading
Loading