Skip to content

Commit 3b9c5f2

Browse files
committed
fix(objectql): no related read for an org-less user under any walled posture
The related-read gate covered `group` only. Under `isolated` the organization wall refuses an org-less writer only on an object it covers, so an org-less USER caller writing an object declared `tenancy: { enabled: false }` still reached a rule that traverses into a tenant-scoped object, and the related read, carrying no `tenantId`, read across organizations: insert, validate() and a by-id update answered differently for another organization's secret and public row. The gate now asks postureEnforcesWall (every posture but `single`). Pin: `isolated`, the real SecurityPlugin + SqlDriver, an org-less user writing a non-tenant object on insert / validate / by-id update, secret == public with zero related reads, and the active-organization control. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
1 parent ada09f0 commit 3b9c5f2

2 files changed

Lines changed: 64 additions & 12 deletions

File tree

‎packages/objectql/src/engine.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ import { isPlatformObjectOutOfTenantAuditScope } from './tenancy/platform-object
153153
import { resolveTenancyPosture } from '@objectstack/types';
154154
import {
155155
normalizeTenancyPosture,
156-
postureUsesUnionScope,
156+
postureEnforcesWall,
157157
TenantLayer0VerdictSchema,
158158
type TenancyPosture,
159159
type TenantLayer0Verdict,
@@ -7071,10 +7071,10 @@ export class ObjectQL implements IObjectQLEngine {
70717071
if (this.buildReferentialFieldClear(context as ExecutionContext | undefined)) return unbound;
70727072
const wanted = collectPredicateRelationships(schema);
70737073
if (wanted.size === 0) return unbound;
7074-
// ⛔ `group`: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing.
7074+
// ⛔ Walled posture: a USER caller with no `tenantId` cannot be scoped here, so it reads nothing.
70757075
const caller = context as ExecutionContext | undefined;
70767076
const readsNothing = !!caller?.userId && !carriesOrganization(caller?.tenantId)
7077-
&& postureUsesUnionScope(this.resolveEnginePosture());
7077+
&& postureEnforcesWall(this.resolveEnginePosture());
70787078

70797079
const fields = (schema?.fields ?? {}) as Record<string, unknown>;
70807080
type Resolved = {

‎packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts‎

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,30 @@ const OBJECTS = [
4949
message: RULE_MESSAGE, condition: "record.line.kind == 'secret'",
5050
}],
5151
},
52+
{
53+
// The same rule on an object the organization wall does not cover.
54+
name: 'qa_note',
55+
label: 'Note',
56+
sharingModel: 'public_read_write',
57+
tenancy: { enabled: false },
58+
fields: {
59+
id: { name: 'id', type: 'text', primaryKey: true },
60+
name: { name: 'name', type: 'text' },
61+
line: { name: 'line', type: 'lookup', reference: 'qa_line' },
62+
},
63+
validations: [{
64+
name: 'no_secret_line', type: 'script', severity: 'error',
65+
message: RULE_MESSAGE, condition: "record.line.kind == 'secret'",
66+
}],
67+
},
5268
];
5369

5470
const MEMBER: PermissionSet = {
5571
name: 'member_default',
5672
label: 'Member',
5773
objects: {
5874
qa_inspection: { allowRead: true, allowCreate: true, allowEdit: true },
75+
qa_note: { allowRead: true, allowCreate: true, allowEdit: true },
5976
qa_line: { allowRead: true },
6077
},
6178
} as unknown as PermissionSet;
@@ -77,7 +94,7 @@ afterEach(async () => {
7794
});
7895

7996
/** `kind` is org Y's row's value; org X holds one `secret` line of its own. */
80-
async function boot(kind: 'secret' | 'public', posture?: 'group') {
97+
async function boot(kind: 'secret' | 'public', posture?: 'group' | 'isolated') {
8198
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
8299
const engine = new ObjectQL();
83100
engine.registerDriver(driver as never, true);
@@ -129,31 +146,34 @@ async function boot(kind: 'secret' | 'public', posture?: 'group') {
129146
return rows;
130147
});
131148

132-
const stored = async (name: string) => (await table('qa_inspection').where({ name }).select('id')).length;
149+
const stored = async (object: string, name: string) => (await table(object).where({ name }).select('id')).length;
133150
return { engine, readsOfLine, stored, table };
134151
}
135152

136153
/** Everything the caller sees of one write and one preview naming `line`. */
137-
async function observe(kind: 'secret' | 'public', line: string, caller: object = CALLER, posture?: 'group') {
154+
async function observe(
155+
kind: 'secret' | 'public', line: string, caller: object = CALLER,
156+
posture?: 'group' | 'isolated', object = 'qa_inspection',
157+
) {
138158
const h = await boot(kind, posture);
139159
const refusal = await h.engine
140-
.insert('qa_inspection', { name: 'probe', line }, { context: caller } as never)
160+
.insert(object, { name: 'probe', line }, { context: caller } as never)
141161
.then(() => null, (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message }));
142162
const preview = await h.engine.validate(
143-
'qa_inspection', { name: 'probe', line }, { mode: 'insert', context: caller } as never,
163+
object, { name: 'probe', line }, { mode: 'insert', context: caller } as never,
144164
);
145-
// Under `group`, the by-id UPDATE door too: org X's own inspection repointed at `line`.
165+
// Given a posture, the by-id UPDATE door too: a seeded row the caller may edit, repointed at `line`.
146166
let update: unknown;
147167
if (posture) {
148-
await h.table('qa_inspection').insert([{ id: 'insp_x', name: 'seed', organization_id: 'org_x' }]);
168+
await h.table(object).insert([{ id: 'row_x', name: 'seed', ...(object === 'qa_inspection' ? { organization_id: 'org_x' } : {}) }]);
149169
update = await h.engine
150-
.update('qa_inspection', { line }, { where: { id: 'insp_x' }, context: caller } as never)
170+
.update(object, { line }, { where: { id: 'row_x' }, context: caller } as never)
151171
.then(() => 'committed', (e: { code?: string; message?: string }) => ({ code: e.code, message: e.message }));
152172
}
153173
return {
154174
seen: {
155175
refusal,
156-
committed: await h.stored('probe'),
176+
committed: await h.stored(object, 'probe'),
157177
preview: { valid: preview.results?.[0]?.valid, errors: preview.results?.[0]?.errors?.map((e) => e.message) },
158178
update,
159179
},
@@ -213,3 +233,35 @@ describe('#18682 — under `group`, a member with no active organization reads n
213233
expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]);
214234
});
215235
});
236+
237+
/**
238+
* Under `isolated` the organization wall refuses an org-less writer only on an
239+
* object it covers. On an object declared `tenancy: { enabled: false }` the
240+
* write is admitted, and its rule's related read has no `tenantId` to scope by:
241+
* such a caller gets no related read at all.
242+
*/
243+
describe('#18682 — under `isolated`, an org-less writer of an unwalled object reads nothing related', () => {
244+
const ORGLESS_USER = { userId: 'u_x', positions: [], permissions: [], posture: 'MEMBER' };
245+
246+
it('a reference to org Y’s row ends identically whether that row is secret or public', async () => {
247+
const secret = await observe('secret', 'line_y', ORGLESS_USER, 'isolated', 'qa_note');
248+
const open = await observe('public', 'line_y', ORGLESS_USER, 'isolated', 'qa_note');
249+
250+
expect(open.seen).toEqual(secret.seen);
251+
expect(secret.seen.refusal?.code).toBe('VALIDATION_FAILED');
252+
expect(secret.seen.committed).toBe(0);
253+
expect(secret.seen.preview.valid).toBe(false);
254+
expect(secret.seen.update).toMatchObject({ code: 'VALIDATION_FAILED' });
255+
expect(secret.readsOfLine).toEqual([]);
256+
expect(open.readsOfLine).toEqual([]);
257+
});
258+
259+
it('CONTROL: WITH an active organization, org X’s own secret line reaches the rule on every door', async () => {
260+
const own = await observe('public', 'line_x', { ...ORGLESS_USER, tenantId: 'org_x' }, 'isolated', 'qa_note');
261+
262+
expect(own.seen.refusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE });
263+
expect(own.seen.preview).toEqual({ valid: false, errors: [RULE_MESSAGE] });
264+
expect(own.seen.update).toEqual({ code: 'VALIDATION_FAILED', message: RULE_MESSAGE });
265+
expect(own.readsOfLine.map((rows) => rows.length)).toEqual([1, 1, 1]);
266+
});
267+
});

0 commit comments

Comments
 (0)