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
49 changes: 49 additions & 0 deletions .changeset/federated-phantom-anchor-share-posture.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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<string, unknown>) {
function makeEngine(
schemas: Record<string, unknown>,
tables: Record<string, Array<Record<string, unknown>>> = {},
) {
/**
* 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<string, unknown>, where: Record<string, unknown>) =>
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<string, unknown> }) => {
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<string, unknown>) => {
(tables[object] ??= []).push(data);
return data;
},
update: async (
_object: string,
data: EngineUpdateDispatchData,
Expand Down Expand Up @@ -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/);
});
});
});
39 changes: 39 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
);
}
}

/**
Expand Down
Loading
Loading