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
19 changes: 19 additions & 0 deletions .changeset/27425-phantom-owner-anchor-fails-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-sharing': patch
'@objectstack/lint': patch
---

fix(plugin-sharing)!: on a federated object whose `owner_id` is the platform's injected anchor, the read and bulk-write scopes narrow below `org` instead of going unscoped (#27425)

Clause-②: no (narrowing) — `SharingService.buildReadFilter` and `buildWriteFilter` answer a deny-all scope, where they answered `null` (no scope), for a caller below `org` depth on an ADR-0015 federated object that neither declares `owner_id` in `fields` nor maps a remote column onto it; on `private` such an object's refused edit answers 404 where it answered 403. Nothing is widened and no key is added, removed or renamed.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the record-sharing scope of one class of federated object fails closed where it went unscoped. The remedy is a metadata choice the author already has (declare the owner column, or choose the sharing model on purpose), so there is nothing for a migration to rewrite and no tombstone. -->

**BREAKING** read-scope narrowing in record sharing (`@objectstack/plugin-sharing`).

- **Before.** On a federated object (`external` binding) with an owner-keyed sharing model, the sharing service decided whether `owner_id` was real from the declared `fields` alone. When the author had not declared it, the registry's injected anchor was taken to mean "no owner column", and the read filter applied no scope. A driver can still serve `owner_id` (the SQL driver through `external.columnMap`, a remote column of that name, a plugin driver on its own), so a `private` object answered its rows to members it was meant to hide them from. Bulk updates and deletes went unscoped the same way, while single-record writes were refused with 403.
- **Now.** The owner column counts as proven when `fields` declares `owner_id` or an `external.columnMap` entry maps a remote column onto it; such an object keeps ordinary owner scoping (a driver that does not apply the map serves no `owner_id`, so the owner predicate admits nothing there). Otherwise an unproven owner narrows the scope: below `org` read depth the read filter admits no row by ownership, no owner predicate and no share rows (a grant on such an object is already refused as inert), and a pending approval step still opens the record it waits on. The bulk write filter admits no row below `org` write depth. Because an edit reads its record through the same scope first, a member's PATCH on a `private` object of this shape now answers 404 `RECORD_NOT_FOUND` where it answered 403 `PERMISSION_DENIED`; on `public_read` it stays 403. `org` depth, the Modify All bypass, system callers and local objects are unchanged.
- **Who sees it.** An app with a federated object on `private` (including no `sharingModel`, which the runtime treats as `private`), or `public_read` for bulk writes, that neither declares nor maps `owner_id`: members below `org` depth now read no row there, and their refused edits answer 404. An external principal on a `public_read` object meets the same closed scope. `os validate`'s `security-external-owner-unmapped` warning (`@objectstack/lint`) now names the 404 on `private`, and its explanation says reads are refused too.

**The remedy.** Choose on purpose. Declare `owner_id` in `fields` and have the driver return the remote owner under that name (a built-in SQL driver through `external.columnMap`; a plugin driver must return it itself), and the object scopes to the owner. Or choose `sharingModel: 'public_read'` (everyone reads) or `'public_read_write'` (everyone reads and edits, a `beforeUpdate` hook decides who may).
5 changes: 5 additions & 0 deletions content/docs/data-modeling/external-datasources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export const Customer = ObjectSchema.create({
That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
`customers` table; filters (`?region=EU`) push down to the remote query.

`private` keys reads to the row's owner, and only an `owner_id` declared in `fields`,
or named by an `external.columnMap` entry, counts as one. Without it a member reads
no row, while a holder of View All (`viewAllRecords`) still reads them all. For a
table everyone may read, choose `sharingModel: 'public_read'`.

The object's **own** `datasource` must name a datasource declared
`schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on
`'default'` (also the value when `datasource` is omitted) is refused by `ObjectSchema.create()`,
Expand Down
10 changes: 6 additions & 4 deletions content/docs/data-modeling/virtual-objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -372,10 +372,12 @@ What each piece does on a plugin driver:
`PATCH /data/berth_slot/:id` above.

<Callout type="warn" title="Choose the sharing model on purpose">
On the default sharing model, **every member edit is refused** (`403`), even for a
member whose permission set grants `allowEdit`. Before a write, the platform's ownership
checks ask the remote for `owner_id` and for `created_by` equal to the caller — columns a
remote row does not carry — so every row reads as someone else's
On the default sharing model, **every member edit is refused**, even for a member whose
permission set grants `allowEdit`. That model keys reads to the row's `owner_id`, a column
a remote row does not carry, so the member's read scope admits no row: lists come back
empty and an edit answers `404`. On `public_read` the rows are readable, but before a write
the platform's ownership checks ask the remote for `owner_id` and for `created_by` equal to
the caller, find neither, and refuse the edit with `403`
([#27332](https://github.com/objectstack-ai/objectstack/issues/27332)). Declare
`sharingModel: 'public_read_write'` and decide who may edit in `beforeUpdate`, as above.
Serving an owner column the checks can read is the other way out; the reference test
Expand Down
8 changes: 5 additions & 3 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,8 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = {
'the remote database owns the table and the platform creates no column for either. So the remote row ' +
'answers no `owner_id` (record sharing refuses) and no `created_by` equal to the caller (the floor ' +
'refuses). A member whose permission set grants `allowEdit` is refused every edit and every delete ' +
'with 403 `PERMISSION_DENIED`, unless an app-authored row-level policy admits the row; otherwise only ' +
'with 403 `PERMISSION_DENIED` (404 on `private`, where reads are scoped by the same owner and no row is ' +
'in reach), unless an app-authored row-level policy admits the row; otherwise only ' +
'an administrator can write. An object with no `sharingModel` is ' +
'judged as \'private\', the value the runtime falls back to.',
'`ownership: \'none\'` (or `\'org\'`, `\'business_unit\'`) is not a way out: without `owner_id` record ' +
Expand All @@ -756,8 +757,9 @@ const SECURITY_EXTERNAL_OWNER_UNMAPPED_EXPLANATION: RuleExplanation = {
'that name, so the owner edits and deletes the row and other members are refused. A built-in SQL ' +
'driver does that through `external.columnMap` (remote column to `owner_id`); `columnMap` is applied ' +
'by those drivers, not by the engine, so a plugin driver must return the field itself. Declaring ' +
'the field matters for reads too: the platform\'s own `owner_id` is treated as unstored, and reads ' +
'are not scoped by owner. On an object without `owner_id`, declare `created_by` the same way instead, ' +
'the field matters for reads too: the platform\'s own `owner_id`, with no field or `columnMap` entry ' +
'naming it, proves no owner, so on `private` a member reads no row at all. On an object without ' +
'`owner_id`, declare `created_by` the same way instead, ' +
'so the creator writes the row.',
'A `columnMap` entry for the column without a declared field is reported too, with its own wording: ' +
'it serves the check on a built-in SQL driver and does nothing on a plugin driver, and the ' +
Expand Down
11 changes: 10 additions & 1 deletion packages/lint/src/validate-external-owner-sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
/**
* The two external objects of `packages/runtime/src/plugin-driver-external-object.spike.test.ts`,
* copied verbatim (fields trimmed to what the rule reads). That spike measures the runtime half:
* on `spike_owned` (no `sharingModel`) a member holding `allowEdit` gets 403 on every PATCH,
* on `spike_owned` (no `sharingModel`) a member holding `allowEdit` is refused every PATCH (404
* since #27425: the read scope admits no row),
* step (e3); on `spike_node` (`public_read_write`) the same member's write reaches the driver.
*/
const SPIKE_STACK = defineStack({
Expand Down Expand Up @@ -135,6 +136,14 @@ describe('security-external-owner-unmapped — which objects it judges', () => {
expect(findings[0]!.message).toContain('columnMap');
});

it('[#27425] names the refusal the model produces: 404 on private (reads are scoped too), 403 on public_read', () => {
for (const obj of [ext(), ext({ sharingModel: 'private' }), ext({ sharingModel: 'private' }, { columnMap: { assignee: 'owner_id' } })]) {
expect(findingsFor(obj)[0]!.message).toContain("refused (404: the member's read scope admits no row)");
}
expect(findingsFor(ext({ sharingModel: 'public_read' }))[0]!.message).toContain('refused (403)');
expect(findingsFor(ext({ sharingModel: 'private', ownership: 'none' }))[0]!.message).toContain('refused (403)');
});

it('a columnMap that maps only created_by does not serve the owner check', () => {
const findings = findingsFor(ext({ sharingModel: 'private' }, { columnMap: { author: 'created_by' } }));
expect(findings).toHaveLength(1);
Expand Down
11 changes: 9 additions & 2 deletions packages/lint/src/validate-external-owner-sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin
: 'with no owner_id, the ownership floor reads created_by == the caller';
const mapped = columnMapTargets(external as AnyRec, column);
const role = column === OWNER_COLUMN ? 'owner' : 'creator';
// [#27425] On `private`, record sharing also scopes READS by owner_id, and
// an owner it cannot read admits no row: the edit's pre-image read finds
// none, so the refusal is a 404 rather than the write check's 403.
const refusal =
column === OWNER_COLUMN && effectiveSharingModelOf(obj) === 'private'
? "(404: the member's read scope admits no row)"
: '(403)';
const declareWayOut =
`declare ${column} in fields and have the driver return the remote ${role} under that name ` +
'(a built-in SQL driver does it through external.columnMap; a plugin driver must return it itself)';
Expand All @@ -178,10 +185,10 @@ export function validateExternalOwnerSharing(stack: AnyRec): ExternalOwnerSharin
message: mapped
? `external object on sharing model ${model} maps a remote column to '${column}' through ` +
`external.columnMap but declares no '${column}' field; ${check} before every edit and delete, ` +
'and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused (403) ' +
`and only a built-in SQL driver applies columnMap, so on a plugin driver every member write is refused ${refusal} ` +
'unless an app-authored row-level policy admits the row'
: `external object on sharing model ${model} declares no '${column}' field a remote row serves; ` +
`${check} before every edit and delete, so every member write is refused (403) unless an app-authored ` +
`${check} before every edit and delete, so every member write is refused ${refusal} unless an app-authored ` +
'row-level policy admits the row',
hint: mapped
? `Declare ${column} in fields (a plugin driver must return the remote ${role} under that name itself), ` +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27425] The SQL-driver half of the pin, over `private` federated objects
* whose remote rows carry an owner.
*
* - The remote column is literally `owner_id` and `fields` do not declare it:
* the driver serves it, but the registered field is the platform's injected
* anchor and nothing declared says the column exists. Before the fix that
* read as "ownership contributes nothing" (`null`), so a member read every
* row; now the scope narrows to none.
* - `external.columnMap` maps the remote owner column onto `owner_id`: the map
* is declared metadata, the SQL driver applies it, and the member reads own
* rows only, as with a declared field.
* - `owner_id` declared in `fields`: the control, own rows only.
*
* Real `ObjectQL`, real `SqlDriver` on `better-sqlite3`, the real sharing
* middleware: the registry's injection, the driver's remap and the composed
* filter are each the shipped code, not a fixture of it.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';

import { SharingService } from './sharing-service.js';
import { buildSharingMiddleware } from './sharing-plugin.js';
import { hasPhantomOwnerAnchor } from './federated-phantom-anchors.js';

const MEMBER = 'u_member';
const ADMIN = 'u_admin';

/** The remote table: its owner column is called `assignee`. */
const REMOTE = {
name: 'remote_rows',
fields: {
id: { type: 'text', name: 'id', label: 'Id', primary: true },
name: { type: 'text', name: 'name', label: 'Name' },
assignee: { type: 'text', name: 'assignee', label: 'Assignee' },
},
};

const SHARE_FIELDS: Record<string, Record<string, unknown>> = {
id: { type: 'text', name: 'id', label: 'Id', primary: true },
object_name: { type: 'text', name: 'object_name', label: 'Object' },
record_id: { type: 'text', name: 'record_id', label: 'Record' },
recipient_type: { type: 'text', name: 'recipient_type', label: 'Recipient type' },
recipient_id: { type: 'text', name: 'recipient_id', label: 'Recipient' },
access_level: { type: 'text', name: 'access_level', label: 'Access' },
};

/** A second remote table whose owner column is literally `owner_id`. */
const REMOTE_OWNED = {
name: 'remote_owned',
fields: {
id: { type: 'text', name: 'id', label: 'Id', primary: true },
name: { type: 'text', name: 'name', label: 'Name' },
owner_id: { type: 'text', name: 'owner_id', label: 'Owner' },
},
};

const binding = { remoteName: REMOTE.name, columnMap: { assignee: 'owner_id' } };

/** Served but undeclared: no map, no field, the registry injects its anchor. */
const SERVED_UNDECLARED = {
name: 'ext_owned',
label: 'External owned rows',
sharingModel: 'private',
external: { remoteName: REMOTE_OWNED.name },
fields: { name: { type: 'text', label: 'Name' } },
};

/** `owner_id` undeclared, but the columnMap names the remote owner column. */
const MAPPED = {
name: 'ext_rows',
label: 'External rows',
sharingModel: 'private',
external: binding,
fields: { name: { type: 'text', label: 'Name' } },
};

/** The control: `owner_id` declared, the author vouches for the column. */
const DECLARED = {
name: 'ext_rows_declared',
label: 'External rows (declared owner)',
sharingModel: 'private',
external: binding,
fields: {
name: { type: 'text', label: 'Name' },
owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner' },
},
};

describe('[#27425] a private SQL federated object whose remote rows carry an owner', () => {
let driver: SqlDriver;
let ql: ObjectQL;

beforeAll(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
ql = new ObjectQL();
ql.registerDriver(driver as never, true);
await ql.init();

await driver.initObjects([
REMOTE as never,
REMOTE_OWNED as never,
{ name: 'sys_record_share', fields: SHARE_FIELDS } as never,
]);
await driver.create(REMOTE.name, { id: 'r_mine', name: 'Mine', assignee: MEMBER });
await driver.create(REMOTE.name, { id: 'r_theirs', name: 'Theirs', assignee: ADMIN });
await driver.create(REMOTE_OWNED.name, { id: 'r_mine', name: 'Mine', owner_id: MEMBER });
await driver.create(REMOTE_OWNED.name, { id: 'r_theirs', name: 'Theirs', owner_id: ADMIN });

ql.registerObject({ name: 'sys_record_share', label: 'Record share', isSystem: true, fields: SHARE_FIELDS } as never);
for (const object of [SERVED_UNDECLARED, MAPPED, DECLARED]) {
ql.registerObject(object as never);
// What the schema sync does for a federated object: bind it, no DDL.
driver.registerExternalObject(ql.getSchema(object.name) as never);
}
ql.registerMiddleware(buildSharingMiddleware(new SharingService({ engine: ql as never })) as never, { object: '*' });
});

afterAll(async () => {
await driver?.disconnect?.();
});

const memberReads = async (object: string): Promise<string[]> => {
const rows = (await ql.find(object, { context: { userId: MEMBER } } as never)) as Array<{ id: string }>;
return rows.map((r) => r.id).sort();
};

it('PREMISE: the driver serves owner_id on all three, and only the unmapped, undeclared one is the injected anchor', async () => {
for (const object of [SERVED_UNDECLARED.name, MAPPED.name, DECLARED.name]) {
const served = (await driver.find(object, {} as never)) as Array<Record<string, unknown>>;
expect(served.map((r) => [r.id, r.owner_id]).sort()).toEqual([
['r_mine', MEMBER],
['r_theirs', ADMIN],
]);
}
expect(hasPhantomOwnerAnchor(ql.getSchema(SERVED_UNDECLARED.name))).toBe(true);
expect(hasPhantomOwnerAnchor(ql.getSchema(MAPPED.name))).toBe(false);
expect(hasPhantomOwnerAnchor(ql.getSchema(DECLARED.name))).toBe(false);
});

it('owner_id served but neither declared nor mapped: the member reads no row (it read 2 of 2 before)', async () => {
expect(await memberReads(SERVED_UNDECLARED.name)).toEqual([]);
});

it('columnMap → owner_id with no declared field: own rows only (it read 2 of 2 before)', async () => {
expect(await memberReads(MAPPED.name)).toEqual(['r_mine']);
});

it('ANTI-VACUITY: owner_id declared over the same table reads own-only', async () => {
expect(await memberReads(DECLARED.name)).toEqual(['r_mine']);
});
});
Loading
Loading