Skip to content

Commit afa8115

Browse files
os-zhuangclaude
andauthored
fix(security)!: ADR-0090 follow-ups — driver tenant wall, batch-insert marshaling, scoped counts, vocabulary leftovers (#2745)
Runtime (closes #2734, #2735, #2737): - driver-sql applyTenantScope: NULL organization_id = GLOBAL/platform row — (org = :tenant OR org IS NULL). Fresh deployments' admins saw ZERO rows in sys_position/sys_permission_set/sys_business_unit (Setup Access Control empty) and no first-boot seeds; cross-tenant rows stay hidden as before. - driver-sql bulkCreate: run each row through formatInput/applyWriteColumnMap like create() — raw {lat,lng} objects crashed the SQLite binder and silently failed whole seed batches (accounts/tasks/field-zoo empty). - engine count()/aggregate(): carry the AST on the opCtx so middleware- injected read filters (RLS/OWD) apply — total was an unscoped row-count oracle while records were scoped. Vocabulary (closes #2722, #2723, #2724): - PortalSchema.profiles → positions, with a loud tombstone rejecting the removed key (FROM→TO in the message); showcase Client Portal migrated and now admits a real declared position (client_portal_user). - RLSUserContextSchema.role → positions (dead field; runtime already used positions). API surface unchanged. - sys_record_share.recipient_type 'role' → 'position' + ShareRecipientType contract aligned with the spec zod enum; plugin-sharing translations regenerated (fixes the pre-stale sys_sharing_rule block) with zh/ja labels patched per the generated-file contract. Verified on a fresh `objectstack dev` boot: zero seed failures; admin sees all RBAC tables and seeds on FIRST boot with no workarounds; member total==records; platform-admin wildcard VAMA reads private rows (the old "admin sees less than the auditor" was the tenant wall hiding the member's org-less note). References regenerated. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 23c8668 commit afa8115

22 files changed

Lines changed: 495 additions & 95 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/example-showcase": minor
5+
---
6+
7+
ADR-0090 vocabulary leftovers (#2722, #2723, #2724) — the last "role"/"profile"
8+
surfaces are renamed one-step, no aliases (launch-window discipline).
9+
10+
**`PortalSchema.profiles``positions`** (#2723, D2 removal miss). FROM → TO:
11+
`profiles: ['client_portal_user']``positions: ['client_portal_user']`
12+
portal admission is now position-scoped; use the built-in `guest` position
13+
for anonymous-only portals. The removed `profiles` key is a loud tombstone:
14+
authoring it fails with the prescription instead of silently stripping. The
15+
showcase Client Portal is migrated and now admits a real declared position
16+
(`client_portal_user`).
17+
18+
**`RLSUserContextSchema.role``positions`** (#2722, D3 rename miss). FROM →
19+
TO: `role: string | string[]``positions: string[]` — matches the runtime
20+
shape the RLS compiler resolves as `current_user.positions`. No runtime
21+
consumer read the old field (the compiler has its own context type); public
22+
export names are unchanged.
23+
24+
**`sys_record_share.recipient_type` `'role'``'position'`** (#2724, D3).
25+
The record-share enum and the `ShareRecipientType` contract type now match
26+
the already-migrated spec zod enum. No stored-data migration is required:
27+
no reader expands non-`user` record-share rows (rules materialize per-user
28+
grants), so legacy `'role'` rows were inert. The plugin-sharing translation
29+
bundles are regenerated — fixing the pre-stale `sys_sharing_rule` options
30+
block too — with zh-CN/ja-JP labels patched per the generated-file contract
31+
(业务单元及下级 / ビジネスユニットと下位階層).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
Three permission-runtime fixes found dogfooding the ADR-0090 showcase zoo:
7+
8+
**#2734 — driver tenant wall hid every global row.** `applyTenantScope` used
9+
strict `organization_id = :tenantId` equality, so any caller with an active
10+
org (every logged-in admin) saw ZERO rows in the org-less platform tables
11+
(`sys_position`, `sys_permission_set`, `sys_business_unit` — Setup → Access
12+
Control rendered empty on a fresh deployment) and none of the first-boot
13+
seeds (stamped before the default org exists). The scope is now
14+
`(organization_id = :tenantId OR organization_id IS NULL)`: a NULL tenant
15+
column marks a GLOBAL/platform row that belongs to no other tenant; rows
16+
stamped with a DIFFERENT org stay invisible exactly as before.
17+
18+
**#2735 — bulkCreate skipped write-side marshaling.** The batch insert path
19+
(the common case for seeds/imports since #2678) handed raw object values
20+
(`location`/`json`/`array` fields) to the SQLite binder — "Wrong API use:
21+
tried to bind a value of an unknown type" — silently failing whole seed
22+
batches (showcase accounts/tasks/field-zoo seeded zero rows). `bulkCreate`
23+
now runs each row through the same `formatInput` + `applyWriteColumnMap` +
24+
timestamp-stamp sequence as `create()`, and decodes the read-back the same
25+
way.
26+
27+
**#2737 — count()/aggregate() ignored injected read filters.** `engine.count`
28+
and `engine.aggregate` built a LOCAL ast inside the executor, discarding the
29+
RLS/OWD filters the security and sharing middlewares inject into
30+
`opCtx.ast.where``GET /data/:object` returned scoped `records` with an
31+
UNSCOPED `total` (a row-count oracle over invisible records, broken
32+
pagination). Both now carry their ast on the opCtx exactly like `find()`.

content/docs/references/security/rls.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const result = RLSEvaluationResult.parse(data);
199199
| **id** | `string` || User ID |
200200
| **email** | `string` | optional | User email |
201201
| **tenantId** | `string` | optional | Tenant/Organization ID |
202-
| **role** | `string \| string[]` | optional | User role(s) |
202+
| **positions** | `string[]` | optional | Positions held by the user |
203203
| **department** | `string` | optional | User department |
204204
| **attributes** | `Record<string, any>` | optional | Additional custom user attributes |
205205

content/docs/references/ui/portal.mdx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ A Portal is **not** a new application or permission model. It is a
1313

1414
declarative projection of the existing app / view / action surface,
1515

16-
scoped to a route prefix, a set of profiles, and an optional anonymous
16+
scoped to a route prefix, a set of admitted positions, and an optional
1717

18-
entry surface.
18+
anonymous entry surface.
1919

2020
Five invariants this schema preserves:
2121

@@ -29,11 +29,13 @@ flows, or permissions. Data API (`/api/v1/data/...`) is unaware of
2929

3030
portals.
3131

32-
3. Portal ≠ permission boundary. The Profile is. Portals only narrow
32+
3. Portal ≠ permission boundary. The permission model is (permission
3333

34-
the UI projection; hiding a view in `navigation` is UX, not security.
34+
sets distributed via positions, ADR-0090). Portals only narrow the
3535

36-
4. Stackable — the same user/profile can be admitted by multiple
36+
UI projection; hiding a view in `navigation` is UX, not security.
37+
38+
4. Stackable — the same user/position can be admitted by multiple
3739

3840
portals. Routing or a picker decides which one is rendered.
3941

@@ -47,9 +49,9 @@ Architectural reach (consumer guidance, not part of the schema):
4749

4850
`/<routePrefix>/*` route families with a per-portal auth scope.
4951

50-
- Auth middleware: admit the request if `profile ∈ portal.profiles`,
52+
- Auth middleware: admit the request if one of the caller's positions
5153

52-
or it matches `anonymousEntry.routes[*]`.
54+
`portal.positions`, or it matches `anonymousEntry.routes[*]`.
5355

5456
- objectui LayoutDispatcher: select shell from `layout`.
5557

@@ -98,7 +100,8 @@ const result = Portal.parse(data);
98100
| **locale** | `string \| string` || Locale resolution strategy. |
99101
| **seo** | `Object` | optional | |
100102
| **authMode** | `string \| string \| string \| string` || Authentication mode for the portal. |
101-
| **profiles** | `string[]` || Profiles admitted to the portal. |
103+
| **positions** | `string[]` || Positions admitted to the portal. |
104+
| **profiles** | `any` | optional | |
102105
| **anonymousEntry** | `Object` | optional | |
103106
| **navigation** | `Object \| Object \| Object \| Object[]` || Flat list of portal entry points (references to existing metadata). |
104107
| **defaultRoute** | `Object` | optional | Landing surface when the user hits the portal root. |

examples/app-showcase/src/security/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
AuditorPosition,
3030
OpsPosition,
3131
FieldOpsDelegatePosition,
32+
ClientPortalUserPosition,
3233
allPositions,
3334
} from './positions.js';
3435

examples/app-showcase/src/security/positions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,25 @@ export const FieldOpsDelegatePosition = definePosition({
6464
description: 'Scoped administration of the Field Operations business-unit subtree.',
6565
});
6666

67+
/**
68+
* External client audience — the position the Client Portal admits
69+
* (src/ui/portals/, `positions: ['client_portal_user']`). External portal
70+
* principals evaluate against each object's `externalSharingModel` dial
71+
* (ADR-0090 D11); this position is how the admin marks a user as belonging
72+
* to that audience.
73+
*/
74+
export const ClientPortalUserPosition = definePosition({
75+
name: 'client_portal_user',
76+
label: 'Client Portal User',
77+
description: 'External client admitted to the Client Portal.',
78+
});
79+
6780
export const allPositions = [
6881
ContributorPosition,
6982
ManagerPosition,
7083
ExecPosition,
7184
AuditorPosition,
7285
OpsPosition,
7386
FieldOpsDelegatePosition,
87+
ClientPortalUserPosition,
7488
];

examples/app-showcase/src/ui/portals/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
/**
44
* Client Portal — an external-user projection of the showcase. Demonstrates
5-
* the portal kind discriminator, profile scoping, and view-backed navigation.
5+
* the portal kind discriminator, position-scoped admission (ADR-0090 — the
6+
* former `profiles` gate was removed with the Profile concept), and
7+
* view-backed navigation. `client_portal_user` is a real showcase position
8+
* (src/security/positions.ts) the admin assigns to external client users.
69
*/
710
export const ClientPortal = {
811
kind: 'portal' as const,
@@ -13,7 +16,7 @@ export const ClientPortal = {
1316
layout: 'minimal',
1417
authMode: 'magic-link',
1518
locale: 'auto',
16-
profiles: ['client_portal_user'],
19+
positions: ['client_portal_user'],
1720
seo: { title: 'Client Portal — Showcase', description: 'Track your projects.', robots: 'noindex' as const },
1821
navigation: [
1922
{ type: 'view' as const, id: 'my_projects', label: 'My Projects', icon: 'folder-kanban', order: 1, viewRef: 'showcase_project.list' },

examples/app-showcase/test/seed.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ describe('showcase stack', () => {
2525
// leaving 3 dataset-bound analytics reports.
2626
expect((stack.reports ?? []).length).toBe(3);
2727
expect((stack.flows ?? []).length).toBeGreaterThan(0);
28-
// Six flat positions (contributor/manager/exec/auditor/ops/
29-
// field_ops_delegate) — the ADR-0090 distribution layer; `everyone` and
30-
// `guest` are built-in anchors and never declared by the app.
31-
expect((stack.positions ?? []).length).toBe(6);
28+
// Seven flat positions (contributor/manager/exec/auditor/ops/
29+
// field_ops_delegate/client_portal_user) — the ADR-0090 distribution
30+
// layer; `everyone` and `guest` are built-in anchors and never declared
31+
// by the app.
32+
expect((stack.positions ?? []).length).toBe(7);
3233
expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none
3334
});
3435
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { ObjectQL } from './engine';
5+
import { SchemaRegistry } from './registry';
6+
7+
/**
8+
* #2737 — count() and aggregate() must honor middleware-injected read filters.
9+
*
10+
* The security/sharing middlewares inject RLS / OWD-scope filters into
11+
* `opCtx.ast.where`. find() carried its AST on the opCtx, so records were
12+
* scoped — but count() and aggregate() built a LOCAL ast inside the executor
13+
* from the caller's raw `where`, discarding every injected filter. Result:
14+
* `GET /data/:object` returned scoped `records` with an UNSCOPED `total`
15+
* (a row-count oracle over invisible records, and broken pagination).
16+
*
17+
* These tests assert on what the DRIVER receives: the middleware's filter
18+
* must be present in the ast that reaches driver.count / driver.aggregate.
19+
*/
20+
vi.mock('./registry', () => {
21+
const instance: any = {
22+
getObject: vi.fn(),
23+
resolveObject: vi.fn((n: string) => instance.getObject(n)),
24+
registerObject: vi.fn(),
25+
getObjectOwner: vi.fn(),
26+
registerNamespace: vi.fn(),
27+
registerKind: vi.fn(),
28+
registerItem: vi.fn(),
29+
registerApp: vi.fn(),
30+
installPackage: vi.fn(),
31+
reset: vi.fn(),
32+
metadata: { get: vi.fn(() => new Map()) },
33+
};
34+
function SchemaRegistry() {
35+
return instance;
36+
}
37+
Object.assign(SchemaRegistry, instance);
38+
return {
39+
SchemaRegistry,
40+
computeFQN: (_ns: string | undefined, name: string) => name,
41+
parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }),
42+
RESERVED_NAMESPACES: new Set(['base', 'system']),
43+
};
44+
});
45+
46+
const NOTE_SCHEMA = {
47+
name: 'note',
48+
fields: {
49+
title: { type: 'text' },
50+
owner: { type: 'text' },
51+
},
52+
};
53+
54+
function makeDriver() {
55+
const seen: { countAst?: any; aggregateAst?: any; findAst?: any } = {};
56+
const driver: any = {
57+
name: 'memory',
58+
supports: {},
59+
connect: vi.fn().mockResolvedValue(undefined),
60+
disconnect: vi.fn().mockResolvedValue(undefined),
61+
find: vi.fn(async (_o: string, ast: any) => {
62+
seen.findAst = ast;
63+
return [];
64+
}),
65+
count: vi.fn(async (_o: string, ast: any) => {
66+
seen.countAst = ast;
67+
return 0;
68+
}),
69+
aggregate: vi.fn(async (_o: string, ast: any) => {
70+
seen.aggregateAst = ast;
71+
return [];
72+
}),
73+
create: vi.fn(),
74+
update: vi.fn(),
75+
delete: vi.fn(),
76+
};
77+
return { driver, seen };
78+
}
79+
80+
/** A read-filter middleware shaped like the security/sharing ones. */
81+
function injectOwnerFilter(ql: ObjectQL) {
82+
ql.registerMiddleware(async (ctx: any, next: () => Promise<void>) => {
83+
if (['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation)) {
84+
const scoped = { owner: 'me' };
85+
const ast: any = ctx.ast ?? { object: ctx.object };
86+
ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped;
87+
ctx.ast = ast;
88+
}
89+
await next();
90+
});
91+
}
92+
93+
async function makeEngine(driver: any) {
94+
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
95+
name === 'note' ? NOTE_SCHEMA : undefined,
96+
);
97+
const ql = new ObjectQL();
98+
ql.registerDriver(driver, true);
99+
await ql.init();
100+
return ql;
101+
}
102+
103+
describe('engine read scoping — count/aggregate honor injected filters (#2737)', () => {
104+
beforeEach(() => {
105+
vi.clearAllMocks();
106+
});
107+
108+
it('count(): the middleware filter reaches driver.count', async () => {
109+
const { driver, seen } = makeDriver();
110+
const ql = await makeEngine(driver);
111+
injectOwnerFilter(ql);
112+
113+
await ql.count('note', { where: { title: 'x' } });
114+
115+
expect(seen.countAst?.where).toEqual({ $and: [{ title: 'x' }, { owner: 'me' }] });
116+
});
117+
118+
it('count() with no caller where: filter still applies', async () => {
119+
const { driver, seen } = makeDriver();
120+
const ql = await makeEngine(driver);
121+
injectOwnerFilter(ql);
122+
123+
await ql.count('note');
124+
125+
expect(seen.countAst?.where).toEqual({ owner: 'me' });
126+
});
127+
128+
it('aggregate(): the middleware filter reaches driver.aggregate', async () => {
129+
const { driver, seen } = makeDriver();
130+
const ql = await makeEngine(driver);
131+
injectOwnerFilter(ql);
132+
133+
await ql.aggregate('note', {
134+
where: { title: 'x' },
135+
groupBy: ['owner'],
136+
aggregations: [{ func: 'count', field: 'id', alias: 'n' }],
137+
} as any);
138+
139+
expect(seen.aggregateAst?.where).toEqual({ $and: [{ title: 'x' }, { owner: 'me' }] });
140+
// groupBy/aggregations survive on the same ast.
141+
expect(seen.aggregateAst?.groupBy).toEqual(['owner']);
142+
});
143+
144+
it('count() and find() see the SAME scoped where (total matches records)', async () => {
145+
const { driver, seen } = makeDriver();
146+
const ql = await makeEngine(driver);
147+
injectOwnerFilter(ql);
148+
149+
await ql.find('note', { where: { title: 'x' } });
150+
await ql.count('note', { where: { title: 'x' } });
151+
152+
expect(seen.countAst?.where).toEqual(seen.findAst?.where);
153+
});
154+
});

0 commit comments

Comments
 (0)