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
12 changes: 12 additions & 0 deletions .changeset/15212-8b0h-share-link-stamped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): a minted share link carries an organization, never a NULL one (ADR-0131 D9)

Clause-②: no

- **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.
- **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.
- **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.
- **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 }`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0131 D9, C8 stage 8b-0h] Every minted `sys_share_link` row carries
* `organization_id` — on a REAL engine over a REAL driver, with the REAL
* `sys_share_link` object, so the column under test is the one the registry
* injects and the row is read back off the driver, never off the response.
*
* ## Why the service decides, not the engine
*
* `sys_share_link` is `unclassified` in the #13491 ledger, so
* `Engine.resolveSystemInsertOrganization` lets an organization-less system
* insert on it through today. The mint used to rely on that and landed NULL on
* every link. The organization is derivable at the mint — the linked record
* and the caller are both in hand — so the service stamps it, and refuses
* loudly where nothing names one, before and after that exemption retires.
*
* ## The four pins
*
* 1. walled: the row carries the RECORD's organization — even when the caller
* acts in another one (the link exposes the record, so it belongs where
* the record does);
* 2. walled: an organization-less record takes the caller's organization;
* 3. `single`: neither names one — the Default Organization (the only one);
* 4. walled: neither names one — refused with the engine's ADR-0112 envelope,
* and no row is written.
*
* The posture is the REQUESTED one, read from `OS_TENANCY_POSTURE`, so each
* case stubs the environment rather than injecting an engine provider.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { ObjectQL, SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import type { ExecutionContext } from '@objectstack/spec/kernel';

import { ShareLinkService } from './share-link-service.js';
import { SysShareLink } from './objects/index.js';

const OBJECT = 'os15212_brief';
const ORG_A = 'org_a';
const ORG_B = 'org_b';
const DEFAULT_ORG = 'org_default';

const BRIEF_FIELDS: Record<string, Record<string, unknown>> = {
id: { type: 'text', name: 'id', label: 'Id', primary: true },
name: { type: 'text', name: 'name', label: 'Name' },
organization_id: { type: 'text', name: 'organization_id', label: 'Org' },
};

const ORG_FIELDS: Record<string, Record<string, unknown>> = {
id: { type: 'text', name: 'id', label: 'Id', primary: true },
name: { type: 'text', name: 'name', label: 'Name' },
};

const open: SqlDriver[] = [];

afterEach(async () => {
vi.unstubAllEnvs();
while (open.length) await open.pop()?.disconnect?.();
});

/**
* brief_a ORG_A
* brief_p (null) <- organization-less; what pins 2-4 mint on
*/
async function boot(posture: 'single' | 'isolated', organizations: readonly string[]) {
vi.stubEnv('OS_TENANCY_POSTURE', posture);
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
open.push(driver);

const ql = new ObjectQL();
ql.registerDriver(driver as never, true);
await ql.init();
ql.registerObject({ name: OBJECT, label: 'Brief', fields: BRIEF_FIELDS } as never);
ql.registerObject(SysShareLink as never);
ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, fields: ORG_FIELDS } as never);
await driver.initObjects(
[OBJECT, 'sys_share_link', 'sys_organization'].map((name) => ({ name, fields: (ql.getSchema(name) as any).fields }) as never),
);
for (const id of organizations) await driver.create('sys_organization', { id, name: id } as never);
await driver.create(OBJECT, { id: 'brief_a', name: 'A', organization_id: ORG_A } as never);
await driver.create(OBJECT, { id: 'brief_p', name: 'P' } as never);

const service = new ShareLinkService({ engine: ql as never, permissive: true, logger: { warn: () => {} } });
const links = async () =>
(await driver.find('sys_share_link', {} as never)).map((r: any) => ({
record_id: String(r.record_id),
organization_id: r.organization_id ?? null,
}));
return { ql, service, links };
}

const caller = (tenantId?: string) =>
({ userId: 'u_1', positions: [], permissions: [], ...(tenantId ? { tenantId } : {}) }) as unknown as ExecutionContext;

describe('[ADR-0131 D9] a minted share link carries an organization', () => {
it("the real sys_share_link object carries the tenant column the stamp writes", async () => {
const { ql } = await boot('isolated', [ORG_A, ORG_B]);
expect(Object.keys((ql.getSchema('sys_share_link') as any).fields)).toContain('organization_id');
});

it("walled: the RECORD's organization, even when the caller acts in another", async () => {
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
await service.createLink({ object: OBJECT, recordId: 'brief_a' }, { ...caller(ORG_B), isSystem: true } as never);
expect(await links()).toEqual([{ record_id: 'brief_a', organization_id: ORG_A }]);
});

it("walled: an organization-less record takes the caller's organization", async () => {
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller(ORG_B));
expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: ORG_B }]);
});

it('single: neither names one — the Default Organization, the only one there is', async () => {
const { service, links } = await boot('single', [DEFAULT_ORG]);
const link = await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller());
expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: DEFAULT_ORG }]);
// The mint response keeps the contract's shape.
expect(link).not.toHaveProperty('organization_id');
});

it('walled: neither names one — refused with the ADR-0112 envelope, and no row is written', async () => {
const { service, links } = await boot('isolated', [ORG_A, ORG_B]);
const refusal = await service
.createLink({ object: OBJECT, recordId: 'brief_p' }, caller())
.then(() => undefined, (err: unknown) => err);
expect(refusal).toMatchObject({ code: SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE, status: 500 });
expect(await links()).toEqual([]);
});
});
135 changes: 134 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ import { materializeDeclaredFields, readInternalColumn } from '@objectstack/obje
// schema and an engine that cannot answer `getSchema` are one answer, `false`
// — the same definition `getPolicy` below has always used.
import { isPublicSharingEnabled } from '@objectstack/spec/data';
import type { TenancyPosture } from '@objectstack/spec/security';
// [ADR-0131 D9] The one "which organization does an organization-less write
// resolve to?" decision (`@objectstack/core`'s, re-exported unchanged), and the
// engine's refusal for it — see
// `ShareLinkService.resolveMintOrganization`.
import {
resolveSystemWriteOrganization,
resolveTenantFieldName,
SystemWriteOrganizationRequiredError,
} from '@objectstack/objectql';
import { resolveTenancyPosture } from '@objectstack/types';
import type { SharingEngine } from './sharing-service.js';
import {
hashShareLinkPassword,
Expand All @@ -60,6 +71,9 @@ import {
/** Service-elevated context for the plugin's own queries / mutations. */
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;

/** The organization object the ADR-0131 D9 decision counts on a `single` install. */
const ORGANIZATION_OBJECT = 'sys_organization';

/**
* [#5190] The table whose orphans this service owns. `sys_share_link` is
* `managedBy: 'engine-owned'` and its object doc states every write flows
Expand Down Expand Up @@ -662,11 +676,130 @@ export class ShareLinkService implements IShareLinkService {
use_count: 0,
};

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

/**
* [ADR-0131 D9, C8 stage 8b-0h] The organization a minted link is stamped
* with — never a NULL row on a tenant-columned `sys_share_link`:
*
* 1. the LINKED RECORD's organization, read under the system context off the
* column the object is walled by, so field-level masking cannot hide it
* from the decision. It wins over the session's: under `group` a member
* active in one organization may mint on a record of another it can see,
* and the link exposes that record, so it belongs where the record does —
* the direct-grant rule `SharingService.resolveDirectGrantOrganization`
* follows for `sys_record_share`;
* 2. the acting context's organization (`tenantId`), when the record names
* none — an object with no tenant column, or an organization-less row;
* 3. otherwise `@objectstack/core`'s one D9 predicate: the Default
* Organization under `single` when exactly one exists, and a loud
* `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal under a wall (and on a
* `single` install holding none or several) — nothing is written.
*
* The refusal is the engine's own: the envelope its system-insert rule
* raises for this same write once the platform-namespace exemption retires,
* so the mint answers alike before and after that.
*
* The posture is the REQUESTED one (`resolveTenancyPosture()`), the source
* and fail-stricter direction `resolveAuthzContext` and the `tenancy`
* service's `defaultOrgId()` read: a deployment that asked for a wall is
* walled here even while it runs degraded.
*
* `undefined` means there is nothing to stamp: `sys_share_link` carries no
* tenant column in this composition, or the composition registers no
* organization object at all.
*
* A failing record read propagates: the mint fails and nothing is written,
* rather than stamping the session's organization on a link whose record's
* organization is unknown.
*/
private async resolveMintOrganization(
input: CreateShareLinkInput,
context: ExecutionContext,
): Promise<string | undefined> {
if (!this.tenantFieldOf('sys_share_link')) return undefined;

const recordTenantField = this.tenantFieldOf(input.object);
if (recordTenantField) {
const rows = await this.engine.find(input.object, {
where: { id: input.recordId },
fields: ['id', recordTenantField],
limit: 1,
context: SYSTEM_CTX,
});
const value = Array.isArray(rows)
? (rows[0] as Record<string, unknown> | undefined)?.[recordTenantField]
: undefined;
if (typeof value === 'string' && value.trim() !== '') return value;
}

const acting = context.tenantId;
if (typeof acting === 'string' && acting.trim() !== '') return acting;

let posture: TenancyPosture;
try {
posture = resolveTenancyPosture();
} catch {
// An unreadable posture is the boot's to refuse; here it reads walled.
posture = 'isolated';
}
const decision = await resolveSystemWriteOrganization({
posture,
probeOrganizations: () => this.probeOrganizations(),
organizationObjectRegistered: !!this.engine.getSchema?.(ORGANIZATION_OBJECT),
});
if (decision.kind === 'derived') return decision.organizationId;
if (decision.kind === 'no-organization-object') return undefined;
throw new SystemWriteOrganizationRequiredError(
'sys_share_link',
posture,
decision.reason,
decision.organizationCount,
);
}

/**
* The install's organization ids, capped at two — only "0 / 1 / several"
* decides. A composition that registers no organization object answers `[]`
* without a read, as the engine's own probe does: the data door refuses an
* unregistered name.
*/
private async probeOrganizations(): Promise<readonly string[]> {
if (!this.engine.getSchema?.(ORGANIZATION_OBJECT)) return [];
const rows = await this.engine.find(ORGANIZATION_OBJECT, {
fields: ['id'],
limit: 2,
context: SYSTEM_CTX,
});
return (Array.isArray(rows) ? rows : [])
.map((r: any) => r?.id)
.filter((id: unknown) => id != null && String(id) !== '')
.map((id: unknown) => String(id));
}

/** The tenant column of `object`, or `null` when it has none / the engine cannot say. */
private tenantFieldOf(object: string): string | null {
if (typeof this.engine.getSchema !== 'function') return null;
let schema: unknown;
try {
schema = this.engine.getSchema(object);
} catch {
return null;
}
return schema ? resolveTenantFieldName(schema) : null;
}

async revokeLink(idOrToken: string, context: ExecutionContext): Promise<void> {
if (!idOrToken) throw makeError(400, 'VALIDATION_FAILED', 'id or token is required');
const filter = idOrToken.startsWith('shl_') ? { id: idOrToken } : { token: idOrToken };
Expand Down
Loading