|
| 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 | +}); |
0 commit comments