Skip to content

Commit 4479e44

Browse files
os-zhuangclaude
andcommitted
test(dogfood): e2e gate for the #3602 dimension-label read-scope leak
Boots the real stack (HTTP → SecurityPlugin → AnalyticsServicePlugin auto-bridges → the plugin's real fetchRecordLabels closure) with a two-object owner-scoped fixture: a member owns a deal pointing at a vendor OWNED BY THE ADMIN. Grouping deals by vendor, resolving the grouped id to a NAME reads the vendor object — which the member cannot read. Asserts the member gets the vendor id RAW (the name does not leak) while the admin, who owns the vendor, still gets the resolved name — proving the fix scopes rather than blanks. Reverting the plugin's scope application reproduces the leak (member receives "Admin-Owned Vendor"). This is the only gate that exercises the plugin's real label closure; the service-analytics unit/integration tests fake fetchRecordLabels. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8e1c930 commit 4479e44

2 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// END-TO-END gate for #3602 residual 1 — "the dimension-label lookup is scoped
4+
// to the referenced object's RLS" — proven through the REAL stack: HTTP → REST
5+
// exec context → SecurityPlugin → AnalyticsServicePlugin auto-bridges → the
6+
// plugin's real fetchRecordLabels closure that ANDs the referenced object's
7+
// scope into the label read.
8+
//
9+
// The member owns a deal that points at a vendor OWNED BY THE ADMIN. Grouping
10+
// deals by vendor, the member's bucket carries the vendor id; resolving that id
11+
// to a NAME reads the vendor object, which the member cannot read. Before the
12+
// fix the member got the vendor's name (leak); after it, the vendor scope
13+
// excludes the admin's vendor so the raw id renders. The admin — who owns the
14+
// vendor — still sees the name, proving the fix SCOPES rather than blanks.
15+
//
16+
// This is the only gate that exercises the plugin's real label closure; the
17+
// service-analytics unit/integration tests fake fetchRecordLabels.
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import { bootStack, type VerifyStack } from '@objectstack/verify';
21+
import { labelScopeStack, labelScopeSecurity } from './fixtures/label-scope-fixture.js';
22+
23+
const VENDOR_NAME = 'Admin-Owned Vendor';
24+
25+
const dealsByVendor = {
26+
name: 'deals_by_vendor',
27+
label: 'Deals by vendor',
28+
object: 'lbl_deal',
29+
dimensions: [{ name: 'vendor', label: 'Vendor', field: 'vendor', type: 'lookup' }],
30+
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
31+
};
32+
33+
describe('dogfood: dimension-label lookup is RLS-scoped to the referenced object (#3602)', () => {
34+
let stack: VerifyStack;
35+
let adminToken: string;
36+
let memberToken: string;
37+
let vendorId: string;
38+
39+
beforeAll(async () => {
40+
stack = await bootStack(labelScopeStack as never, { security: labelScopeSecurity() });
41+
adminToken = await stack.signIn();
42+
memberToken = await stack.signUp('lbl-member@verify.test');
43+
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
const ql = await stack.kernel.getServiceAsync<any>('objectql');
46+
const sys = { context: { isSystem: true } };
47+
const uid = async (email: string): Promise<string> => {
48+
const u = await ql.findOne('sys_user', { where: { email }, ...sys });
49+
if (!u?.id) throw new Error(`no sys_user for ${email}`);
50+
return u.id as string;
51+
};
52+
const adminId = await uid('admin@objectos.ai');
53+
const memberId = await uid('lbl-member@verify.test');
54+
55+
// Vendor owned by the admin — the member must NOT be able to read it.
56+
const vendor = await ql.insert('lbl_vendor', { name: VENDOR_NAME, created_by: adminId }, sys);
57+
vendorId = vendor.id as string;
58+
59+
// One deal per principal, BOTH pointing at the admin's vendor. Authored as
60+
// system with an explicit owner so the owner policy binds deterministically
61+
// (and the member's deal can reference a vendor it cannot itself read).
62+
await ql.insert('lbl_deal', { name: 'member deal', amount: 10, vendor: vendorId, created_by: memberId }, sys);
63+
await ql.insert('lbl_deal', { name: 'admin deal', amount: 20, vendor: vendorId, created_by: adminId }, sys);
64+
65+
// Preconditions: the member reads their deal but NOT the admin's vendor.
66+
const deals = await stack.apiAs(memberToken, 'GET', '/data/lbl_deal');
67+
expect(((await deals.json()).records ?? []).map((r: any) => r.name)).toEqual(['member deal']);
68+
const vendors = await stack.apiAs(memberToken, 'GET', '/data/lbl_vendor');
69+
expect((await vendors.json()).records ?? []).toHaveLength(0);
70+
}, 90_000);
71+
72+
afterAll(async () => {
73+
await stack?.stop();
74+
});
75+
76+
async function vendorCellFor(token: string): Promise<unknown> {
77+
const res = await stack.apiAs(token, 'POST', '/analytics/dataset/query', {
78+
dataset: dealsByVendor,
79+
selection: { dimensions: ['vendor'], measures: ['cnt'] },
80+
});
81+
expect(res.status).toBe(200);
82+
const body = (await res.json()) as { rows?: Array<Record<string, unknown>> };
83+
// Exactly one deal is visible to each principal → one vendor bucket.
84+
expect(body.rows ?? []).toHaveLength(1);
85+
return (body.rows ?? [])[0].vendor;
86+
}
87+
88+
it('the member sees the vendor id RAW — the name it cannot read does not leak', async () => {
89+
const cell = await vendorCellFor(memberToken);
90+
expect(cell).toBe(vendorId);
91+
expect(cell).not.toBe(VENDOR_NAME);
92+
});
93+
94+
it('the admin — who owns the vendor — still sees the resolved name (scopes, not blanks)', async () => {
95+
const cell = await vendorCellFor(adminToken);
96+
expect(cell).toBe(VENDOR_NAME);
97+
});
98+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Two-object fixture for the #3602 dimension-label leak (residual 1).
4+
//
5+
// A dataset that groups by a LOOKUP dimension resolves each grouped FK id to the
6+
// related record's display NAME. That label read used to carry no read scope, so
7+
// it revealed the referenced record's name even when the reader could not read
8+
// that record — the leak fires whenever the base object's rows point at a
9+
// referenced record the reader's RLS hides.
10+
//
11+
// This fixture reproduces exactly that with zero dependence on org-scoping:
12+
// • `lbl_vendor` — owner-scoped on `created_by`.
13+
// • `lbl_deal` — owner-scoped on `created_by`, with a `vendor` lookup.
14+
// A member owns a deal that points at a vendor OWNED BY THE ADMIN. The member can
15+
// read their deal (owner match) but NOT the vendor (owned by someone else), so a
16+
// deals-by-vendor dataset must show the vendor's raw id to the member and its
17+
// display name only to the admin who owns it.
18+
19+
import { defineStack } from '@objectstack/spec';
20+
import { ObjectSchema, Field } from '@objectstack/spec/data';
21+
import { PermissionSetSchema, RLS, type PermissionSet } from '@objectstack/spec/security';
22+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
23+
24+
export const Vendor = ObjectSchema.create({
25+
name: 'lbl_vendor',
26+
// [ADR-0090 D1] grandfather stamp — the gate under test is permission-set RLS,
27+
// not owner-sharing.
28+
sharingModel: 'public_read_write',
29+
label: 'Vendor',
30+
pluralLabel: 'Vendors',
31+
fields: {
32+
name: Field.text({ label: 'Name', required: true }),
33+
},
34+
});
35+
36+
export const Deal = ObjectSchema.create({
37+
name: 'lbl_deal',
38+
sharingModel: 'public_read_write',
39+
label: 'Deal',
40+
pluralLabel: 'Deals',
41+
fields: {
42+
name: Field.text({ label: 'Name', required: true }),
43+
amount: Field.number({ label: 'Amount' }),
44+
vendor: Field.lookup('lbl_vendor', { label: 'Vendor' }),
45+
},
46+
});
47+
48+
export const labelScopeStack = defineStack({
49+
manifest: {
50+
id: 'com.dogfood.label_scope',
51+
namespace: 'lbl',
52+
version: '0.0.0',
53+
type: 'app',
54+
name: 'Label Scope Fixture',
55+
description: 'Deal → vendor lookup exercising the #3602 label read-scope leak.',
56+
},
57+
objects: [Vendor, Deal],
58+
});
59+
60+
const MEMBER_SET = 'lbl_fixture_member';
61+
62+
/**
63+
* The fallback set a fresh member resolves to: CRUD on both objects (so requests
64+
* reach the RLS layer rather than being denied by RBAC) plus an OWNER policy on
65+
* each. The member therefore reads only rows they own — the precondition the
66+
* label leak needs (their deal points at the admin's vendor).
67+
*/
68+
export const memberSet: PermissionSet = PermissionSetSchema.parse({
69+
name: MEMBER_SET,
70+
label: 'Label Fixture Member — owner-scoped',
71+
objects: {
72+
lbl_deal: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
73+
lbl_vendor: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
74+
},
75+
rowLevelSecurity: [
76+
RLS.ownerPolicy('lbl_deal', 'created_by'),
77+
RLS.ownerPolicy('lbl_vendor', 'created_by'),
78+
],
79+
});
80+
81+
export function labelScopeSecurity(): SecurityPlugin {
82+
return new SecurityPlugin({
83+
defaultPermissionSets: [...securityDefaultPermissionSets, memberSet],
84+
fallbackPermissionSet: memberSet.name,
85+
});
86+
}

0 commit comments

Comments
 (0)