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
45 changes: 45 additions & 0 deletions .changeset/sharing-write-verdict-tristate.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
---

feat(sharing): `ISharingService` 的每行写判定补三态 —— 放行 / 不表态 / 拒绝(#6428)

#5492 的维护者裁决(2026-08-07,B 案)分两步兑现两种已声明的写扩权,本次是 **step 1:
契约与默认实现**。plugin-security 前像门的 provenance 分层合成是 step 2,本次一行未动。

**为什么二态不够(实测,不是推演)。** `canEdit()` 用同一个 `true` 表达了两件事 ——
「我有依据放行」与「本服务对这一行根本不设门」。对只**追加**一道门的调用方(sharing
中间件、`sys_attachment` 父记录门、ADR-0055 master 判定)这没问题:`true` = 「我不拦
你」。对让这个答案去**顶替另一个权威的地板**的调用方就是 fail-open —— #5492 的 E2 实验
把前像写门委托给 `canEdit()` 后,在**没有 `owner_id` 列**的对象上,普通成员跨 creator
的 UPDATE 变成 `ok: true`(main 上是 403),因为平台的 `created_by` 所有权地板正是这类
对象唯一的行级写门,而一个「不表态」的 `true` 把它盖掉了。

**新增契约面**(`@objectstack/spec/contracts`):

- `SharingWriteVerdict = 'allow' | 'abstain' | 'deny'` —— 闭合联合,普通 TS 类型
(非 zod 派生,不进 ADR-0122 的 pin 计数)。
- `ISharingService.checkEdit()` / `checkDelete()` —— 三态主形态,动作边界照 ADR-0111 D3
继承:`edit` 级共享让 `checkEdit` 答 `allow`、同一行 `checkDelete` 仍答 `deny`;两者
的 `abstain` 集合完全相同(两道门对「哪些对象由共享设门」意见一致,只在动词上分歧)。

**兼容:`canEdit()` / `canDelete()` 原样保留,语义零漂移。** 它们被定义为三态的
**投影** `verdict !== 'deny'` —— 从前对 public / 无 owner 字段 / bypass 对象返回的那个
`true`,现在落在 `abstain` 上,投影回来仍是 `true`。真值表逐分支被测试钉住(9 个分支
× 两个动词),因为 `resolveSharingCanEdit`(plugin-security)与 `sys_attachment` 父记录
门读的正是这一列,翻掉任何一格都是本 PR 未触及的包里的静默权限变更。

**fail-closed 落点:查询失败是 `deny`,永远不是 `abstain`。** 两者对合成方是相反的指令
(`abstain` 把这一行交给另一个权威,`deny` 就地终结),把失败读成「没有意见」正是造出上述
fail-open 的那个混淆。默认实现把所有权查询与共享查询整段包在 fail-closed 分支里,并
`logger.error` 记名,不静默吞。

**行为变化(一处,方向收紧)**:引擎查询抛错时,`canEdit`/`canDelete` 从**向外抛**改为
返回 `false`。两个既有调用点本来就在自己那侧 catch 成 `false`(`resolveSharingCanEdit`
的 #5386 fail-closed、attachment hook 的降级读),所以对它们是同一结果;其余调用点由
「异常中止写入」变成「403 拒绝写入」,严格不更宽松。

**解锁**:#5492 step 2 的前像门可以按 provenance 分层合成 —— `abstain` 回落平台所有权
地板、`allow` 按声明顶替地板、`deny` 维持拒绝 —— 而不必在 security 侧重算一份
owner/depth/share/bypass(那会是同一契约的第二份实现)。#5491 与 #5492 同批落地。
239 changes: 239 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1452,3 +1452,242 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
expect(seen).toHaveLength(0);
});
});

// ─────────────────────────────────────────────────────────────────────
// [#6428] Tri-state write verdicts (#5492 ruling B, step 1).
//
// `canEdit` answered ONE `true` for two different facts — "I permit this
// write" and "record sharing does not enforce on this row at all". #5492's E2
// experiment delegated a pre-image write gate to it and measured the cost: on
// an object with NO `owner_id` column, an ordinary member's cross-creator
// UPDATE came back `ok: true` where `main` answers 403, because the platform's
// `created_by` ownership floor is that object's only row-level write gate and
// an abstaining `true` overrode it.
//
// `checkEdit` / `checkDelete` separate the two; `canEdit` / `canDelete` stay
// exactly as they were (`verdict !== 'deny'`), which the parity block below
// pins branch by branch so a two-state caller cannot drift under this change.
// ─────────────────────────────────────────────────────────────────────

describe('[#6428] SharingService.checkEdit / checkDelete — allow / abstain / deny', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let svc: SharingService;

beforeEach(() => {
engine = makeFakeEngine({
account: ACCOUNT_SCHEMA, // private + owner_id → sharing enforces
whiteboard: CANON_PUBLIC_RW_SCHEMA, // public_read_write → sharing does not
note: ORPHAN_SCHEMA, // private, NO owner_id → the E2 shape
sys_record_share: { name: 'sys_record_share' },
});
svc = new SharingService({ engine });
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
engine._tables.whiteboard = [{ id: 'w1', name: 'Board', owner_id: 'alice' }];
engine._tables.note = [{ id: 'n1', body: 'an object with no owner column' }];
});

it('allow — the record owner, on both verbs', async () => {
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
expect(await svc.checkDelete('account', 'a1', { userId: 'alice' })).toBe('allow');
});

it('deny — a stranger on a sharing-enforced object', async () => {
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
expect(await svc.checkDelete('account', 'a1', { userId: 'bob' })).toBe('deny');
});

it('deny — a principal-less context (no userId is not "no opinion")', async () => {
expect(await svc.checkEdit('account', 'a1', {})).toBe('deny');
expect(await svc.checkDelete('account', 'a1', {})).toBe('deny');
});

it('deny — a read-level share', async () => {
await svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'read' },
{ isSystem: true },
);
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
});

it('[ADR-0111 D3] an edit share is allow on update and DENY on delete — the verb boundary survives the tri-state', async () => {
await svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' },
{ isSystem: true },
);
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');
// NOT `abstain`: the delete gate has a real opinion here and it is "no".
// An abstain would hand the row to a caller's fallback and let an edit
// share leak the delete verb through the back door.
expect(await svc.checkDelete('account', 'a1', { userId: 'bob' })).toBe('deny');
});

it('[#5492 E2] abstain — an object with NO owner_id column, where the boolean said `true`', async () => {
// THE pin this card exists for. The verdict is "I do not enforce here",
// so #5492 step 2 can keep the platform `created_by` floor in force…
expect(await svc.checkEdit('note', 'n1', { userId: 'bob' })).toBe('abstain');
expect(await svc.checkDelete('note', 'n1', { userId: 'bob' })).toBe('abstain');
// …while the two-state projection every current caller reads is unchanged.
expect(await svc.canEdit('note', 'n1', { userId: 'bob' })).toBe(true);
expect(await svc.canDelete('note', 'n1', { userId: 'bob' })).toBe(true);
});

it('abstain — a public object, and an object this engine has no schema for', async () => {
expect(await svc.checkEdit('whiteboard', 'w1', { userId: 'bob' })).toBe('abstain');
expect(await svc.checkDelete('whiteboard', 'w1', { userId: 'bob' })).toBe('abstain');
expect(await svc.checkEdit('ghost', 'g1', { userId: 'bob' })).toBe('abstain');
});

it('the two bypass reasons split: a system context ALLOWS, a bypass-listed object ABSTAINS', async () => {
// Merging these was the ambiguity in miniature. A platform-internal writer
// is positively permitted; `sys_user` is merely not sharing-enforced, and
// saying `allow` there would invite a composing caller to skip the gate
// that actually guards those tables.
expect(await svc.checkEdit('account', 'a1', { isSystem: true })).toBe('allow');
expect(await svc.checkDelete('account', 'a1', { isSystem: true })).toBe('allow');
expect(await svc.checkEdit('sys_user', 'u1', { userId: 'bob' })).toBe('abstain');
expect(await svc.checkDelete('sys_user', 'u1', { userId: 'bob' })).toBe('abstain');
});

it('[#4647] allow — Modify All Data, still asked LAST', async () => {
let probeCalls = 0;
const withBypass = new SharingService({
engine,
securityService: () => ({
hasWriteBypass: async () => { probeCalls++; return true; },
}),
});
expect(await withBypass.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');
expect(await withBypass.checkDelete('account', 'a1', { userId: 'bob' })).toBe('allow');
expect(probeCalls).toBe(2);
// The owner never pays for the probe: ownership answers first.
probeCalls = 0;
expect(await withBypass.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
expect(probeCalls).toBe(0);
});
});

describe('[#6428] fail-closed: an unresolvable verdict is DENY, never abstain', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let logged: any[];
let svc: SharingService;

beforeEach(() => {
engine = makeFakeEngine({
account: ACCOUNT_SCHEMA,
sys_record_share: { name: 'sys_record_share' },
});
logged = [];
svc = new SharingService({
engine,
logger: { error: (...args: any[]) => { logged.push(args); } },
});
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
});

it('a throwing ownership lookup denies — and says so in the log', async () => {
// Non-vacuity: this caller is ALLOWED while the engine works.
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');

engine.find = async () => { throw new Error('engine down'); };

// `abstain` here would be the fail-open: it tells a composing caller
// "nobody objects", on a row this service is supposed to be guarding.
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('deny');
expect(await svc.checkDelete('account', 'a1', { userId: 'alice' })).toBe('deny');
// The boolean projection converges with it: a denial, not a thrown 500.
expect(await svc.canEdit('account', 'a1', { userId: 'alice' })).toBe(false);
expect(await svc.canDelete('account', 'a1', { userId: 'alice' })).toBe(false);

expect(logged.length).toBeGreaterThan(0);
expect(String(logged[0][0])).toContain('fail-closed');
expect(String(logged[0][0])).toContain('#6428');
});

it('a throwing SHARE lookup denies too — the whole evaluation is covered, not just the first query', async () => {
await svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' },
{ isSystem: true },
);
// Non-vacuity: the share is what makes this caller `allow`…
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');

const realFind = engine.find.bind(engine);
engine.find = async (object: string, options?: any) => {
if (object === 'sys_record_share') throw new Error('share table unavailable');
return realFind(object, options);
};

// …so losing exactly that lookup must refuse, never fall back to "I have
// no opinion about a row I was gating a second ago".
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
});
});

describe('[#6428] the boolean projection does not drift (compatibility clause)', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let svc: SharingService;

beforeEach(async () => {
engine = makeFakeEngine({
account: ACCOUNT_SCHEMA,
whiteboard: CANON_PUBLIC_RW_SCHEMA,
note: ORPHAN_SCHEMA,
sys_record_share: { name: 'sys_record_share' },
});
svc = new SharingService({ engine });
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
engine._tables.whiteboard = [{ id: 'w1', name: 'Board', owner_id: 'alice' }];
engine._tables.note = [{ id: 'n1', body: 'no owner column' }];
await svc.grant(
{ object: 'account', recordId: 'a1', recipientId: 'carol', accessLevel: 'edit' },
{ isSystem: true },
);
});

// Every branch of both gates, with the boolean answer `main` gives today.
// `resolveSharingCanEdit` (plugin-security :3484) and the `sys_attachment`
// parent gate read exactly this column, so a single flipped cell here is a
// silent enforcement change in packages this PR does not touch.
const CASES: Array<{
what: string;
object: string;
id: string;
ctx: any;
edit: boolean;
del: boolean;
}> = [
{ what: 'owner', object: 'account', id: 'a1', ctx: { userId: 'alice' }, edit: true, del: true },
{ what: 'stranger', object: 'account', id: 'a1', ctx: { userId: 'bob' }, edit: false, del: false },
{ what: 'edit-share holder', object: 'account', id: 'a1', ctx: { userId: 'carol' }, edit: true, del: false },
{ what: 'no principal', object: 'account', id: 'a1', ctx: {}, edit: false, del: false },
{ what: 'system context', object: 'account', id: 'a1', ctx: { isSystem: true }, edit: true, del: true },
{ what: 'public object', object: 'whiteboard', id: 'w1', ctx: { userId: 'bob' }, edit: true, del: true },
{ what: 'no owner column', object: 'note', id: 'n1', ctx: { userId: 'bob' }, edit: true, del: true },
{ what: 'unknown object', object: 'ghost', id: 'g1', ctx: { userId: 'bob' }, edit: true, del: true },
{ what: 'bypass-listed object', object: 'sys_user', id: 'u1', ctx: { userId: 'bob' }, edit: true, del: true },
];

it('canEdit / canDelete are `verdict !== deny` on every branch, and unchanged from the two-state era', async () => {
for (const c of CASES) {
const editVerdict = await svc.checkEdit(c.object, c.id, c.ctx);
const deleteVerdict = await svc.checkDelete(c.object, c.id, c.ctx);
expect(await svc.canEdit(c.object, c.id, c.ctx), `canEdit — ${c.what}`).toBe(c.edit);
expect(await svc.canDelete(c.object, c.id, c.ctx), `canDelete — ${c.what}`).toBe(c.del);
expect(editVerdict !== 'deny', `projection parity, canEdit — ${c.what}`).toBe(c.edit);
expect(deleteVerdict !== 'deny', `projection parity, canDelete — ${c.what}`).toBe(c.del);
}
});

it('the abstain set is exactly where the boolean `true` carried no permission', async () => {
// Anti-vacuity for the table above: three of its `true` cells are abstains,
// not allows — which is the whole content of this change. If a later edit
// made these `allow`, the parity test would stay green and the fail-open
// would be back.
for (const [object, id] of [['whiteboard', 'w1'], ['note', 'n1'], ['ghost', 'g1'], ['sys_user', 'u1']]) {
expect(await svc.checkEdit(object, id, { userId: 'bob' }), `${object} must abstain`).toBe('abstain');
}
// …and the ones that are real permissions stay `allow`.
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
expect(await svc.checkEdit('account', 'a1', { isSystem: true })).toBe('allow');
});
});
Loading
Loading