diff --git a/.changeset/federated-phantom-anchor-share-posture.md b/.changeset/federated-phantom-anchor-share-posture.md new file mode 100644 index 0000000000..125676d0c3 --- /dev/null +++ b/.changeset/federated-phantom-anchor-share-posture.md @@ -0,0 +1,49 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): refuse to mint a share row on a federated object whose `owner_id` is the platform's injected anchor (#8119) + +Granting a per-record share on a **federated** (ADR-0015 `external`) object whose +`owner_id` is the anchor the registry injected — rather than a column the author +declared on the remote table — used to succeed and persist a `sys_record_share` +row. That row was inert **by construction**: no read or write verdict can ever +consult it. `POST /api/v1/data/:object/:id/shares` now answers **422 +`SHARING_NOT_ENABLED`**, the same refusal the guard already gives for public and +owner-less objects (ADR-0111 D7, closing the ADR-0078 silently-inert trap). + +**Why the anchor is not a column.** `applySystemFields` injects `owner_id` into +every object that has not opted out, federated ones included, while +`Engine.syncObjectSchema` returns early for `external != null` and issues no DDL +— the remote schema is owned externally. So for a federated object `owner_id` +exists in the registered schema and nowhere else, and the field-existence test +behind the share-posture guard was answering YES about a column that is not +there. + +**Measured, not inferred.** On a booted showcase stack with an unstamped +federated object bound to the remote `customers` table, the single-record +ownership lookup does **not** raise on SQLite. A projection naming the phantom +column is *discarded* and the whole row comes back without it: + +``` +find(obj, { where:{id:'c1'}, fields:['id','name'] }) -> keys [id, name] +find(obj, { where:{id:'c1'}, fields:['id','owner_id'] }) -> keys [id, created_at, + updated_at, name, email, region, lifetime_value] — no `owner_id` key, no error +``` + +So the ownership fast-path reads `owner == null` and both write gates answer +`deny` — silently, for every principal at every write DEPTH including `org`, +because the null-owner branch short-circuits before the scope is consulted. Only +the `modifyAllRecords` bypass reaches `allow`, and it does so without reading a +share row. Refusing the grant therefore costs no live access: the row it declines +to write could never have granted any. + +**What is deliberately unchanged.** `checkEdit` / `checkDelete` still refuse on +these objects. That is fail-closed and safe; widening them to `abstain` would +hand the row to another authority and can turn a refusal into an allow, which is +a decision recorded on #8119 rather than part of this fix. + +**Unaffected:** every local object; a federated object whose author **declared** +a real remote `owner_id` (the test is provenance, not `external`, so its shares +keep working); and the grandfathered `public_read_write` federated objects, which +are still refused as *public* by the check that runs first. diff --git a/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts index ce744d70d8..be1ab9411e 100644 --- a/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts +++ b/packages/plugins/plugin-sharing/src/federated-phantom-owner-scoping.test.ts @@ -56,6 +56,7 @@ import { type EngineUpdateDispatchData, type EngineUpdateDispatchInput, } from '@objectstack/metadata-core'; +import { ERROR_CODE_LEDGER } from '@objectstack/spec/api'; import { SharingService } from './sharing-service.js'; /** The caller: an ordinary member whose read/write DEPTH is narrower than `org`. */ @@ -126,12 +127,56 @@ const GRANDFATHERED_SHOWCASE_SHAPE = federatedSchema({ * it stands for. Imported from `@objectstack/metadata-core`, where the * predicates have lived since #5619 and which this package already depends on * for {@link OWNER_FIELD_DEF}. + * + * [#8119] `tables` is optional and defaults to empty, so every pre-existing + * caller keeps the always-`[]` behaviour it was written against. When rows ARE + * supplied, `find` returns them by `id` **verbatim** — it deliberately does NOT + * apply the `fields` projection `matchesOwnerScope` asks for, because the real + * SQLite driver does not either: measured on a booted stack, a projection naming + * a column the remote table lacks is DISCARDED and the whole row comes back + * (minus the absent column). Storing each row in that measured shape — a + * federated row simply having no `owner_id` key — is what makes the gate cases + * below reproduce the defect instead of a fixture-shaped approximation of it. */ -function makeEngine(schemas: Record) { +function makeEngine( + schemas: Record, + tables: Record>> = {}, +) { + /** + * Match one row against a `where` bag. Handles exactly the two shapes this + * service composes — primitive equality and `{ $in: [...] }` — and THROWS on + * anything else rather than ignoring it. An unrecognised operator silently + * skipped would make the double strictly more permissive than the engine, and + * a fixture that over-matches is how a share lookup "finds" a grant that the + * real store would not have returned. + */ + const matches = (row: Record, where: Record) => + Object.entries(where).every(([key, cond]) => { + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + const ops = Object.keys(cond as object); + if (ops.length !== 1 || ops[0] !== '$in') { + throw new Error(`fake engine: unsupported filter operator(s) ${ops.join(',')} on '${key}'`); + } + const set = (cond as { $in: unknown[] }).$in.map(String); + return set.includes(String(row[key])); + } + return String(row[key]) === String(cond); + }); + return { getSchema: (name: string) => schemas[name], - find: async () => [], - insert: async (_object: string, data: unknown) => data, + find: async (object: string, options?: { where?: Record }) => { + const rows = tables[object] ?? []; + const where = options?.where; + return where === undefined ? rows : rows.filter((r) => matches(r, where)); + }, + // Persists, so a grant made in a case is READABLE by a later gate call in + // the same case — which is what makes "the minted row is LIVE" provable + // rather than merely "grant() resolved". + insert: async (object: string, data: Record) => { + (tables[object] ??= []).push(data); + return data; + }, update: async ( _object: string, data: EngineUpdateDispatchData, @@ -257,3 +302,209 @@ describe('[#7858] ownership scoping vs federated (external) objects', () => { }); }); }); + +/** + * [#8119] The SINGLE-record half — the three `hasOwnerField` consumers #7858's + * ruled scope left alone. Two of them are pinned here as **unchanged**, and the + * third is the only behaviour this card moves. + * + * ## What the measurement established, and why it changes what the fixtures say + * + * The card was filed as a code-path reading and flagged its own SELECT-list + * premise as unverified. Measured on a booted showcase stack (SQLite external + * datasource, an unstamped federated object bound to remote table `customers`), + * the answer is neither branch the card offered: + * + * ``` + * find(measure_ext_nostamp, { where:{id:'c1'}, fields:['id','name'] }) + * -> keys [id, name] (projection honoured) + * find(measure_ext_nostamp, { where:{id:'c1'}, fields:['id','owner_id'] }) + * -> keys [id, created_at, updated_at, name, email, region, lifetime_value] + * hasOwnProperty('owner_id') === false (projection DISCARDED) + * NO throw, in any dialect position tested. + * ``` + * + * So on SQLite the driver does not raise, `writeGateFailClosed` is never + * reached, and nothing is logged: the refusal is produced silently by + * `matchesOwnerScope` reading `owner == null`. That is why the federated rows + * below carry NO `owner_id` key rather than an explicit `null` and rather than + * being absent altogether — a row that is simply missing would prove only that + * the gate refuses unknown records, which it does for every object. + * + * ## Why `checkEdit` / `checkDelete` are pinned but NOT changed + * + * They refuse, which is fail-closed and safe. Widening them to `abstain` hands + * the row to another authority and can turn a refusal into an allow — ruled a + * decision rather than a dispatch on #8119, and deliberately not taken here. + * These cases exist so that decision is made against a measured baseline, and so + * a later change to it cannot land silently. + */ +describe('[#8119] federated phantom anchor and the SINGLE-record gates', () => { + /** The MEASURED federated row shape: present, and with no `owner_id` key. */ + const FEDERATED_ROW = { id: 'c1', name: 'Aurora Labs', email: 'ap@aurora.example', region: 'NA' }; + + let svc: SharingService; + + beforeEach(() => { + svc = new SharingService({ + engine: makeEngine( + { + measure_ext_nostamp: federatedSchema(), + ext_with_real_owner: DECLARED_REAL_OWNER, + local_task: LOCAL_PRIVATE, + showcase_ext_customer: GRANDFATHERED_SHOWCASE_SHAPE, + sys_record_share: { name: 'sys_record_share' }, + }, + { + measure_ext_nostamp: [FEDERATED_ROW], + // The author-declared remote owner column IS real — the row has it. + ext_with_real_owner: [{ id: 'a1', owner_id: MEMBER, name: 'Acme' }], + // `t2` is the same object, a DIFFERENT record: it is what proves a + // grant on `t1` does not widen the whole object. + local_task: [{ id: 't1', owner_id: MEMBER, name: 'My task' }, { id: 't2', owner_id: MEMBER, name: 'Other' }], + showcase_ext_customer: [FEDERATED_ROW], + }, + ), + }); + }); + + describe('unchanged: the write gates still REFUSE (fail-closed, not widened)', () => { + it.each(['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const)( + 'checkEdit denies at __writeScope=%s', + async (scope) => { + // `org` is in the list on purpose: `matchesOwnerScope` short-circuits on + // `owner == null` BEFORE it consults the scope, so even the widest + // non-bypass depth cannot reach `allow`. Measured on the booted stack. + expect( + await svc.checkEdit('measure_ext_nostamp', 'c1', { + userId: MEMBER, + __writeScope: scope, + } as never), + ).toBe('deny'); + }, + ); + + it.each(['own', 'org'] as const)('checkDelete denies at __writeScope=%s', async (scope) => { + expect( + await svc.checkDelete('measure_ext_nostamp', 'c1', { + userId: MEMBER, + __writeScope: scope, + } as never), + ).toBe('deny'); + }); + + it('the two-state projections agree with the verdicts', async () => { + const ctx = { userId: MEMBER, __writeScope: 'own' } as never; + expect(await svc.canEdit('measure_ext_nostamp', 'c1', ctx)).toBe(false); + expect(await svc.canDelete('measure_ext_nostamp', 'c1', ctx)).toBe(false); + }); + + it('ANTI-VACUITY: the same gate ALLOWS on a local object the caller owns', async () => { + // Without this the block above would read identically if the gates denied + // everything — which is exactly the fixture-that-cannot-fail shape. + const ctx = { userId: MEMBER, __writeScope: 'own' } as never; + expect(await svc.checkEdit('local_task', 't1', ctx)).toBe('allow'); + expect(await svc.checkDelete('local_task', 't1', ctx)).toBe('allow'); + // …and DENIES the same row to a different principal. + const other = { userId: 'usr_someone_else', __writeScope: 'own' } as never; + expect(await svc.checkEdit('local_task', 't1', other)).toBe('deny'); + }); + + it('ANTI-VACUITY: a federated object with a DECLARED remote owner column allows', async () => { + // The provenance test's whole point: `external` is not the predicate. + const ctx = { userId: MEMBER, __writeScope: 'own' } as never; + expect(await svc.checkEdit('ext_with_real_owner', 'a1', ctx)).toBe('allow'); + expect(await svc.checkDelete('ext_with_real_owner', 'a1', ctx)).toBe('allow'); + }); + }); + + describe('changed: no share row may be minted on a phantom anchor (ADR-0111 D7)', () => { + it('grant() refuses with SHARING_NOT_ENABLED', async () => { + // Pre-fix this RESOLVED and persisted a `sys_record_share` row — measured + // on the booted stack with a real platform-admin principal. The row was + // inert by construction: no verdict above can ever consult it. + await expect( + svc.grant( + { object: 'measure_ext_nostamp', recordId: 'c1', recipientId: 'usr_grantee' }, + { userId: MEMBER } as never, + ), + ).rejects.toThrow(/SHARING_NOT_ENABLED/); + }); + + it('the refusal names the federated anchor as the reason, not a missing field', async () => { + // The operator-facing half: "this object has no owner_id" would be false + // and would send them to add a column the platform already injected. + await expect( + svc.grant( + { object: 'measure_ext_nostamp', recordId: 'c1', recipientId: 'usr_grantee' }, + { userId: MEMBER } as never, + ), + ).rejects.toThrow(/federated .*injected anchor, not a remote column/s); + }); + + it('the message carries the code as a PREFIX — what REST reads to pick 422', async () => { + // This plugin's declared error idiom is a `CODE: message` prefix, and the + // REST layer picks the status by `msg.startsWith(code)` + // (`rest-server.ts` → `respondSharingError`, ['SHARING_NOT_ENABLED', 422]). + // So the prefix IS the mechanism that produces the status: a refusal that + // merely mentioned the code mid-sentence would fall through to a 500. + // Asserting it here, and the resulting `code` + `status` end-to-end over + // real HTTP in `federated-phantom-share-grant.dogfood.test.ts`. + const err = await svc + .grant( + { object: 'measure_ext_nostamp', recordId: 'c1', recipientId: 'usr_grantee' }, + { userId: MEMBER } as never, + ) + .then(() => null, (e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect(err!.message.startsWith('SHARING_NOT_ENABLED:')).toBe(true); + // …and the code is one this package DECLARES in the ADR-0112 ledger, + // rather than a new spelling invented at the throw site. + expect(ERROR_CODE_LEDGER['@objectstack/plugin-sharing']).toContain('SHARING_NOT_ENABLED'); + }); + + it('what must NOT change: a federated object with a DECLARED owner stays shareable', async () => { + // `hasPhantomOwnerAnchor` is a PROVENANCE test — a real remote owner + // column means the gates can consult a share row, so minting one is live. + await expect( + svc.grant( + { object: 'ext_with_real_owner', recordId: 'a1', recipientId: 'usr_grantee' }, + { userId: MEMBER } as never, + ), + ).resolves.toMatchObject({ object_name: 'ext_with_real_owner', recipient_id: 'usr_grantee' }); + }); + + it('what must NOT change: a LOCAL private object stays shareable, and the row is LIVE', async () => { + await expect( + svc.grant( + { object: 'local_task', recordId: 't1', recipientId: 'usr_grantee', accessLevel: 'edit' }, + { userId: MEMBER } as never, + ), + ).resolves.toMatchObject({ object_name: 'local_task', recipient_id: 'usr_grantee' }); + + // LIVE, not merely persisted — the contrast that gives the federated + // refusal its meaning. `usr_grantee` owns nothing and holds no bypass, so + // the only thing that can lift this verdict is the share row just minted. + expect( + await svc.checkEdit('local_task', 't1', { userId: 'usr_grantee', __writeScope: 'own' } as never), + ).toBe('allow'); + // …and the grant does NOT leak to a different record of the same object. + expect( + await svc.checkEdit('local_task', 't2', { userId: 'usr_grantee', __writeScope: 'own' } as never), + ).toBe('deny'); + }); + + it('what must NOT change: the grandfathered showcase object still refuses as PUBLIC', async () => { + // It is federated AND phantom-anchored, but `public_read_write` is judged + // first — so its message must still be the public one. The regression + // surface: a new branch inserted ABOVE the public check would silently + // re-attribute this shipped object's refusal. + await expect( + svc.grant( + { object: 'showcase_ext_customer', recordId: 'c1', recipientId: 'usr_grantee' }, + { userId: MEMBER } as never, + ), + ).rejects.toThrow(/SHARING_NOT_ENABLED: 'showcase_ext_customer' is not under record-sharing/); + }); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 4c773916ce..69857381d1 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -892,6 +892,45 @@ export class SharingService implements ISharingService { + `(public sharing model or no '${OWNER_FIELD}' field); a share row on it would never be consulted`, ); } + // [#8119] …and an `owner_id` the REGISTRY injected into a FEDERATED object is + // not an owner column either — it is the one case `hasOwnerField` answers YES + // about a column that is not there. The platform provisions no storage for a + // federated object (`Engine.syncObjectSchema` returns early for + // `external != null`), so the ownership fast-path behind `checkEdit` / + // `checkDelete` selects `owner_id` off a remote table that has no such column. + // + // MEASURED on a booted showcase stack (SQLite external datasource, an + // unstamped federated object bound to remote table `customers`): that lookup + // does NOT raise. The driver DISCARDS the whole projection when it names a + // column the remote table lacks and returns the full row, which simply has no + // `owner_id` key — so `matchesOwnerScope` reads `owner == null`, returns + // false, and both gates answer `deny` for every principal at every write + // DEPTH (`org` included: the null-owner short-circuit runs before the scope is + // consulted). Only the `modifyAllRecords` bypass can still reach `allow`, and + // it does so without reading a share row. + // + // A grant here is therefore inert BY CONSTRUCTION — the ADR-0078 + // silently-inert trap this whole guard (ADR-0111 D7) exists to close: "share" + // succeeds and nothing is shared. Measured pre-fix, an admin's grant on such + // an object minted a real `sys_record_share` row that no verdict can ever + // consult. Refusing is the fail-closed direction and costs no live access: + // the row it declines to write could never have granted any. + // + // ⛔ Deliberately NOT paired with a change to `checkEdit` / `checkDelete`. + // Those REFUSE today, which is safe; widening them to `abstain` would hand + // the row to another authority and can turn a refusal into an allow. That is + // a decision, recorded on #8119, not a rider on this guard. + // + // A federated object whose author DECLARED a real remote `owner_id` keeps its + // shares — see `federated-phantom-anchors.ts` for why this is a provenance + // test and not an `external` test. + if (hasPhantomOwnerAnchor(schema)) { + throw new Error( + `SHARING_NOT_ENABLED: '${object}' is federated (ADR-0015) and its '${OWNER_FIELD}' is the ` + + `platform's injected anchor, not a remote column — the record-level gates read it off a ` + + `table that does not have it, so a share row on it would never be consulted`, + ); + } } /** diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts new file mode 100644 index 0000000000..e41e36379a --- /dev/null +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -0,0 +1,357 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8119] The SINGLE-record sharing gates on a FEDERATED object, measured on a + * REAL boot — the measurement the card asked for, and the one refusal it + * authorised. + * + * ## Why this file had to boot something + * + * #8119 was filed as a **code-path reading**, and said so: unlike #7858, whose + * body carried booted-stack filter output, nobody had run a federated + * single-record write. Its central premise — what a driver does with a + * nonexistent column in the SELECT LIST (as opposed to #7858's measured + * comparison-position degradation) — was explicitly unverified and expected to + * be dialect-dependent. The card offered two possibilities: the driver raises + * and `writeGateFailClosed` denies, or the value comes back absent and + * `matchesOwnerScope` returns false. + * + * Measured here, on SQLite, it is **neither cleanly** — the projection itself is + * discarded: + * + * ``` + * find(measure_ext_nostamp, { where:{id:'c1'}, fields:['id','name'] }) + * -> keys [id, name] projection HONOURED + * find(measure_ext_nostamp, { where:{id:'c1'}, fields:['id','owner_id'] }) + * -> keys [id, created_at, updated_at, name, email, region, lifetime_value] + * hasOwnProperty('owner_id') === false projection DISCARDED + * NO throw. + * ``` + * + * So the second branch is what runs, and it runs SILENTLY: `writeGateFailClosed` + * is never reached, nothing is logged, and both gates deny. The consequence the + * card could not know: the refusal is **not depth-dependent**. `matchesOwnerScope` + * short-circuits on `owner == null` BEFORE it consults `__writeScope`, so even an + * `org`-scope caller is refused; only the `modifyAllRecords` bypass reaches + * `allow`, and it does so without reading a share row at all. + * + * ## What this file changes, and what it deliberately does not + * + * `assertSharingEnforced` — and ONLY it. Pre-fix, an admin's `grant()` on such an + * object minted a real `sys_record_share` row (measured; the row is reproduced in + * the case below). That row is inert BY CONSTRUCTION: no verdict can consult it. + * Refusing it is the ADR-0078 silently-inert trap ADR-0111 D7 already closes for + * public and owner-less objects, applied to the one case `hasOwnerField` answers + * YES about a column that is not there. + * + * ⛔ `checkEdit` / `checkDelete` are pinned UNCHANGED. They refuse today, which is + * fail-closed and safe; widening them to `abstain` hands the row to another + * authority and can turn a refusal into an allow. That is a decision recorded on + * #8119, not a rider on this guard. + * + * ## Why the fixture is not the shipped federated object + * + * Both shipped showcase federated objects carry the ADR-0090 D1 grandfather stamp + * (`public_read_write`), which `effectiveSharingModel` collapses to `public` — so + * they return at a gate ABOVE the phantom-anchor line and cannot exercise it at + * all. A fixture built on them would pass against the broken build. The object + * registered here leaves `sharingModel` unset and therefore takes the + * secure-default `private` OWD — what an app author gets by declaring nothing. + * The stamped object is kept as the no-change control instead. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack, { onEnable } from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { resolveAuthzContext } from '@objectstack/core'; +import { + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from '@objectstack/metadata-core'; +import type { IObjectQLEngine, ISharingService } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { ServiceObject } from '@objectstack/spec/data'; + +/** An UNSTAMPED federated object over the showcase's real external SQLite DB. */ +const FEDERATED = 'measure_ext_nostamp'; +/** The shipped, GRANDFATHERED federated object — the no-change control. */ +const STAMPED = 'showcase_ext_customer'; +/** A LOCAL private object — the control whose `owner_id` is real. */ +const LOCAL = 'showcase_private_note'; +/** A row that really exists in the remote `customers` table (fixture seed). */ +const REMOTE_ID = 'c1'; + +const SYS = { isSystem: true } as ExecutionContext; + +/** + * The runtime-registration slice of the engine. `IObjectQLEngine` does not + * declare `registry`, and this is the same narrow structural widening + * `storage-growth.dogfood.test.ts` uses for the identical need — not an erasure. + */ +interface RegistrarEngine { + registry: { registerObject(schema: Record): unknown }; + syncObjectSchema(name: string): Promise; +} + +/** Build the write context the sharing gates read, naming the seam it crosses. */ +function writeCtx(userId: string, scope: string): ExecutionContext { + // `__writeScope` is stamped onto the context by plugin-security's middleware + // and is deliberately NOT a field of `ExecutionContext` (publishing it would + // make a middleware seam authorable). Naming the bypass beats `as any`. + return { userId, __writeScope: scope } as unknown as ExecutionContext; +} + +function keysOfFirst(rows: unknown): string[] { + const first = Array.isArray(rows) ? rows[0] : undefined; + return first && typeof first === 'object' ? Object.keys(first as object) : []; +} + +describe('[#8119] federated phantom anchor: single-record gates + share posture', () => { + let stack: VerifyStack; + let ql: IObjectQLEngine; + let sharing: ISharingService; + let adminCtx: ExecutionContext; + let adminId: string; + let adminToken: string; + let localNoteId: string; + + beforeAll(async () => { + // Provision the "remote" database (the showcase's own fixture provisioner), + // then boot the real app. + await onEnable({ logger: { info() {}, warn() {} } } as never); + stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + ql = stack.kernel.getService('objectql'); + sharing = stack.kernel.getService('sharing'); + + // Registered against the LIVE registry, so `applySystemFields` injects the + // anchors exactly as it would for an authored object — nothing here builds a + // schema by hand. + const registrar = ql as unknown as RegistrarEngine; + registrar.registry.registerObject({ + name: FEDERATED, + label: 'Unstamped federated customer', + datasource: 'showcase_external', + external: { remoteName: 'customers' }, + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, + region: { type: 'text', label: 'Region' }, + }, + }); + await registrar.syncObjectSchema(FEDERATED); + + adminToken = await stack.signIn(); + adminCtx = await resolveAuthzContext({ + ql, + headers: new Headers({ authorization: `Bearer ${adminToken}` }), + getSession: async (h: unknown) => { + const authService = await stack.kernel.getServiceAsync<{ + api?: { getSession?(a: { headers: unknown }): Promise }; + getApi?(): Promise<{ getSession?(a: { headers: unknown }): Promise }>; + }>('auth'); + const api = authService?.api ?? (await authService?.getApi?.()); + return api?.getSession?.({ headers: h }); + }, + } as never) as ExecutionContext; + adminId = String(adminCtx.userId); + expect(adminId, 'a real signed-in admin principal').toBeTruthy(); + + // A LOCAL private record for the live-share control, created through the + // REAL HTTP path as the admin. Deliberately not a system-context `insert`: + // the management gate's record-visibility probe runs under the CALLER's + // context (so ownership scoping and object CRUD both apply to it), and a + // row seeded around that path is invisible to the very caller that must + // manage it — which is a property of the seeding, not of sharing. + const created = await stack.apiAs(adminToken, 'POST', `/data/${LOCAL}`, { + title: '#8119 control note', body: 'live share control', + }); + expect(created.status, 'admin creates the control note').toBeLessThan(300); + const createdBody = await created.json() as Record; + localNoteId = String( + createdBody?.id + ?? (createdBody?.record as Record | undefined)?.id + ?? (createdBody?.data as Record | undefined)?.id + ?? '', + ); + expect(localNoteId, 'control note created').toBeTruthy(); + // The owner anchor is auto-stamped to the creating user (ADR-0056). + const seeded = await ql.find(LOCAL, { + where: { id: localNoteId }, fields: ['id', 'owner_id'], limit: 1, context: SYS, + }); + expect((seeded as Array>)[0]?.owner_id).toBe(adminId); + }, 180_000); + + afterAll(async () => { await stack?.stop?.(); }); + + describe('PREMISE — the state of the tree this fix was written against', () => { + it('the registry injects a phantom `owner_id` into the unstamped federated object', () => { + const schema = ql.getSchema(FEDERATED) as ServiceObject | undefined; + expect(schema?.external, `${FEDERATED} must be federated`).toBeTruthy(); + // No grandfather stamp ⇒ the secure-default `private` OWD (ADR-0090 D1), + // which is what makes the phantom-anchor line reachable at all. + expect((schema as { sharingModel?: unknown } | undefined)?.sharingModel).toBeUndefined(); + expect(Object.keys((schema as { fields: Record }).fields)).toContain('owner_id'); + }); + + it('the platform provisions no storage for it, so the anchor is unprovisioned', () => { + const federated = ql.getSchema(FEDERATED); + const local = ql.getSchema(LOCAL); + expect(platformProvisionsStorage(federated)).toBe(false); + expect(platformProvisionsStorage(local)).toBe(true); + expect(unprovisionedInjectedColumns(federated)).toContain('owner_id'); + // CONTROL: the local object's anchors are real columns. + expect(unprovisionedInjectedColumns(local)).toEqual([]); + }); + + it('#7865 marker agreement: the anchor reads `injected-unprovisioned`', () => { + // Recorded because #8115 landed the marker 38 minutes after #7858 shipped + // this plugin's hand-rolled `hasPhantomOwnerAnchor`. Direction B has + // consumers converge on the marker AS THEY ARE TOUCHED; this case is the + // evidence that converging would be behaviour-preserving here, so the + // decision not to rewrite the shared helper in this card is a measured one + // rather than an assumption. The local control declares its own `owner_id` + // (see `private-note.object.ts`), which is why it reads `author`. + expect(resolveInjectedColumnProvenance(ql.getSchema(FEDERATED), 'owner_id')) + .toBe('injected-unprovisioned'); + expect(resolveInjectedColumnProvenance(ql.getSchema(LOCAL), 'owner_id')).toBe('author'); + }); + }); + + describe('PHASE 1 — what the driver really does with the phantom column', () => { + it('a projection naming only REAL columns is honoured', async () => { + const rows = await ql.find(FEDERATED, { + where: { id: REMOTE_ID }, fields: ['id', 'name'], limit: 1, context: SYS, + }); + expect(keysOfFirst(rows)).toEqual(['id', 'name']); + }); + + it('a projection naming the PHANTOM column is DISCARDED — and does not throw', async () => { + // The card's unverified premise, answered. This is the whole reason the + // refusal below is silent: no error ever reaches `writeGateFailClosed`. + const rows = await ql.find(FEDERATED, { + where: { id: REMOTE_ID }, fields: ['id', 'owner_id'], limit: 1, context: SYS, + }); + const keys = keysOfFirst(rows); + expect(keys.length, 'the whole row comes back, not the 2-column projection') + .toBeGreaterThan(2); + expect(keys).toContain('name'); + expect(keys).not.toContain('owner_id'); + // …and the value the ownership fast-path reads is therefore absent. + const first = (rows as Array>)[0]; + expect(Object.prototype.hasOwnProperty.call(first, 'owner_id')).toBe(false); + }); + + it('the record itself is perfectly readable — it is only the anchor that is not', async () => { + // Anti-vacuity for the case above: "no owner_id" must not be "no row". + const rows = await ql.find(FEDERATED, { where: { id: REMOTE_ID }, limit: 1, context: SYS }); + expect(Array.isArray(rows) && rows.length).toBe(1); + }); + }); + + describe('PHASE 1 — the gates refuse (measured, and pinned UNCHANGED)', () => { + it.each(['own', 'unit', 'unit_and_below', 'org'])( + 'checkEdit denies at __writeScope=%s', + async (scope) => { + expect(await sharing.checkEdit(FEDERATED, REMOTE_ID, writeCtx('usr_measure_member', scope))) + .toBe('deny'); + }, + ); + + it.each(['own', 'org'])('checkDelete denies at __writeScope=%s', async (scope) => { + expect(await sharing.checkDelete(FEDERATED, REMOTE_ID, writeCtx('usr_measure_member', scope))) + .toBe('deny'); + }); + + it('ANTI-VACUITY: the same gates ALLOW on a local record the caller owns', async () => { + // Without this the block above would read identically if the gates denied + // everything — the fixture-that-cannot-fail shape. + const ctx = writeCtx(adminId, 'own'); + expect(await sharing.checkEdit(LOCAL, localNoteId, ctx)).toBe('allow'); + expect(await sharing.checkDelete(LOCAL, localNoteId, ctx)).toBe('allow'); + expect(await sharing.checkEdit(LOCAL, localNoteId, writeCtx('usr_not_the_owner', 'own'))) + .toBe('deny'); + }); + + it('the `modifyAllRecords` bypass is the ONLY route to allow on the federated object', async () => { + // A real platform admin — the bypass answers before ownership is consulted, + // which is why the refusal above is not "sharing is broken here". + expect(await sharing.checkEdit(FEDERATED, REMOTE_ID, adminCtx)).toBe('allow'); + }); + }); + + describe('PHASE 2 — no share row may be minted on a phantom anchor', () => { + it('grant() refuses, and persists nothing', async () => { + // Pre-fix this RESOLVED with a real row: + // { id: 'shr_…', object_name: 'measure_ext_nostamp', record_id: 'c1', + // recipient_id: '', access_level: 'edit', source: 'manual' } + // …which no verdict above can ever consult. + await expect( + sharing.grant( + { object: FEDERATED, recordId: REMOTE_ID, recipientId: adminId, accessLevel: 'edit' }, + adminCtx, + ), + ).rejects.toThrow(/SHARING_NOT_ENABLED/); + + const rows = await ql.find('sys_record_share', { + where: { object_name: FEDERATED }, context: SYS, + }); + expect(Array.isArray(rows) ? rows.length : -1, 'no inert row persisted').toBe(0); + }); + + it('over real HTTP the envelope is code SHARING_NOT_ENABLED + status 422', async () => { + // The ADR-0112 envelope, asserted where both halves are real: the status is + // produced by the REST layer's code→status map, not by the service. + const res = await stack.apiAs( + adminToken, 'POST', `/data/${FEDERATED}/${REMOTE_ID}/shares`, + { recipientId: adminId, accessLevel: 'edit' }, + ); + expect(res.status).toBe(422); + const body = await res.json() as { code?: string; error?: string }; + expect(body.code).toBe('SHARING_NOT_ENABLED'); + // The operator-facing half: "no owner_id field" would be false here and + // would send them to add a column the platform already injected. + expect(body.error).toMatch(/federated/); + }); + + it('CONTROL: a LOCAL private record is NOT refused by the posture guard', async () => { + // The contrast that gives the 422 above its meaning: on the SAME route, + // with the same admin, a local private object gets PAST + // `assertSharingEnforced` — the guard this card changes — and is judged by + // the next gate instead. + // + // What that next gate answers here is a property of THIS BOOT, not of + // sharing: the management pre-flight's record-visibility probe runs under + // the caller's own context, and under the isolated posture this stack boots + // with, the admin resolves no active organization while the seeded row + // carries `organization_id: null` — so Layer 0 walls the row away from its + // own creator and the pre-flight answers 404. The assertion is therefore + // written against the thing under test: NOT 422, and NOT + // SHARING_NOT_ENABLED. Anything else would be pinning an unrelated tenancy + // artefact as if it were this card's behaviour. + // + // The positive half — a grant on a local object mints a row the gates then + // read back as `allow` — is proven deterministically in + // `plugin-sharing/src/federated-phantom-owner-scoping.test.ts`, where no + // tenancy layer sits between the grant and the verdict. + const res = await stack.apiAs( + adminToken, 'POST', `/data/${LOCAL}/${localNoteId}/shares`, + { recipientId: 'usr_grantee_8119', accessLevel: 'edit' }, + ); + expect(res.status).not.toBe(422); + expect((await res.json() as { code?: string }).code).not.toBe('SHARING_NOT_ENABLED'); + }); + + it('CONTROL: the grandfathered shipped object still refuses as PUBLIC, unchanged', async () => { + // It is federated AND phantom-anchored, but `public_read_write` is judged + // first. A new branch inserted above the public check would silently + // re-attribute this shipped object's refusal. + await expect( + sharing.grant( + { object: STAMPED, recordId: REMOTE_ID, recipientId: adminId, accessLevel: 'edit' }, + adminCtx, + ), + ).rejects.toThrow(/SHARING_NOT_ENABLED: '.*' is not under record-sharing enforcement/); + }); + }); +});