Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/adr-0090-vocab-leftovers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": patch
"@objectstack/example-showcase": minor
---

ADR-0090 vocabulary leftovers (#2722, #2723, #2724) — the last "role"/"profile"
surfaces are renamed one-step, no aliases (launch-window discipline).

**`PortalSchema.profiles` → `positions`** (#2723, D2 removal miss). FROM → TO:
`profiles: ['client_portal_user']` → `positions: ['client_portal_user']` —
portal admission is now position-scoped; use the built-in `guest` position
for anonymous-only portals. The removed `profiles` key is a loud tombstone:
authoring it fails with the prescription instead of silently stripping. The
showcase Client Portal is migrated and now admits a real declared position
(`client_portal_user`).

**`RLSUserContextSchema.role` → `positions`** (#2722, D3 rename miss). FROM →
TO: `role: string | string[]` → `positions: string[]` — matches the runtime
shape the RLS compiler resolves as `current_user.positions`. No runtime
consumer read the old field (the compiler has its own context type); public
export names are unchanged.

**`sys_record_share.recipient_type` `'role'` → `'position'`** (#2724, D3).
The record-share enum and the `ShareRecipientType` contract type now match
the already-migrated spec zod enum. No stored-data migration is required:
no reader expands non-`user` record-share rows (rules materialize per-user
grants), so legacy `'role'` rows were inert. The plugin-sharing translation
bundles are regenerated — fixing the pre-stale `sys_sharing_rule` options
block too — with zh-CN/ja-JP labels patched per the generated-file contract
(业务单元及下级 / ビジネスユニットと下位階層).
32 changes: 32 additions & 0 deletions .changeset/perm-runtime-fixes-2734-2735-2737.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@objectstack/driver-sql": patch
"@objectstack/objectql": patch
---

Three permission-runtime fixes found dogfooding the ADR-0090 showcase zoo:

**#2734 — driver tenant wall hid every global row.** `applyTenantScope` used
strict `organization_id = :tenantId` equality, so any caller with an active
org (every logged-in admin) saw ZERO rows in the org-less platform tables
(`sys_position`, `sys_permission_set`, `sys_business_unit` — Setup → Access
Control rendered empty on a fresh deployment) and none of the first-boot
seeds (stamped before the default org exists). The scope is now
`(organization_id = :tenantId OR organization_id IS NULL)`: a NULL tenant
column marks a GLOBAL/platform row that belongs to no other tenant; rows
stamped with a DIFFERENT org stay invisible exactly as before.

**#2735 — bulkCreate skipped write-side marshaling.** The batch insert path
(the common case for seeds/imports since #2678) handed raw object values
(`location`/`json`/`array` fields) to the SQLite binder — "Wrong API use:
tried to bind a value of an unknown type" — silently failing whole seed
batches (showcase accounts/tasks/field-zoo seeded zero rows). `bulkCreate`
now runs each row through the same `formatInput` + `applyWriteColumnMap` +
timestamp-stamp sequence as `create()`, and decodes the read-back the same
way.

**#2737 — count()/aggregate() ignored injected read filters.** `engine.count`
and `engine.aggregate` built a LOCAL ast inside the executor, discarding the
RLS/OWD filters the security and sharing middlewares inject into
`opCtx.ast.where` — `GET /data/:object` returned scoped `records` with an
UNSCOPED `total` (a row-count oracle over invisible records, broken
pagination). Both now carry their ast on the opCtx exactly like `find()`.
2 changes: 1 addition & 1 deletion content/docs/references/security/rls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const result = RLSEvaluationResult.parse(data);
| **id** | `string` | ✅ | User ID |
| **email** | `string` | optional | User email |
| **tenantId** | `string` | optional | Tenant/Organization ID |
| **role** | `string \| string[]` | optional | User role(s) |
| **positions** | `string[]` | optional | Positions held by the user |
| **department** | `string` | optional | User department |
| **attributes** | `Record<string, any>` | optional | Additional custom user attributes |

Expand Down
19 changes: 11 additions & 8 deletions content/docs/references/ui/portal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ A Portal is **not** a new application or permission model. It is a

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

scoped to a route prefix, a set of profiles, and an optional anonymous
scoped to a route prefix, a set of admitted positions, and an optional

entry surface.
anonymous entry surface.

Five invariants this schema preserves:

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

portals.

3. Portal ≠ permission boundary. The Profile is. Portals only narrow
3. Portal ≠ permission boundary. The permission model is (permission

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

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

4. Stackable — the same user/position can be admitted by multiple

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

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

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

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

or it matches `anonymousEntry.routes[*]`.
∈ `portal.positions`, or it matches `anonymousEntry.routes[*]`.

- objectui LayoutDispatcher: select shell from `layout`.

Expand Down Expand Up @@ -98,7 +100,8 @@ const result = Portal.parse(data);
| **locale** | `string \| string` | ✅ | Locale resolution strategy. |
| **seo** | `Object` | optional | |
| **authMode** | `string \| string \| string \| string` | ✅ | Authentication mode for the portal. |
| **profiles** | `string[]` | ✅ | Profiles admitted to the portal. |
| **positions** | `string[]` | ✅ | Positions admitted to the portal. |
| **profiles** | `any` | optional | |
| **anonymousEntry** | `Object` | optional | |
| **navigation** | `Object \| Object \| Object \| Object[]` | ✅ | Flat list of portal entry points (references to existing metadata). |
| **defaultRoute** | `Object` | optional | Landing surface when the user hits the portal root. |
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
AuditorPosition,
OpsPosition,
FieldOpsDelegatePosition,
ClientPortalUserPosition,
allPositions,
} from './positions.js';

Expand Down
14 changes: 14 additions & 0 deletions examples/app-showcase/src/security/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,25 @@ export const FieldOpsDelegatePosition = definePosition({
description: 'Scoped administration of the Field Operations business-unit subtree.',
});

/**
* External client audience — the position the Client Portal admits
* (src/ui/portals/, `positions: ['client_portal_user']`). External portal
* principals evaluate against each object's `externalSharingModel` dial
* (ADR-0090 D11); this position is how the admin marks a user as belonging
* to that audience.
*/
export const ClientPortalUserPosition = definePosition({
name: 'client_portal_user',
label: 'Client Portal User',
description: 'External client admitted to the Client Portal.',
});

export const allPositions = [
ContributorPosition,
ManagerPosition,
ExecPosition,
AuditorPosition,
OpsPosition,
FieldOpsDelegatePosition,
ClientPortalUserPosition,
];
7 changes: 5 additions & 2 deletions examples/app-showcase/src/ui/portals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

/**
* Client Portal — an external-user projection of the showcase. Demonstrates
* the portal kind discriminator, profile scoping, and view-backed navigation.
* the portal kind discriminator, position-scoped admission (ADR-0090 — the
* former `profiles` gate was removed with the Profile concept), and
* view-backed navigation. `client_portal_user` is a real showcase position
* (src/security/positions.ts) the admin assigns to external client users.
*/
export const ClientPortal = {
kind: 'portal' as const,
Expand All @@ -13,7 +16,7 @@ export const ClientPortal = {
layout: 'minimal',
authMode: 'magic-link',
locale: 'auto',
profiles: ['client_portal_user'],
positions: ['client_portal_user'],
seo: { title: 'Client Portal — Showcase', description: 'Track your projects.', robots: 'noindex' as const },
navigation: [
{ type: 'view' as const, id: 'my_projects', label: 'My Projects', icon: 'folder-kanban', order: 1, viewRef: 'showcase_project.list' },
Expand Down
9 changes: 5 additions & 4 deletions examples/app-showcase/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ describe('showcase stack', () => {
// leaving 3 dataset-bound analytics reports.
expect((stack.reports ?? []).length).toBe(3);
expect((stack.flows ?? []).length).toBeGreaterThan(0);
// Six flat positions (contributor/manager/exec/auditor/ops/
// field_ops_delegate) — the ADR-0090 distribution layer; `everyone` and
// `guest` are built-in anchors and never declared by the app.
expect((stack.positions ?? []).length).toBe(6);
// Seven flat positions (contributor/manager/exec/auditor/ops/
// field_ops_delegate/client_portal_user) — the ADR-0090 distribution
// layer; `everyone` and `guest` are built-in anchors and never declared
// by the app.
expect((stack.positions ?? []).length).toBe(7);
expect((stack.agents ?? []).length).toBe(0); // AI agents are an enterprise (service-ai) feature; the open showcase ships none
});
});
154 changes: 154 additions & 0 deletions packages/objectql/src/engine-count-read-filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ObjectQL } from './engine';
import { SchemaRegistry } from './registry';

/**
* #2737 — count() and aggregate() must honor middleware-injected read filters.
*
* The security/sharing middlewares inject RLS / OWD-scope filters into
* `opCtx.ast.where`. find() carried its AST on the opCtx, so records were
* scoped — but count() and aggregate() built a LOCAL ast inside the executor
* from the caller's raw `where`, discarding every injected filter. Result:
* `GET /data/:object` returned scoped `records` with an UNSCOPED `total`
* (a row-count oracle over invisible records, and broken pagination).
*
* These tests assert on what the DRIVER receives: the middleware's filter
* must be present in the ast that reaches driver.count / driver.aggregate.
*/
vi.mock('./registry', () => {
const instance: any = {
getObject: vi.fn(),
resolveObject: vi.fn((n: string) => instance.getObject(n)),
registerObject: vi.fn(),
getObjectOwner: vi.fn(),
registerNamespace: vi.fn(),
registerKind: vi.fn(),
registerItem: vi.fn(),
registerApp: vi.fn(),
installPackage: vi.fn(),
reset: vi.fn(),
metadata: { get: vi.fn(() => new Map()) },
};
function SchemaRegistry() {
return instance;
}
Object.assign(SchemaRegistry, instance);
return {
SchemaRegistry,
computeFQN: (_ns: string | undefined, name: string) => name,
parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }),
RESERVED_NAMESPACES: new Set(['base', 'system']),
};
});

const NOTE_SCHEMA = {
name: 'note',
fields: {
title: { type: 'text' },
owner: { type: 'text' },
},
};

function makeDriver() {
const seen: { countAst?: any; aggregateAst?: any; findAst?: any } = {};
const driver: any = {
name: 'memory',
supports: {},
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
find: vi.fn(async (_o: string, ast: any) => {
seen.findAst = ast;
return [];
}),
count: vi.fn(async (_o: string, ast: any) => {
seen.countAst = ast;
return 0;
}),
aggregate: vi.fn(async (_o: string, ast: any) => {
seen.aggregateAst = ast;
return [];
}),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
return { driver, seen };
}

/** A read-filter middleware shaped like the security/sharing ones. */
function injectOwnerFilter(ql: ObjectQL) {
ql.registerMiddleware(async (ctx: any, next: () => Promise<void>) => {
if (['find', 'findOne', 'count', 'aggregate'].includes(ctx.operation)) {
const scoped = { owner: 'me' };
const ast: any = ctx.ast ?? { object: ctx.object };
ast.where = ast.where ? { $and: [ast.where, scoped] } : scoped;
ctx.ast = ast;
}
await next();
});
}

async function makeEngine(driver: any) {
vi.mocked((SchemaRegistry as any).getObject).mockImplementation((name: string) =>
name === 'note' ? NOTE_SCHEMA : undefined,
);
const ql = new ObjectQL();
ql.registerDriver(driver, true);
await ql.init();
return ql;
}

describe('engine read scoping — count/aggregate honor injected filters (#2737)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('count(): the middleware filter reaches driver.count', async () => {
const { driver, seen } = makeDriver();
const ql = await makeEngine(driver);
injectOwnerFilter(ql);

await ql.count('note', { where: { title: 'x' } });

expect(seen.countAst?.where).toEqual({ $and: [{ title: 'x' }, { owner: 'me' }] });
});

it('count() with no caller where: filter still applies', async () => {
const { driver, seen } = makeDriver();
const ql = await makeEngine(driver);
injectOwnerFilter(ql);

await ql.count('note');

expect(seen.countAst?.where).toEqual({ owner: 'me' });
});

it('aggregate(): the middleware filter reaches driver.aggregate', async () => {
const { driver, seen } = makeDriver();
const ql = await makeEngine(driver);
injectOwnerFilter(ql);

await ql.aggregate('note', {
where: { title: 'x' },
groupBy: ['owner'],
aggregations: [{ func: 'count', field: 'id', alias: 'n' }],
} as any);

expect(seen.aggregateAst?.where).toEqual({ $and: [{ title: 'x' }, { owner: 'me' }] });
// groupBy/aggregations survive on the same ast.
expect(seen.aggregateAst?.groupBy).toEqual(['owner']);
});

it('count() and find() see the SAME scoped where (total matches records)', async () => {
const { driver, seen } = makeDriver();
const ql = await makeEngine(driver);
injectOwnerFilter(ql);

await ql.find('note', { where: { title: 'x' } });
await ql.count('note', { where: { title: 'x' } });

expect(seen.countAst?.where).toEqual(seen.findAst?.where);
});
});
Loading