diff --git a/.changeset/adr-0090-vocab-leftovers.md b/.changeset/adr-0090-vocab-leftovers.md new file mode 100644 index 0000000000..8ce851c921 --- /dev/null +++ b/.changeset/adr-0090-vocab-leftovers.md @@ -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 +(业务单元及下级 / ビジネスユニットと下位階層). diff --git a/.changeset/perm-runtime-fixes-2734-2735-2737.md b/.changeset/perm-runtime-fixes-2734-2735-2737.md new file mode 100644 index 0000000000..8c9a53dcdf --- /dev/null +++ b/.changeset/perm-runtime-fixes-2734-2735-2737.md @@ -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()`. diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 26f5e8a34e..fef61d7884 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -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` | optional | Additional custom user attributes | diff --git a/content/docs/references/ui/portal.mdx b/content/docs/references/ui/portal.mdx index 71ec42d035..a655a49d4e 100644 --- a/content/docs/references/ui/portal.mdx +++ b/content/docs/references/ui/portal.mdx @@ -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: @@ -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. @@ -47,9 +49,9 @@ Architectural reach (consumer guidance, not part of the schema): `//*` 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`. @@ -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. | diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index b03e16b494..cf36192905 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -29,6 +29,7 @@ export { AuditorPosition, OpsPosition, FieldOpsDelegatePosition, + ClientPortalUserPosition, allPositions, } from './positions.js'; diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index c095ccea85..c9228e2e6b 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -64,6 +64,19 @@ 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, @@ -71,4 +84,5 @@ export const allPositions = [ AuditorPosition, OpsPosition, FieldOpsDelegatePosition, + ClientPortalUserPosition, ]; diff --git a/examples/app-showcase/src/ui/portals/index.ts b/examples/app-showcase/src/ui/portals/index.ts index 924ebedc3f..4b6a6114e8 100644 --- a/examples/app-showcase/src/ui/portals/index.ts +++ b/examples/app-showcase/src/ui/portals/index.ts @@ -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, @@ -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' }, diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 4a96dfd064..b4432841fb 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -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 }); }); diff --git a/packages/objectql/src/engine-count-read-filter.test.ts b/packages/objectql/src/engine-count-read-filter.test.ts new file mode 100644 index 0000000000..0809aa567e --- /dev/null +++ b/packages/objectql/src/engine-count-read-filter.test.ts @@ -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) => { + 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); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index aeea424525..04cc7e8ff4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2582,9 +2582,16 @@ export class ObjectQL implements IDataEngine { object = this.resolveObjectName(object); const driver = this.getDriver(object); + // The AST must ride on the opCtx so the security/sharing middlewares can + // inject their read filters (RLS, OWD/sharing scope) into `ast.where` — + // exactly like find(). Building it locally inside the executor (#2737) + // discarded every injected filter: `total` counted the RAW table while + // `records` were scoped, leaking invisible-row counts and breaking + // pagination. const opCtx: OperationContext = { object, operation: 'count', + ast: { object, where: query?.where }, options: query, context: mergeReadContext(query?.context, options?.context), }; @@ -2592,10 +2599,10 @@ export class ObjectQL implements IDataEngine { await this.executeWithMiddleware(opCtx, async () => { const countOpts = this.buildDriverOptions(opCtx.context); if (driver.count) { - const ast: QueryAST = { object, where: query?.where }; - return driver.count(object, ast, countOpts); + return driver.count(object, opCtx.ast as QueryAST, countOpts); } - // Fallback to find().length + // Fallback to find().length — find() applies the read filters itself, + // so pass the caller's original where, not the already-scoped ast. const res = await this.find(object, { where: query?.where, fields: ['id'], context: opCtx.context }); return res.length; }); @@ -2611,17 +2618,21 @@ export class ObjectQL implements IDataEngine { const opCtx: OperationContext = { object, operation: 'aggregate', + // On the opCtx so middleware-injected read filters (RLS/sharing) land + // in `ast.where` — same #2737 hole as count(): a locally-built AST + // aggregated over rows the caller may not see. + ast: { + object, + where: query.where, + groupBy: query.groupBy as any, + aggregations: query.aggregations, + }, options: query, context: mergeReadContext(query?.context, options?.context), }; await this.executeWithMiddleware(opCtx, async () => { - const ast: QueryAST = { - object, - where: query.where, - groupBy: query.groupBy as any, - aggregations: query.aggregations, - }; + const ast = opCtx.ast as QueryAST; // Prefer driver.aggregate() when available — driver.find() in many // drivers (e.g. driver-sql) does not honor `groupBy` / `aggregations` diff --git a/packages/plugins/driver-sql/src/sql-driver-bulk-json.test.ts b/packages/plugins/driver-sql/src/sql-driver-bulk-json.test.ts new file mode 100644 index 0000000000..d613e270b3 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-bulk-json.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +/** + * bulkCreate write-side marshaling (#2735). + * + * The batch insert path (the common case for seeds/imports since + * framework#2678) must serialize JSON-typed and object-valued fields per row + * exactly like create() does. Before the fix, raw `{lat, lng}` objects went + * straight to the SQLite binder — "Wrong API use: tried to bind a value of an + * unknown type ([object Object])" — silently failing whole seed batches + * (showcase accounts/tasks/field-zoo seeded zero rows). + */ +describe('SqlDriver bulkCreate JSON marshaling (#2735)', () => { + let driver: SqlDriver; + + const objects = [ + { + name: 'venue', + fields: { + name: { type: 'string' }, + location: { type: 'location' }, + tags: { type: 'array' }, + meta: { type: 'json' }, + }, + }, + ]; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects(objects); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('serializes object-valued fields per row like create() does', async () => { + const rows = [ + { id: 'v1', name: 'HQ', location: { lat: 47.6062, lng: -122.3321 }, tags: ['a', 'b'], meta: { tier: 1 } }, + { id: 'v2', name: 'Lab', location: { lat: 37.7749, lng: -122.4194 }, tags: [], meta: null }, + ]; + const result = await driver.bulkCreate!('venue', rows); + expect(result).toHaveLength(2); + + // Read-back parity: JSON columns decode to objects, same as single insert. + const v1 = await driver.findOne('venue', { object: 'venue', where: { id: 'v1' } }); + expect(v1.location).toEqual({ lat: 47.6062, lng: -122.3321 }); + expect(v1.tags).toEqual(['a', 'b']); + expect(v1.meta).toEqual({ tier: 1 }); + }); + + it('matches the single-insert result shape', async () => { + const single = await driver.create('venue', { + id: 's1', name: 'Single', location: { lat: 1, lng: 2 }, + }); + const [batch] = await driver.bulkCreate!('venue', [ + { id: 'b1', name: 'Batch', location: { lat: 1, lng: 2 } }, + ]); + expect(batch.location).toEqual(single.location); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts index 76bb3ea65a..23816c55a3 100644 --- a/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/plugins/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -130,6 +130,33 @@ describe('SqlDriver tenant scope (organization_id)', () => { }); }); + describe('NULL tenant column = GLOBAL/platform row (#2734)', () => { + beforeEach(async () => { + // A platform-seeded row with no organization (bootstrap positions, + // permission sets, business units, pre-org first-boot seeds). + await driver.create('account', { id: 'g1', name: 'Global', tier: 'global' }); + }); + + it('a scoped read still sees the org-less row (any tenant)', async () => { + const rowsA = await driver.find('account', { object: 'account' }, { tenantId: 'org_a' }); + const rowsB = await driver.find('account', { object: 'account' }, { tenantId: 'org_b' }); + expect(rowsA.map((r: any) => r.id)).toContain('g1'); + expect(rowsB.map((r: any) => r.id)).toContain('g1'); + // …while cross-tenant rows stay hidden exactly as before. + expect(rowsA.map((r: any) => r.id).sort()).toEqual(['a1', 'a2', 'g1']); + }); + + it('a scoped by-id read resolves the global row', async () => { + const row = await driver.findOne('account', { object: 'account', where: { id: 'g1' } }, { tenantId: 'org_a' }); + expect(row?.id).toBe('g1'); + }); + + it('a scoped count includes the global row', async () => { + const a = await driver.count!('account', { object: 'account' }, { tenantId: 'org_a' }); + expect(a).toBe(3); // a1, a2, g1 — never org_b's rows + }); + }); + describe('updateMany / deleteMany', () => { it('updateMany only touches caller tenant rows', async () => { await driver.updateMany!( diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 0a036e1a4d..80a1b397e1 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -1202,13 +1202,28 @@ export class SqlDriver implements IDataDriver { // Reserve a persistent sequence value for each row's autonumber // field(s) — the engine no longer pre-fills these (see #1603). await this.fillAutoNumberFields(object, row, options); - // Same canonical-instant stamp as create()/upsert() so bulk-inserted - // rows don't fall back to the zone-naive CURRENT_TIMESTAMP default. - this.stampInsertTimestamps(object, row); } } + // Same write-side marshaling as create() (#2735): JSON-typed and + // object-valued fields must be serialized per row before they reach the + // knex binder — the raw batch used to hand `{lat, lng}` objects straight + // to SQLite ("Wrong API use: tried to bind a value of an unknown type"), + // silently failing the whole seed batch. Timestamp stamping runs on the + // FORMATTED copy, mirroring create(). + const formattedRows = rows.map((row) => { + if (!row || typeof row !== 'object') return row; + const formatted = this.applyWriteColumnMap(object, this.formatInput(object, row)); + this.stampInsertTimestamps(object, formatted); + return formatted; + }); const builder = this.getBuilder(object, options); - return await builder.insert(rows).returning('*'); + const result = await builder.insert(formattedRows).returning('*'); + // Read-back parity with create(): JSON columns come back as their stored + // strings from `returning('*')` — decode them so batch callers see the + // same shapes single-insert callers do. + return Array.isArray(result) + ? result.map((r) => this.formatOutput(object, r)) + : result; } /** @@ -2385,7 +2400,18 @@ export class SqlDriver implements IDataDriver { if (tenantId === undefined || tenantId === null || tenantId === '') return builder; const field = this.resolveTenantField(object); if (!field) return builder; - return builder.where(field, String(tenantId)); + // `(field = :tenantId OR field IS NULL)` — a NULL tenant column marks a + // GLOBAL/platform row (bootstrap-seeded positions and permission sets, + // business units, pre-org first-boot seeds). Such a row belongs to no + // OTHER tenant, so the cross-tenant wall must not hide it: with strict + // equality every tenant admin saw ZERO RBAC rows on a fresh deployment, + // because every platform row is org-less (#2734). Rows stamped with a + // DIFFERENT tenant stay invisible exactly as before; authorization on + // global rows remains the job of the layers above (RBAC, RLS, + // managed_by gates). + return builder.where((b) => { + void b.where(field, String(tenantId)).orWhereNull(field); + }); } /** diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index 2a90d536f3..05a8a8877d 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -21,7 +21,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Conventions: * - `object_name` is the short object name (e.g. `account`, `lead`). * - `recipient_type` mirrors `ShareRecipientType` from the spec - * (`user` is enforced today; `group`/`role` are persisted for + * (`user` is enforced today; `group`/`position` are persisted for * forward-compatibility). * - `source = 'manual'` rows are created by a user via the REST * `POST /data/:object/:id/shares` endpoint. `source = 'rule'` rows @@ -135,7 +135,7 @@ export const SysRecordShare = ObjectSchema.create({ // ── Recipient (who receives access) ────────────────────────── recipient_type: Field.select( - ['user', 'group', 'role', 'unit_and_subordinates', 'guest'], + ['user', 'group', 'position', 'unit_and_subordinates', 'guest'], { label: 'Recipient Type', required: true, @@ -149,7 +149,7 @@ export const SysRecordShare = ObjectSchema.create({ label: 'Recipient', required: true, maxLength: 100, - description: 'ID of the user/group/role that receives access', + description: 'ID of the user/group/position that receives access', group: 'Recipient', }), diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 8d76c3b882..f0d579c546 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -31,14 +31,14 @@ export const enObjects: NonNullable = { options: { user: "user", group: "group", - role: "role", + position: "position", unit_and_subordinates: "unit_and_subordinates", guest: "guest" } }, recipient_id: { label: "Recipient", - help: "ID of the user/group/role that receives access" + help: "ID of the user/group/position that receives access" }, access_level: { label: "Access Level", @@ -131,18 +131,19 @@ export const enObjects: NonNullable = { }, recipient_type: { label: "Recipient Type", - help: "Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_business_unit_id tree; `team` is flat (better-auth).", + help: "Kind of principal that receives access — expanded to user grants at evaluation time. `business_unit` walks the parent_business_unit_id tree; `team` is flat (better-auth); `position` expands the position's holders (positions are flat, ADR-0090 D3); `unit_and_subordinates` expands the named business unit PLUS every descendant unit's members via the sys_business_unit tree (ADR-0057 D5).", options: { user: "user", team: "team", - department: "department", - role: "role", + business_unit: "business_unit", + position: "position", + unit_and_subordinates: "unit_and_subordinates", queue: "queue" } }, recipient_id: { label: "Recipient", - help: "department id / team id / role name / queue name / user id depending on recipient_type" + help: "business unit id / team id / position name / queue name / user id depending on recipient_type" }, access_level: { label: "Access Level", diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 859323974f..f77519e4af 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -31,7 +31,7 @@ export const esESObjects: NonNullable = { options: { user: "Usuario", group: "Grupo", - role: "Rol", + position: "posición", unit_and_subordinates: "Rol y subordinados", guest: "Invitado" } @@ -135,8 +135,9 @@ export const esESObjects: NonNullable = { options: { user: "Usuario", team: "Equipo", - department: "Departamento", - role: "Rol", + business_unit: "business_unit", + position: "posición", + unit_and_subordinates: "unit_and_subordinates", queue: "Cola" } }, diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index ea54be04c1..ee8e6acb7e 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -31,8 +31,8 @@ export const jaJPObjects: NonNullable = { options: { user: "ユーザー", group: "グループ", - role: "ロール", - unit_and_subordinates: "ロールと下位階層", + position: "ポジション", + unit_and_subordinates: "ビジネスユニットと下位階層", guest: "ゲスト" } }, @@ -135,8 +135,9 @@ export const jaJPObjects: NonNullable = { options: { user: "ユーザー", team: "チーム", - department: "部門", - role: "ロール", + business_unit: "business_unit", + position: "ポジション", + unit_and_subordinates: "unit_and_subordinates", queue: "キュー" } }, diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index f89c455da0..7f1fff2b22 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -31,14 +31,14 @@ export const zhCNObjects: NonNullable = { options: { user: "用户", group: "组", - role: "角色", - unit_and_subordinates: "角色及下级", + position: "岗位", + unit_and_subordinates: "业务单元及下级", guest: "访客" } }, recipient_id: { label: "接收方", - help: "获得访问权限的用户 / 组 / 角色 ID" + help: "获得访问权限的用户 / 组 / 岗位 ID" }, access_level: { label: "访问级别", @@ -135,14 +135,15 @@ export const zhCNObjects: NonNullable = { options: { user: "用户", team: "团队", - department: "部门", - role: "角色", + business_unit: "business_unit", + position: "岗位", + unit_and_subordinates: "unit_and_subordinates", queue: "队列" } }, recipient_id: { label: "接收方", - help: "根据 recipient_type 填写 department id / team id / role name / queue name / user id" + help: "根据 recipient_type 填写 业务单元 id / 团队 id / 岗位名 / 队列名 / 用户 id" }, access_level: { label: "访问级别", diff --git a/packages/spec/src/security/rls.test.ts b/packages/spec/src/security/rls.test.ts index 9dcc3ae79a..8faad4ce71 100644 --- a/packages/spec/src/security/rls.test.ts +++ b/packages/spec/src/security/rls.test.ts @@ -142,7 +142,7 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result.operation).toBe('all'); }); - it('should validate role restrictions', () => { + it('should validate position restrictions', () => { const policy = { name: 'sales_only', object: 'opportunity', @@ -184,7 +184,7 @@ describe('Row-Level Security (RLS) Protocol', () => { id: 'user_123', email: 'john@example.com', tenantId: 'tenant_456', - role: 'sales_rep', + positions: ['sales_rep'], department: 'sales', attributes: { region: 'US-West', @@ -197,24 +197,14 @@ describe('Row-Level Security (RLS) Protocol', () => { expect(result).toEqual(context); }); - it('should validate multiple roles as array', () => { - const context = { - id: 'user_123', - role: ['sales_rep', 'team_lead'], - }; - - const result = RLSUserContextSchema.parse(context); - expect(result.role).toEqual(['sales_rep', 'team_lead']); - }); - - it('should validate single role as string', () => { + it('should validate positions as an array (ADR-0090 D3 — formerly `role`)', () => { const context = { id: 'user_123', - role: 'admin', + positions: ['sales_rep', 'team_lead'], }; const result = RLSUserContextSchema.parse(context); - expect(result.role).toBe('admin'); + expect(result.positions).toEqual(['sales_rep', 'team_lead']); }); it('should validate email format', () => { @@ -327,7 +317,7 @@ describe('Row-Level Security (RLS) Protocol', () => { }); describe('positionPolicy', () => { - it('should create role-based policy', () => { + it('should create a position-scoped policy', () => { const policy = RLS.positionPolicy( 'sensitive_data', ['manager', 'director'], @@ -344,7 +334,7 @@ describe('Row-Level Security (RLS) Protocol', () => { }); describe('allowAllPolicy', () => { - it('should create permissive policy for specified roles', () => { + it('should create permissive policy for specified positions', () => { const policy = RLS.allowAllPolicy('account', ['ceo', 'cfo']); expect(policy.name).toBe('account_ceo_cfo_full_access'); diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 3c069d40ab..3e91183896 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -454,14 +454,12 @@ export const RLSUserContextSchema = lazySchema(() => z.object({ .describe('Tenant/Organization ID'), /** - * User role(s) + * Positions held by the user (ADR-0090 D3 — formerly `role`; matches the + * runtime shape the RLS compiler resolves as `current_user.positions`). */ - role: z.union([ - z.string(), - z.array(z.string()), - ]) + positions: z.array(z.string()) .optional() - .describe('User role(s)'), + .describe('Positions held by the user'), /** * User department diff --git a/packages/spec/src/ui/portal.test.ts b/packages/spec/src/ui/portal.test.ts index 7dd04372fc..375eb170a8 100644 --- a/packages/spec/src/ui/portal.test.ts +++ b/packages/spec/src/ui/portal.test.ts @@ -10,7 +10,7 @@ describe('PortalSchema', () => { id: 'helpdesk_customer', label: 'Help Center', routePrefix: '/portal/helpdesk', - profiles: ['helpdesk_customer_portal'], + positions: ['helpdesk_customer_portal'], navigation: [ { id: 'nav_my_tickets', @@ -33,7 +33,7 @@ describe('PortalSchema', () => { label: 'Help Center', routePrefix: '/portal/helpdesk', authMode: 'magic-link', - profiles: ['helpdesk_customer_portal'], + positions: ['helpdesk_customer_portal'], navigation: [ { id: 'nav_my', type: 'view', label: 'My', viewRef: 'helpdesk_ticket.list.my_tickets' }, ], @@ -65,25 +65,38 @@ describe('PortalSchema', () => { id: 'bad', label: 'bad', routePrefix: 'no-leading-slash', - profiles: ['x'], + positions: ['x'], navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }], }), ).toThrow(); }); - it('rejects an empty profiles array', () => { + it('rejects an empty positions array', () => { expect(() => definePortal({ kind: 'portal', id: 'bad', label: 'bad', routePrefix: '/x', - profiles: [], + positions: [], navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }], }), ).toThrow(); }); + it('rejects the removed `profiles` key with the FROM → TO prescription (ADR-0090 D2)', () => { + expect(() => + definePortal({ + kind: 'portal', + id: 'bad', + label: 'bad', + routePrefix: '/x', + profiles: ['client_portal_user'], + navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }], + } as never), + ).toThrow(/profiles.*removed.*ADR-0090 D2[\s\S]*positions/); + }); + it('accepts SSO and custom plugin layouts', () => { const portal = definePortal({ kind: 'portal', @@ -92,7 +105,7 @@ describe('PortalSchema', () => { routePrefix: '/portal/enterprise', authMode: 'sso:azure-ad', layout: 'custom:my-plugin/dashboard', - profiles: ['enterprise_user'], + positions: ['enterprise_user'], navigation: [ { id: 'home', type: 'dashboard', label: 'Home', dashboardName: 'enterprise_home' }, ], @@ -109,7 +122,7 @@ describe('PortalSchema', () => { label: 'x', routePrefix: '/x', authMode: 'sso:', - profiles: ['x'], + positions: ['x'], navigation: [{ id: 'a', type: 'view', label: 'A', viewRef: 'x.y' }], }), ).toThrow(); diff --git a/packages/spec/src/ui/portal.zod.ts b/packages/spec/src/ui/portal.zod.ts index 2d7ab3d8a7..7551bad12e 100644 --- a/packages/spec/src/ui/portal.zod.ts +++ b/packages/spec/src/ui/portal.zod.ts @@ -7,8 +7,8 @@ * * 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 - * entry surface. + * scoped to a route prefix, a set of admitted positions, and an optional + * anonymous entry surface. * * Five invariants this schema preserves: * 1. Zero business code — layout is an enum + plugin id; theme is tokens; @@ -16,9 +16,10 @@ * 2. Data plane is untouched — portal cannot declare objects, fields, * flows, or permissions. Data API (`/api/v1/data/...`) is unaware of * portals. - * 3. Portal ≠ permission boundary. The Profile is. Portals only narrow - * the UI projection; hiding a view in `navigation` is UX, not security. - * 4. Stackable — the same user/profile can be admitted by multiple + * 3. Portal ≠ permission boundary. The permission model is (permission + * sets distributed via positions, ADR-0090). Portals only narrow the + * 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. * 5. Template-first — a template author ships `customer.portal.ts` and * the platform guarantees the rendering shell. @@ -26,8 +27,8 @@ * Architectural reach (consumer guidance, not part of the schema): * - Dispatcher / HonoServer: at boot, enumerate portals and register * `//*` route families with a per-portal auth scope. - * - Auth middleware: admit the request if `profile ∈ portal.profiles`, - * or it matches `anonymousEntry.routes[*]`. + * - Auth middleware: admit the request if one of the caller's positions + * ∈ `portal.positions`, or it matches `anonymousEntry.routes[*]`. * - objectui LayoutDispatcher: select shell from `layout`. * - objectui NavigationBuilder: render `navigation` (not the all-apps * grid). @@ -247,14 +248,37 @@ export const PortalSchema = lazySchema(() => z.object({ .describe('Authentication mode for the portal.'), /** - * Profiles admitted to the portal. A user is allowed in iff at least - * one of their effective profiles is listed here. **This is a UI gate, - * not the source of truth — the data layer still enforces profile + - * sharing on every API call.** + * Positions admitted to the portal (ADR-0090 — formerly `profiles`; the + * Profile concept was removed by D2). A user is allowed in iff they hold + * at least one of the listed positions. Use the built-in `guest` position + * for anonymous-only portals (D9). **This is a UI gate, not the source of + * truth — the data layer still enforces permission sets + sharing on + * every API call.** */ - profiles: z.array(SnakeCaseIdentifierSchema) - .min(1, 'A portal must admit at least one profile (use a dedicated profile for "anonymous-only" portals).') - .describe('Profiles admitted to the portal.'), + positions: z.array(SnakeCaseIdentifierSchema) + .min(1, "A portal must admit at least one position (use the built-in 'guest' position for anonymous-only portals).") + .describe('Positions admitted to the portal.'), + + /** + * Tombstone for the removed `profiles` key (ADR-0090 D2): reject loudly + * with the FROM → TO prescription instead of silently stripping — a + * silently-dropped admission gate is exactly the class of authoring error + * the vocabulary freeze exists to prevent. + */ + profiles: z + .unknown() + .optional() + .superRefine((v, ctx) => { + if (v !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "'profiles' was removed (ADR-0090 D2 — the Profile concept no longer exists). " + + "Declare `positions` instead: the flat distribution groups admitted to this portal " + + "(use the built-in 'guest' position for anonymous-only portals).", + }); + } + }), /** * Anonymous entry surface — declarative routes that can be hit without