From 519a7691511c28caee670ae056829f4c2800a1e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 12:26:49 +0000 Subject: [PATCH] test(plugin-auth): register sys_position / sys_user_position in the sso-register harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bootEngine()` in `sso-register-platform-admin-gate.test.ts` registered the auth identity objects plus hand-declared `sys_permission_set` and `sys_user_permission_set`, but never the position pair the platform-admin standing resolver also reads. `resolveAuthzContext` reads `sys_user_position` on every resolution, and `sys_position` whenever the principal holds any position — always, since every authenticated member implicitly holds the ADR-0090 D5 `everyone` anchor. With no such tables, both reads were refused by the driver and `tryFind`'s loud-failure arm logged them, while the resolver went on treating each refusal as "this principal holds no positions". The suite reported `Tests 4 passed (4)` over eight `[sql-driver] DATABASE_ERROR ... no such table` lines, four per table, and no assertion could notice. That is a latent false green on a security-adjacent resolver: a change making platform-admin standing genuinely depend on a position row would have been measured against an engine that can never return one, and this file would have stayed green. Same shape #14756 exists to remove — a harness whose registered object set is narrower than the code path it drives. Registers both objects the way the file already hand-declares the permission-set pair, with only the columns the judge reads: `active` (the ADR-0049 predicate `isRowActive` applies in §6a) and `organization_id` (what the driver's `applyTenantScope` filters the organization-scoped `sys_position` read on, and what §4 tests to place a `sys_user_position` row). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../sso-register-platform-admin-gate.test.ts | 68 +++++++++++++++++-- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 705d72f000..ee19287829 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -46,12 +46,33 @@ * * ## Fixture note * - * The RBAC objects (`sys_permission_set`, `sys_user_permission_set`) live in - * `@objectstack/plugin-security`. They are declared locally here — the - * `last-admin-guard.test.ts` precedent — with only the columns the judge reads, - * so a fixture does not add a dependency edge to plugin-auth. Everything else is - * real: a real ObjectQL engine over real better-sqlite3, the real `AuthManager` - * with the real `sso()` plugin, real sign-up and real session cookies. + * The RBAC objects (`sys_permission_set`, `sys_user_permission_set`, + * `sys_position`, `sys_user_position`) live in `@objectstack/plugin-security`. + * They are declared locally here — the `last-admin-guard.test.ts` precedent — + * with only the columns the judge reads, so a fixture does not add a dependency + * edge to plugin-auth. Everything else is real: a real ObjectQL engine over real + * better-sqlite3, the real `AuthManager` with the real `sso()` plugin, real + * sign-up and real session cookies. + * + * ## ⚠️ Why the POSITION pair is registered, when no assertion mentions it + * + * [#14846] `resolveAuthzContext` reads `sys_user_position` on every resolution, + * and `sys_position` whenever the principal holds any position — which is + * always, because every authenticated member implicitly holds the ADR-0090 D5 + * `everyone` anchor. Until those two objects were registered here the tables did + * not exist, so both reads were REFUSED by the driver and `tryFind`'s + * loud-failure arm logged a `[sql-driver] DATABASE_ERROR … no such table` line + * while the resolver carried on treating the refusal as "this principal holds no + * positions". Measured on this file alone: `Tests 4 passed (4)` sitting on top of + * EIGHT refused reads, four per table, and not one assertion able to notice. + * + * That is a latent false green on a security-adjacent resolver, not untidy log + * output: a future change that made platform-admin standing genuinely depend on + * a position row would have been measured against an engine that can never + * return one, and this file would have stayed green. It is the same shape + * #14756 exists to remove — a harness whose registered object set is narrower + * than the code path it drives — kept invisible because the errors are logged + * rather than thrown. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -98,6 +119,39 @@ const sysUserPermissionSet = { }, }; +/** + * [#14846] The ADR-0057 D4 position pair, in the same shape and for the same + * reason as the permission-set pair above — these are read by the resolver this + * file drives, so the harness has to be able to answer them. + * + * `active` is the ADR-0049 predicate the resolver applies to `sys_position` + * (`isRowActive`, §6a). `organization_id` is what the driver's + * `applyTenantScope` filters the organization-scoped `sys_position` read on, and + * what §4 tests to decide whether a `sys_user_position` row is global or belongs + * to another tenant. + */ +const sysPosition = { + name: 'sys_position', + label: 'Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, +}; + +const sysUserPosition = { + name: 'sys_user_position', + label: 'User Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + position: { name: 'position', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, +}; + const engines: ObjectQL[] = []; afterEach(async () => { while (engines.length) { @@ -127,6 +181,8 @@ async function bootEngine(): Promise { } engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-auth'); + engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); + engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); return engine; }