Skip to content

Commit 9847f87

Browse files
fix(plugin-sharing): a minted share link carries its organization, never NULL (ADR-0131 D9, C8 stage 8b-0h) (#24461)
Part of #15212 Clause-②: no C8 stage 8b-0h (claim 6259036738, pointer 6258557956, plan amendment 11 6259028550). `ShareLinkService.createLink` stamps `sys_share_link.organization_id`, so a minted link is never a NULL row. #15212 remains open; stage 8b and the rest of C8 are not in this PR. ## What changed `packages/plugins/plugin-sharing/src/share-link-service.ts`: a new private `resolveMintOrganization`. It runs after the authority and eligibility checks and before the insert. In order: 1. **The linked record's organization.** One read under the system context: `fields: ['id', TENANT_FIELD]`, with the tenant field from `resolveTenantFieldName`. It takes the system context so that field-level masking cannot hide the column. The record's organization wins over the session's. 2. **The acting context's organization** (`tenantId`), when the record names none: an object with no tenant column, or a row with no organization. 3. **The ADR-0131 D9 predicate** (`resolveSystemWriteOrganization`, from `@objectstack/core` through `@objectstack/objectql`'s re-export of the same binding). Under `single` with exactly one organization, it derives the Default Organization. Otherwise it refuses with the engine's own `SystemWriteOrganizationRequiredError` (`ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, status 500). That covers a walled posture, and a `single` install with none or several organizations. Nothing is written. The stamp rides both the row and the write context (`{ ...SYSTEM_CTX, tenantId }`), the shape `SharingService.grant` uses for `sys_record_share`. The mint response is still the contract's `ShareLink` shape, which declares no organization column. Nothing is stamped when `sys_share_link` has no tenant column, or when the composition registers no `sys_organization`; the probe answers `[]` without a read, like the engine's own probe. The posture is the **requested** one (`resolveTenancyPosture()`). `resolveAuthzContext`'s platform-anchor read and the `tenancy` service's `defaultOrgId()` read the same source in the same fail-stricter direction: a deployment that asked for a wall stays walled here even when it runs degraded. ## Why the service decides (not the engine) `sys_share_link` is `unclassified` in the #13491 ledger, so `Engine.resolveSystemInsertOrganization` returns early for it (`isPlatformObjectOutOfTenantAuditScope`, `engine.ts` about `:7183`), and today the NULL insert goes through. The service raises the engine's own refusal envelope, so a walled mint gets the same answer before and after 8b retires that exemption. This PR does not touch the engine or the ledger. ## Measured (dispatch assumptions) - **A1 (one writer): holds.** On `538f923cb`, `git grep` for `insert|create|upsert('sys_share_link'` finds one site: `share-link-service.ts:665`. `revokeLink` and the usage stamp only update existing rows. - **A2 (record read before insert): falsified.** The existing read is `fields: ['id']` unless an eligibility predicate is declared. It runs under the caller's context, where field-level masking applies. So the stage adds the narrowest system read, `['id', TENANT_FIELD]`. A failing read propagates, so the mint fails and nothing is written. The stage never stamps the session's organization in place of an unknown record organization. - **A3 (cross-organization link): possible under `group`.** Layer 0 is a membership union there, so a member active in A can mint on a visible record in B. The `isolated` wall is equality, so both are the same. The owner and Modify-All alternative is withheld under a wall. A system caller reads under the system context. In every case the record's organization is stamped (pin 1). - **A5 / Clause-②.** No in-repo caller reaches the new refusal. The only `createLink` callers are the two HTTP doors (`share-link-routes.ts:362`, `runtime/src/domains/share-links.ts:433`). Both require a `userId`. Under a wall, an organization-less API key is refused at admission (`share-link-tenancy-posture-admission.test.ts`). An organization-less session fails Layer 0 on any tenant-columned record, so the visibility check refuses first. No in-repo object pairs `publicSharing` with `tenancy.enabled: false`; the only `publicSharing` object is showcase's `client-brief`, which is tenant-columned. The refusal is reachable only by an external system caller that threads no `tenantId`, or by a third-party organization-less object. So the claim's `Clause-②: no` is kept as written. - **A6 (size):** +281 / -1 over 3 files (the changeset, the service and a new 135-line pin), inside the 0.1–0.3k estimate. ## Tests - New `share-link-organization-stamp.test.ts`: a real `ObjectQL` and `SqlDriver` (better-sqlite3 `:memory:`), the real `SysShareLink` object, and rows read back off the driver. The posture is stubbed through `OS_TENANCY_POSTURE`. - (0) The registered `sys_share_link` carries the tenant column. - (1) Walled: the row gets the record's organization, even when the caller acts in another. - (2) Walled: a record with no organization gets the caller's. - (3) `single`: the Default Organization, and the response has no `organization_id`. - (4) Walled with neither: the refusal envelope (`code` and `status`), and zero rows. - **Ablations**, through `scripts/ablation-replace.mjs`, on commit `36706e5e`. Every anchor hit 1 → 0, and every restore showed blob == HEAD with an empty `git diff HEAD`. The test imports `./share-link-service.js` from `src`, so no build or dist step is involved. - Drop the record-organization return: only pin 1 goes red (1 failed / 4 passed). - Drop the session fallback: only pin 2 goes red. - Drop the `derived` return: only pin 3 goes red. - Turn the refusal into `return undefined`: only pin 4 goes red. - `pnpm --filter @objectstack/plugin-sharing test`: 49 files, 1144 tests pass. `typecheck`: exit 0. - `pnpm --filter @objectstack/runtime exec vitest run src/domains/share-links`: 6 files, 202 tests pass. These suites construct `ShareLinkService`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 67 commands, plus the 53 artifact-roster families printed on STDERR (118 unique). All of them were run, and the readings are in the dev report on #15212. The first run found two real problems, both fixed in `a117f81c`: - `check-tenant-audit-census`: a conditional insert context counted as "elevation undecidable". It is now `{ ...SYSTEM_CTX, tenantId }`. - `check:query-options-erasure`: 5 → 7 because of two `as any` casts on find options. Both casts are dropped. The rest exit non-zero only because they cannot measure locally. Four need PR context (`closing-target-claim`, `partof-closing-keyword`, `single-claim-paths`, `governed-queue-guard`). One needs an unshallowed clone (`check-plugin-teardown-shape --self-test`). Three need a full workspace build (`check:i18n`, `check:dual-build-cjs-loads`, `check:published-readme-exports`). All of these are NOT MEASURED locally and are left to CI. ## Acceptance notes - The `sys_share_link` row in `platform-object-tenancy.ts` is still `unclassified`. With the writer now stamping, it is ready for 8b, which owns the engine-side change; that is not done here. - The `sys_share_link` row in `organization-ownership-inventory.ts` (`fate: 'attribution'`, subject anchor `object_name` / `record_id`) already matches this stamp. No change is needed. - `sharing-plugin.ts` is not touched. The stage needs no new wiring, because the posture is the requested one and the Default Organization comes from the same D9 probe the engine uses. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ff48407 commit 9847f87

3 files changed

Lines changed: 281 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): a minted share link carries an organization, never a NULL one (ADR-0131 D9)
6+
7+
Clause-②: no
8+
9+
- **What changed.** `ShareLinkService.createLink` stamps `sys_share_link.organization_id`. It takes the organization of the linked record, read under the system context. When the record names none, it takes the acting context's organization (`tenantId`). When neither names one, it applies the system-write rule: under `single`, the Default Organization (the only one there is); under a wall (`group` or `isolated`), the mint is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500) and no link is written. The same refusal answers a `single` install that holds no organization or several. Before, every link was written with `organization_id` NULL.
10+
- **A link on another organization's record.** Under `group`, a member active in one organization can mint on a visible record of another. The link carries the record's organization, not the session's.
11+
- **What stays the same.** Under `single` nothing a user sees changes. The mint response keeps the `ShareLink` shape and carries no organization field. A composition that registers no `sys_organization` object writes links as before.
12+
- **Who can meet the refusal.** No in-repo caller reaches it. Both share-link routes need a signed-in caller. Under a wall, an organization-less API key is refused at admission, and an organization-less session cannot see a record that has a tenant column, so its mint is refused before the stamp. The refusal is reached by a walled, organization-less caller minting on an object with no tenant column, or by a system caller that threads no `tenantId`. The fix is to pass the organization on the context (`{ isSystem: true, tenantId }`).
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D9, C8 stage 8b-0h] Every minted `sys_share_link` row carries
5+
* `organization_id` — on a REAL engine over a REAL driver, with the REAL
6+
* `sys_share_link` object, so the column under test is the one the registry
7+
* injects and the row is read back off the driver, never off the response.
8+
*
9+
* ## Why the service decides, not the engine
10+
*
11+
* `sys_share_link` is `unclassified` in the #13491 ledger, so
12+
* `Engine.resolveSystemInsertOrganization` lets an organization-less system
13+
* insert on it through today. The mint used to rely on that and landed NULL on
14+
* every link. The organization is derivable at the mint — the linked record
15+
* and the caller are both in hand — so the service stamps it, and refuses
16+
* loudly where nothing names one, before and after that exemption retires.
17+
*
18+
* ## The four pins
19+
*
20+
* 1. walled: the row carries the RECORD's organization — even when the caller
21+
* acts in another one (the link exposes the record, so it belongs where
22+
* the record does);
23+
* 2. walled: an organization-less record takes the caller's organization;
24+
* 3. `single`: neither names one — the Default Organization (the only one);
25+
* 4. walled: neither names one — refused with the engine's ADR-0112 envelope,
26+
* and no row is written.
27+
*
28+
* The posture is the REQUESTED one, read from `OS_TENANCY_POSTURE`, so each
29+
* case stubs the environment rather than injecting an engine provider.
30+
*/
31+
32+
import { describe, it, expect, afterEach, vi } from 'vitest';
33+
import { ObjectQL, SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE } from '@objectstack/objectql';
34+
import { SqlDriver } from '@objectstack/driver-sql';
35+
import type { ExecutionContext } from '@objectstack/spec/kernel';
36+
37+
import { ShareLinkService } from './share-link-service.js';
38+
import { SysShareLink } from './objects/index.js';
39+
40+
const OBJECT = 'os15212_brief';
41+
const ORG_A = 'org_a';
42+
const ORG_B = 'org_b';
43+
const DEFAULT_ORG = 'org_default';
44+
45+
const BRIEF_FIELDS: Record<string, Record<string, unknown>> = {
46+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
47+
name: { type: 'text', name: 'name', label: 'Name' },
48+
organization_id: { type: 'text', name: 'organization_id', label: 'Org' },
49+
};
50+
51+
const ORG_FIELDS: Record<string, Record<string, unknown>> = {
52+
id: { type: 'text', name: 'id', label: 'Id', primary: true },
53+
name: { type: 'text', name: 'name', label: 'Name' },
54+
};
55+
56+
const open: SqlDriver[] = [];
57+
58+
afterEach(async () => {
59+
vi.unstubAllEnvs();
60+
while (open.length) await open.pop()?.disconnect?.();
61+
});
62+
63+
/**
64+
* brief_a ORG_A
65+
* brief_p (null) <- organization-less; what pins 2-4 mint on
66+
*/
67+
async function boot(posture: 'single' | 'isolated', organizations: readonly string[]) {
68+
vi.stubEnv('OS_TENANCY_POSTURE', posture);
69+
const driver = new SqlDriver({
70+
client: 'better-sqlite3',
71+
connection: { filename: ':memory:' },
72+
useNullAsDefault: true,
73+
});
74+
open.push(driver);
75+
76+
const ql = new ObjectQL();
77+
ql.registerDriver(driver as never, true);
78+
await ql.init();
79+
ql.registerObject({ name: OBJECT, label: 'Brief', fields: BRIEF_FIELDS } as never);
80+
ql.registerObject(SysShareLink as never);
81+
ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, fields: ORG_FIELDS } as never);
82+
await driver.initObjects(
83+
[OBJECT, 'sys_share_link', 'sys_organization'].map((name) => ({ name, fields: (ql.getSchema(name) as any).fields }) as never),
84+
);
85+
for (const id of organizations) await driver.create('sys_organization', { id, name: id } as never);
86+
await driver.create(OBJECT, { id: 'brief_a', name: 'A', organization_id: ORG_A } as never);
87+
await driver.create(OBJECT, { id: 'brief_p', name: 'P' } as never);
88+
89+
const service = new ShareLinkService({ engine: ql as never, permissive: true, logger: { warn: () => {} } });
90+
const links = async () =>
91+
(await driver.find('sys_share_link', {} as never)).map((r: any) => ({
92+
record_id: String(r.record_id),
93+
organization_id: r.organization_id ?? null,
94+
}));
95+
return { ql, service, links };
96+
}
97+
98+
const caller = (tenantId?: string) =>
99+
({ userId: 'u_1', positions: [], permissions: [], ...(tenantId ? { tenantId } : {}) }) as unknown as ExecutionContext;
100+
101+
describe('[ADR-0131 D9] a minted share link carries an organization', () => {
102+
it("the real sys_share_link object carries the tenant column the stamp writes", async () => {
103+
const { ql } = await boot('isolated', [ORG_A, ORG_B]);
104+
expect(Object.keys((ql.getSchema('sys_share_link') as any).fields)).toContain('organization_id');
105+
});
106+
107+
it("walled: the RECORD's organization, even when the caller acts in another", async () => {
108+
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
109+
await service.createLink({ object: OBJECT, recordId: 'brief_a' }, { ...caller(ORG_B), isSystem: true } as never);
110+
expect(await links()).toEqual([{ record_id: 'brief_a', organization_id: ORG_A }]);
111+
});
112+
113+
it("walled: an organization-less record takes the caller's organization", async () => {
114+
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
115+
await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller(ORG_B));
116+
expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: ORG_B }]);
117+
});
118+
119+
it('single: neither names one — the Default Organization, the only one there is', async () => {
120+
const { service, links } = await boot('single', [DEFAULT_ORG]);
121+
const link = await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller());
122+
expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: DEFAULT_ORG }]);
123+
// The mint response keeps the contract's shape.
124+
expect(link).not.toHaveProperty('organization_id');
125+
});
126+
127+
it('walled: neither names one — refused with the ADR-0112 envelope, and no row is written', async () => {
128+
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
129+
const refusal = await service
130+
.createLink({ object: OBJECT, recordId: 'brief_p' }, caller())
131+
.then(() => undefined, (err: unknown) => err);
132+
expect(refusal).toMatchObject({ code: SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE, status: 500 });
133+
expect(await links()).toEqual([]);
134+
});
135+
});

‎packages/plugins/plugin-sharing/src/share-link-service.ts‎

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ import { materializeDeclaredFields, readInternalColumn } from '@objectstack/obje
4444
// schema and an engine that cannot answer `getSchema` are one answer, `false`
4545
// — the same definition `getPolicy` below has always used.
4646
import { isPublicSharingEnabled } from '@objectstack/spec/data';
47+
import type { TenancyPosture } from '@objectstack/spec/security';
48+
// [ADR-0131 D9] The one "which organization does an organization-less write
49+
// resolve to?" decision (`@objectstack/core`'s, re-exported unchanged), and the
50+
// engine's refusal for it — see
51+
// `ShareLinkService.resolveMintOrganization`.
52+
import {
53+
resolveSystemWriteOrganization,
54+
resolveTenantFieldName,
55+
SystemWriteOrganizationRequiredError,
56+
} from '@objectstack/objectql';
57+
import { resolveTenancyPosture } from '@objectstack/types';
4758
import type { SharingEngine } from './sharing-service.js';
4859
import {
4960
hashShareLinkPassword,
@@ -60,6 +71,9 @@ import {
6071
/** Service-elevated context for the plugin's own queries / mutations. */
6172
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
6273

74+
/** The organization object the ADR-0131 D9 decision counts on a `single` install. */
75+
const ORGANIZATION_OBJECT = 'sys_organization';
76+
6377
/**
6478
* [#5190] The table whose orphans this service owns. `sys_share_link` is
6579
* `managedBy: 'engine-owned'` and its object doc states every write flows
@@ -662,11 +676,130 @@ export class ShareLinkService implements IShareLinkService {
662676
use_count: 0,
663677
};
664678

665-
await this.engine.insert('sys_share_link', row, { context: SYSTEM_CTX });
679+
// [ADR-0131 D9] The organization the link belongs to rides the stored row
680+
// and the write context — the `{ isSystem, tenantId }` shape #8844's
681+
// refusal prescribes for a system write. The mint response keeps the
682+
// contract's shape (`ShareLink` declares no organization column).
683+
const organizationId = await this.resolveMintOrganization(input, context);
684+
const stored = organizationId === undefined ? row : { ...row, organization_id: organizationId };
685+
await this.engine.insert('sys_share_link', stored, {
686+
context: { ...SYSTEM_CTX, tenantId: organizationId },
687+
});
666688
// [#21839] The row as stored carries the hash; the mint response does not.
667689
return withoutPasswordHash(row);
668690
}
669691

692+
/**
693+
* [ADR-0131 D9, C8 stage 8b-0h] The organization a minted link is stamped
694+
* with — never a NULL row on a tenant-columned `sys_share_link`:
695+
*
696+
* 1. the LINKED RECORD's organization, read under the system context off the
697+
* column the object is walled by, so field-level masking cannot hide it
698+
* from the decision. It wins over the session's: under `group` a member
699+
* active in one organization may mint on a record of another it can see,
700+
* and the link exposes that record, so it belongs where the record does —
701+
* the direct-grant rule `SharingService.resolveDirectGrantOrganization`
702+
* follows for `sys_record_share`;
703+
* 2. the acting context's organization (`tenantId`), when the record names
704+
* none — an object with no tenant column, or an organization-less row;
705+
* 3. otherwise `@objectstack/core`'s one D9 predicate: the Default
706+
* Organization under `single` when exactly one exists, and a loud
707+
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal under a wall (and on a
708+
* `single` install holding none or several) — nothing is written.
709+
*
710+
* The refusal is the engine's own: the envelope its system-insert rule
711+
* raises for this same write once the platform-namespace exemption retires,
712+
* so the mint answers alike before and after that.
713+
*
714+
* The posture is the REQUESTED one (`resolveTenancyPosture()`), the source
715+
* and fail-stricter direction `resolveAuthzContext` and the `tenancy`
716+
* service's `defaultOrgId()` read: a deployment that asked for a wall is
717+
* walled here even while it runs degraded.
718+
*
719+
* `undefined` means there is nothing to stamp: `sys_share_link` carries no
720+
* tenant column in this composition, or the composition registers no
721+
* organization object at all.
722+
*
723+
* A failing record read propagates: the mint fails and nothing is written,
724+
* rather than stamping the session's organization on a link whose record's
725+
* organization is unknown.
726+
*/
727+
private async resolveMintOrganization(
728+
input: CreateShareLinkInput,
729+
context: ExecutionContext,
730+
): Promise<string | undefined> {
731+
if (!this.tenantFieldOf('sys_share_link')) return undefined;
732+
733+
const recordTenantField = this.tenantFieldOf(input.object);
734+
if (recordTenantField) {
735+
const rows = await this.engine.find(input.object, {
736+
where: { id: input.recordId },
737+
fields: ['id', recordTenantField],
738+
limit: 1,
739+
context: SYSTEM_CTX,
740+
});
741+
const value = Array.isArray(rows)
742+
? (rows[0] as Record<string, unknown> | undefined)?.[recordTenantField]
743+
: undefined;
744+
if (typeof value === 'string' && value.trim() !== '') return value;
745+
}
746+
747+
const acting = context.tenantId;
748+
if (typeof acting === 'string' && acting.trim() !== '') return acting;
749+
750+
let posture: TenancyPosture;
751+
try {
752+
posture = resolveTenancyPosture();
753+
} catch {
754+
// An unreadable posture is the boot's to refuse; here it reads walled.
755+
posture = 'isolated';
756+
}
757+
const decision = await resolveSystemWriteOrganization({
758+
posture,
759+
probeOrganizations: () => this.probeOrganizations(),
760+
organizationObjectRegistered: !!this.engine.getSchema?.(ORGANIZATION_OBJECT),
761+
});
762+
if (decision.kind === 'derived') return decision.organizationId;
763+
if (decision.kind === 'no-organization-object') return undefined;
764+
throw new SystemWriteOrganizationRequiredError(
765+
'sys_share_link',
766+
posture,
767+
decision.reason,
768+
decision.organizationCount,
769+
);
770+
}
771+
772+
/**
773+
* The install's organization ids, capped at two — only "0 / 1 / several"
774+
* decides. A composition that registers no organization object answers `[]`
775+
* without a read, as the engine's own probe does: the data door refuses an
776+
* unregistered name.
777+
*/
778+
private async probeOrganizations(): Promise<readonly string[]> {
779+
if (!this.engine.getSchema?.(ORGANIZATION_OBJECT)) return [];
780+
const rows = await this.engine.find(ORGANIZATION_OBJECT, {
781+
fields: ['id'],
782+
limit: 2,
783+
context: SYSTEM_CTX,
784+
});
785+
return (Array.isArray(rows) ? rows : [])
786+
.map((r: any) => r?.id)
787+
.filter((id: unknown) => id != null && String(id) !== '')
788+
.map((id: unknown) => String(id));
789+
}
790+
791+
/** The tenant column of `object`, or `null` when it has none / the engine cannot say. */
792+
private tenantFieldOf(object: string): string | null {
793+
if (typeof this.engine.getSchema !== 'function') return null;
794+
let schema: unknown;
795+
try {
796+
schema = this.engine.getSchema(object);
797+
} catch {
798+
return null;
799+
}
800+
return schema ? resolveTenantFieldName(schema) : null;
801+
}
802+
670803
async revokeLink(idOrToken: string, context: ExecutionContext): Promise<void> {
671804
if (!idOrToken) throw makeError(400, 'VALIDATION_FAILED', 'id or token is required');
672805
const filter = idOrToken.startsWith('shl_') ? { id: idOrToken } : { token: idOrToken };

0 commit comments

Comments
 (0)