Skip to content

Commit d07c6bd

Browse files
committed
test(service-storage): pin the family gate and the route on one credential, and the changeset
Pins that a session the ADR-0069 gate holds is refused 403 with its posture on the download routes (an unclaimed file and a parent-governed file) and the upload start route, whatever else the request carries. Controls: an un-held session is served, and a held owner's key alone is refused as today. Clause-②: no Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
1 parent b623d60 commit d07c6bd

4 files changed

Lines changed: 295 additions & 12 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): the storage family gate and the route judge the same credential
6+
7+
A session the ADR-0069 authentication policy HOLDS (an expired password, enforced MFA not yet enrolled) is now refused on every route of the storage family whatever else the request carries. The family door fronts routes that serve different credentials, so it judges every credential a route can serve: the held session's own posture, and a held key's owner through the shared key-admission seam. The result is a `403` with the gate's posture code before any of the family's own logic runs, so the gate and the route never judge a different credential.
8+
9+
No request a storage route refuses today becomes accepted, and no export, schema or accepted value changes.
10+
11+
Clause-②: no

‎packages/services/service-storage/src/storage-routes.api-key-account-posture.pin.test.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
* MFA_REQUIRED` with the session's message, storage never touched;
1919
* - a key whose owner holds only `PASSWORD_EXPIRED` (scope `credential`) →
2020
* never the gate's answer;
21-
* - a held session beside an un-held owner's key → never the gate's answer:
22-
* the session is not read for a key;
21+
* - [#24366] the family gate and the route judge the same credential: a
22+
* session the gate holds is refused `403` with its own posture whatever
23+
* else the request carries, storage never touched;
2324
* - the refusal is not a revocation: the same key passes once it clears.
2425
*
2526
* The composition is `storage-routes.auth-gate.pin.test.ts`'s, with a data
@@ -154,13 +155,13 @@ describe('storage family — a key answers as its owner\'s session does (#23287,
154155
expect(POSTURE_CODES).not.toContain(answer.code);
155156
});
156157

157-
it.each(census)('%s · a held session beside an un-held owner\'s key → never the gate, session not read', async (route) => {
158-
const { routes, sessionReads } = await mount({ [OWNER]: [] });
159-
const before = sessionReads.n;
158+
it.each(census)('%s · [#24366] a held session is refused with its own posture whatever else the request carries', async (route) => {
159+
const { routes, calls } = await mount({ [OWNER]: [] });
160160
const answer = await drive(routes, route, { 'x-api-key': KEY, 'x-test-user': 'u_cookie', 'x-test-hold': 'MFA_REQUIRED' });
161-
expect(POSTURE_CODES).not.toContain(answer.code);
162-
// The DOOR made no session read for the key (a route's own upload resolver may).
163-
expect(sessionReads.n - before).toBeLessThanOrEqual(1);
161+
expect(answer.status).toBe(403);
162+
expect(answer.code).toBe('MFA_REQUIRED');
163+
expect(answer.body?.error?.message).toBe(MFA_REQUIRED.message);
164+
expect(calls).toEqual([]);
164165
});
165166

166167
it('the refusal is not a revocation: the same key passes the door once the posture clears', async () => {
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24366] The storage family's ADR-0069 gate and the route judge the SAME
5+
* credential.
6+
*
7+
* ## The rule
8+
*
9+
* A session the authentication policy HOLDS (an expired password, enforced MFA
10+
* not yet enrolled) is gated out of every protected resource until it
11+
* remediates, and every route of the storage family is one. The family door
12+
* fronts routes that serve different credentials — the upload routes and the
13+
* unclaimed-file download identify their caller by the session, the
14+
* parent-governed download serves the one composition's principal — so the
15+
* gate judges every credential a route can serve: a held session is refused
16+
* `403` with its own posture whatever else the request carries, and a held
17+
* key's owner is refused through the shared key-admission seam.
18+
*
19+
* ## What is pinned
20+
*
21+
* On the download routes (an unclaimed file and a parent-governed file) and an
22+
* upload route (the start route): a held session is refused `403` with the
23+
* gate's code whatever else the request carries, and storage is never touched.
24+
*
25+
* ## Controls
26+
*
27+
* - an un-held session is SERVED (the gate is not firing for everyone);
28+
* - a held owner's key alone is refused as today — through the key arm, with
29+
* the owner's posture.
30+
*
31+
* The composition is `mountStorageRoutes` over a real `ObjectKernel`, the same
32+
* one the plugin's own mount calls, with an engine that knows the key and the
33+
* files.
34+
*/
35+
36+
import { describe, it, expect, afterEach } from 'vitest';
37+
import { promises as fs } from 'node:fs';
38+
import { join } from 'node:path';
39+
import { tmpdir } from 'node:os';
40+
import { ObjectKernel, hashApiKey } from '@objectstack/core';
41+
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
42+
import { LocalStorageAdapter } from './local-storage-adapter.js';
43+
import { mountStorageRoutes } from './mount-storage-routes.js';
44+
45+
const BASE = '/api/v1/storage';
46+
const ORG = 'org_alpha';
47+
const FUTURE = '2999-01-01T00:00:00Z';
48+
49+
/** A held user whose session carries the posture; an un-held user; a separate key owner. */
50+
const HELD_USER = 'u_held';
51+
const OK_USER = 'u_ok';
52+
const KEY_OWNER = 'u_key_owner';
53+
const MFA_OWNER = 'u_mfa_owner';
54+
55+
/** An admitted key owned by someone NOT the held session. */
56+
const OTHER_KEY = 'osk_family_gate_other_owner';
57+
/** A key whose own owner is held (the key arm's control). */
58+
const HELD_OWNER_KEY = 'osk_family_gate_held_owner';
59+
60+
const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired. Please change it to continue.' };
61+
const MFA_REQUIRED = {
62+
code: 'MFA_REQUIRED',
63+
message: 'Multi-factor authentication is required. Please set up an authenticator app to continue.',
64+
};
65+
const POSTURE_CODES = ['MFA_REQUIRED', 'PASSWORD_EXPIRED', 'PASSWORD_RESET_REQUIRED'];
66+
67+
/** An unclaimed file (no attachments scope, no field owner): a signed-in caller is all it needs. */
68+
const UNCLAIMED_FILE = 'file_family_gate_unclaimed';
69+
/** A parent-governed file: access derives from the record its field owns. */
70+
const GOVERNED_FILE = 'file_family_gate_governed';
71+
const GOVERNED_RECORD = 'rec_family_gate';
72+
73+
type Gate = { code: string; message: string };
74+
75+
function services(sessionReads: { n: number }) {
76+
const tables: Record<string, Array<Record<string, unknown>>> = {
77+
sys_api_key: [
78+
{ id: 'k_other', key: hashApiKey(OTHER_KEY), revoked: false, user_id: KEY_OWNER, active_organization_id: ORG, expires_at: FUTURE },
79+
{ id: 'k_mfa', key: hashApiKey(HELD_OWNER_KEY), revoked: false, user_id: MFA_OWNER, active_organization_id: ORG, expires_at: FUTURE },
80+
],
81+
sys_member: [
82+
{ user_id: HELD_USER, organization_id: ORG, role: 'member' },
83+
{ user_id: OK_USER, organization_id: ORG, role: 'member' },
84+
{ user_id: KEY_OWNER, organization_id: ORG, role: 'member' },
85+
{ user_id: MFA_OWNER, organization_id: ORG, role: 'member' },
86+
],
87+
sys_file: [
88+
{
89+
id: UNCLAIMED_FILE, key: 'files/unclaimed.txt', name: 'unclaimed.txt', mime_type: 'text/plain', size: 3,
90+
scope: 'user', status: 'committed', acl: 'private', owner_id: 'u_uploader',
91+
},
92+
{
93+
id: GOVERNED_FILE, key: 'files/governed.pdf', name: 'governed.pdf', mime_type: 'application/pdf', size: 3,
94+
scope: 'record', status: 'committed', acl: 'private', owner_id: 'u_uploader',
95+
ref_object: 'contract', ref_id: GOVERNED_RECORD, ref_field: 'attachment',
96+
},
97+
],
98+
};
99+
const matches = (row: Record<string, unknown>, where: Record<string, unknown> | undefined) =>
100+
Object.entries(where ?? {}).every(([k, v]) => {
101+
if (v !== null && typeof v === 'object') {
102+
const list = (v as { $in?: unknown[] }).$in;
103+
return Array.isArray(list) ? list.includes(row[k]) : false;
104+
}
105+
return (v ?? null) === (row[k] ?? null);
106+
});
107+
const objectql = {
108+
async find(object: string, q: any = {}) {
109+
// The owner record is readable by every principal that resolves one, so
110+
// only the gate decides a held case.
111+
if (object === 'contract') return q?.context?.userId ? [{ id: GOVERNED_RECORD }] : [];
112+
const rows = (tables[object] ?? []).filter((row) => matches(row, q?.where));
113+
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
114+
},
115+
async findOne(object: string, q: any = {}) {
116+
return (tables[object] ?? []).find((row) => matches(row, q?.where)) ?? null;
117+
},
118+
async insert(_object: string, row: any) { return row; },
119+
async update(_object: string, row: any) { return row; },
120+
};
121+
const sessions: Record<string, Gate | undefined> = { [HELD_USER]: PASSWORD_EXPIRED, [OK_USER]: undefined };
122+
return {
123+
objectql,
124+
tenancy: { posture: 'isolated' },
125+
auth: {
126+
isAuthGateActive: () => true,
127+
api: {
128+
getSession: async ({ headers }: { headers: Headers }) => {
129+
sessionReads.n++;
130+
const userId = headers.get('x-test-user');
131+
if (!userId) return undefined;
132+
const authGate = sessions[userId];
133+
return {
134+
user: { id: userId, ...(authGate ? { authGate } : {}) },
135+
session: { id: `s_${userId}`, userId, token: `b_${userId}`, activeOrganizationId: ORG },
136+
};
137+
},
138+
},
139+
// The key owner is clean; MFA_OWNER holds an account-scope posture.
140+
resolveUserAuthGates: async (userId: string) => (userId === MFA_OWNER ? [MFA_REQUIRED] : []),
141+
},
142+
} as Record<string, unknown>;
143+
}
144+
145+
let rootDirs: string[] = [];
146+
afterEach(async () => {
147+
for (const dir of rootDirs) await fs.rm(dir, { recursive: true, force: true });
148+
rootDirs = [];
149+
});
150+
151+
async function mount() {
152+
const rootDir = join(tmpdir(), `os-24366-${Date.now()}-${Math.random().toString(36).slice(2)}`);
153+
await fs.mkdir(rootDir, { recursive: true });
154+
rootDirs.push(rootDir);
155+
const adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret-24366' });
156+
const calls: string[] = [];
157+
const storage = new Proxy(adapter, {
158+
get(target, prop, receiver) {
159+
const value = Reflect.get(target, prop, receiver);
160+
return typeof value === 'function'
161+
? (...args: unknown[]) => { calls.push(String(prop)); return value.apply(target, args); }
162+
: value;
163+
},
164+
});
165+
const sessionReads = { n: 0 };
166+
const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never);
167+
kernel.registerService('storage', storage);
168+
kernel.registerService('tenancy', (services(sessionReads) as any).tenancy);
169+
kernel.registerService('auth', (services(sessionReads) as any).auth);
170+
kernel.registerService('objectql', (services(sessionReads) as any).objectql);
171+
const routes = new Map<string, RouteHandler>();
172+
const http = {
173+
get: (path: string, handler: RouteHandler) => { routes.set(`GET ${path}`, handler); },
174+
post: (path: string, handler: RouteHandler) => { routes.set(`POST ${path}`, handler); },
175+
put: (path: string, handler: RouteHandler) => { routes.set(`PUT ${path}`, handler); },
176+
delete: () => {}, patch: () => {}, use: () => {},
177+
listen: async () => {}, close: async () => {},
178+
};
179+
mountStorageRoutes(http as any, kernel, { basePath: BASE, logger: { info: () => {}, warn: () => {} } });
180+
calls.length = 0;
181+
return { routes, calls, sessionReads };
182+
}
183+
184+
async function drive(routes: Map<string, RouteHandler>, key: string, headers: Record<string, string>, body?: unknown) {
185+
const handler = routes.get(key);
186+
if (!handler) throw new Error(`no handler for ${key}`);
187+
const method = key.slice(0, key.indexOf(' '));
188+
const path = key.slice(key.indexOf(' ') + 1);
189+
const params: Record<string, string> = {};
190+
const fileId = path.includes('files/') ? (path.includes(':fileId') ? headers['x-test-file'] ?? UNCLAIMED_FILE : '') : '';
191+
for (const seg of path.split('/')) if (seg.startsWith(':')) params[seg.slice(1)] = seg === ':fileId' ? fileId : 'x';
192+
const captured: { status: number; body: any } = { status: 200, body: undefined };
193+
const res: any = {
194+
json(data: unknown) { captured.body = data; return res; },
195+
send() { return res; },
196+
status(code: number) { captured.status = code; return res; },
197+
header() { return res; },
198+
};
199+
await handler(
200+
{ params, query: {}, body: method === 'GET' ? undefined : body, headers, method, path } as unknown as IHttpRequest,
201+
res as IHttpResponse,
202+
);
203+
return { status: captured.status, code: captured.body?.error?.code as string | undefined, body: captured.body };
204+
}
205+
206+
const PRESIGNED = `POST ${BASE}/upload/presigned`;
207+
const DL_URL = `GET ${BASE}/files/:fileId/url`;
208+
const UPLOAD_BODY = { filename: 'a.txt', mimeType: 'text/plain', size: 3 };
209+
210+
/** The three route kinds the card names, driven on a concrete file where they read one. */
211+
const ROUTES: Array<{ name: string; route: string; file?: string; body?: unknown }> = [
212+
{ name: 'upload start', route: PRESIGNED, body: UPLOAD_BODY },
213+
{ name: 'download — unclaimed file', route: DL_URL, file: UNCLAIMED_FILE },
214+
{ name: 'download — parent-governed file', route: DL_URL, file: GOVERNED_FILE },
215+
];
216+
217+
describe('[#24366] the storage family gate and the route judge the same credential', () => {
218+
for (const { name, route, file, body } of ROUTES) {
219+
const held = { 'x-test-user': HELD_USER, ...(file ? { 'x-test-file': file } : {}) };
220+
221+
it(`${name} · a held session is refused 403 with its posture, storage never touched`, async () => {
222+
const { routes, calls } = await mount();
223+
const answer = await drive(routes, route, held, body);
224+
expect(answer.status).toBe(403);
225+
expect(answer.code).toBe(PASSWORD_EXPIRED.code);
226+
expect(answer.body?.error?.message).toBe(PASSWORD_EXPIRED.message);
227+
expect(calls).toEqual([]);
228+
});
229+
230+
it(`${name} · a held session is refused 403 whatever else the request carries (an admitted key beside it)`, async () => {
231+
const { routes, calls } = await mount();
232+
const answer = await drive(routes, route, { ...held, 'x-api-key': OTHER_KEY }, body);
233+
expect(answer.status).toBe(403);
234+
expect(answer.code).toBe(PASSWORD_EXPIRED.code);
235+
expect(answer.body?.error?.message).toBe(PASSWORD_EXPIRED.message);
236+
expect(calls).toEqual([]);
237+
});
238+
}
239+
240+
it('CONTROL · an un-held session is served the upload start route', async () => {
241+
const { routes, calls } = await mount();
242+
const answer = await drive(routes, PRESIGNED, { 'x-test-user': OK_USER }, UPLOAD_BODY);
243+
expect(answer.status).toBe(200);
244+
expect(answer.body?.success).toBe(true);
245+
expect(calls.length).toBeGreaterThan(0);
246+
});
247+
248+
it('CONTROL · an un-held session is served the unclaimed download', async () => {
249+
const { routes } = await mount();
250+
const answer = await drive(routes, DL_URL, { 'x-test-user': OK_USER, 'x-test-file': UNCLAIMED_FILE });
251+
expect(answer.status).toBe(200);
252+
expect(answer.body?.data?.url).toBeTruthy();
253+
});
254+
255+
it('CONTROL · a held owner\'s key alone is refused as today — the key arm, with the owner\'s posture', async () => {
256+
const { routes, calls } = await mount();
257+
const answer = await drive(routes, PRESIGNED, { 'x-api-key': HELD_OWNER_KEY }, UPLOAD_BODY);
258+
expect(answer.status).toBe(403);
259+
expect(answer.code).toBe(MFA_REQUIRED.code);
260+
expect(answer.body?.error?.message).toBe(MFA_REQUIRED.message);
261+
expect(calls).toEqual([]);
262+
});
263+
264+
it('CONTROL · an un-held owner\'s key alone is NOT refused by the gate (no posture code)', async () => {
265+
const { routes } = await mount();
266+
const answer = await drive(routes, PRESIGNED, { 'x-api-key': OTHER_KEY }, UPLOAD_BODY);
267+
expect(POSTURE_CODES).not.toContain(answer.code);
268+
});
269+
});

‎packages/services/service-storage/src/storage-routes.request-context-door.pin.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
* - ADR-0069: the family's gate reader runs before the door. A session the
2525
* policy holds is refused `403` with its posture while a gate is active, and
2626
* a session read that fails while a gate is active is the `503` outage. A key
27-
* answers for its own owner's `account`-scope posture, whatever cookie rides
28-
* beside it. With no gate active nothing is re-read and the held session is
27+
* answers for its own owner's `account`-scope posture, and [#24366] a held
28+
* session is refused whatever else the request carries: the family gate and
29+
* the route judge the same credential. With no gate active nothing is
30+
* re-read and the held session is
2931
* served, and the context the door hands on carries the posture that the
3032
* admitting read decided.
3133
*
@@ -275,9 +277,9 @@ const ROWS: Array<{
275277
status: 403, code: MFA_REQUIRED.code, message: MFA_REQUIRED.message,
276278
},
277279
{
278-
name: 'ADR-0069: a key beside the held cookie answers for its own owner, never for the cookie',
280+
name: 'ADR-0069 [#24366]: a held session is refused 403 with its posture whatever else the request carries, and the owner record is never read',
279281
host: { auth: 'session' }, headers: { 'x-api-key': KEY, cookie: HELD_COOKIE },
280-
status: 200, principal: 'u_key',
282+
status: 403, code: PASSWORD_EXPIRED.code, message: PASSWORD_EXPIRED.message,
281283
},
282284
{
283285
name: 'ADR-0069: with no gate active the held session is served, and its context carries the posture the admitting read decided',

0 commit comments

Comments
 (0)