Skip to content

Commit 54299ca

Browse files
qq9340100claude
andauthored
feat(sharing): ISharingService 的每行写判定补三态(放行/不表态/拒绝) (#6564)
#5492 维护者裁决 B 案的 step 1:契约与默认实现同 PR,防「声明了没人实现」窗口。 plugin-security 前像门的 provenance 分层合成是 step 2,本次一行未动。 - packages/spec/src/contracts/sharing-service.ts - SharingWriteVerdict = 'allow' | 'abstain' | 'deny'(普通 TS 类型,非 zod 派生) - ISharingService.checkEdit() / checkDelete():三态主形态,动作边界照 ADR-0111 D3 继承(edit 共享 → update 放行、delete 仍拒),两者 abstain 集合相同 - canEdit() / canDelete() 保留并被定义为投影 `verdict !== 'deny'`,真值表零漂移 - packages/plugins/plugin-sharing/src/sharing-service.ts - 三态实现;shouldBypass 的两个理由拆开:isSystem → allow,bypass 名单 → abstain - writeGateFailClosed():查询失败一律 deny(永不 abstain)并 logger.error 记名 - 测试:spec 侧编译期 + 散文 pin;plugin 侧三态逐分支、E2 无 owner_id 形状、 fail-closed 两条、投影不漂移的 9 分支真值表(含反空转) - api-surface/contracts.json 随新导出重生成 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92e13a0 commit 54299ca

6 files changed

Lines changed: 688 additions & 62 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
---
5+
6+
feat(sharing): `ISharingService` 的每行写判定补三态 —— 放行 / 不表态 / 拒绝(#6428)
7+
8+
#5492 的维护者裁决(2026-08-07,B 案)分两步兑现两种已声明的写扩权,本次是 **step 1:
9+
契约与默认实现**。plugin-security 前像门的 provenance 分层合成是 step 2,本次一行未动。
10+
11+
**为什么二态不够(实测,不是推演)。** `canEdit()` 用同一个 `true` 表达了两件事 ——
12+
「我有依据放行」与「本服务对这一行根本不设门」。对只**追加**一道门的调用方(sharing
13+
中间件、`sys_attachment` 父记录门、ADR-0055 master 判定)这没问题:`true` = 「我不拦
14+
你」。对让这个答案去**顶替另一个权威的地板**的调用方就是 fail-open —— #5492 的 E2 实验
15+
把前像写门委托给 `canEdit()` 后,在**没有 `owner_id`**的对象上,普通成员跨 creator
16+
的 UPDATE 变成 `ok: true`(main 上是 403),因为平台的 `created_by` 所有权地板正是这类
17+
对象唯一的行级写门,而一个「不表态」的 `true` 把它盖掉了。
18+
19+
**新增契约面**(`@objectstack/spec/contracts`):
20+
21+
- `SharingWriteVerdict = 'allow' | 'abstain' | 'deny'` —— 闭合联合,普通 TS 类型
22+
(非 zod 派生,不进 ADR-0122 的 pin 计数)。
23+
- `ISharingService.checkEdit()` / `checkDelete()` —— 三态主形态,动作边界照 ADR-0111 D3
24+
继承:`edit` 级共享让 `checkEdit``allow`、同一行 `checkDelete` 仍答 `deny`;两者
25+
`abstain` 集合完全相同(两道门对「哪些对象由共享设门」意见一致,只在动词上分歧)。
26+
27+
**兼容:`canEdit()` / `canDelete()` 原样保留,语义零漂移。** 它们被定义为三态的
28+
**投影** `verdict !== 'deny'` —— 从前对 public / 无 owner 字段 / bypass 对象返回的那个
29+
`true`,现在落在 `abstain` 上,投影回来仍是 `true`。真值表逐分支被测试钉住(9 个分支
30+
× 两个动词),因为 `resolveSharingCanEdit`(plugin-security)与 `sys_attachment` 父记录
31+
门读的正是这一列,翻掉任何一格都是本 PR 未触及的包里的静默权限变更。
32+
33+
**fail-closed 落点:查询失败是 `deny`,永远不是 `abstain`** 两者对合成方是相反的指令
34+
(`abstain` 把这一行交给另一个权威,`deny` 就地终结),把失败读成「没有意见」正是造出上述
35+
fail-open 的那个混淆。默认实现把所有权查询与共享查询整段包在 fail-closed 分支里,并
36+
`logger.error` 记名,不静默吞。
37+
38+
**行为变化(一处,方向收紧)**:引擎查询抛错时,`canEdit`/`canDelete`**向外抛**改为
39+
返回 `false`。两个既有调用点本来就在自己那侧 catch 成 `false`(`resolveSharingCanEdit`
40+
#5386 fail-closed、attachment hook 的降级读),所以对它们是同一结果;其余调用点由
41+
「异常中止写入」变成「403 拒绝写入」,严格不更宽松。
42+
43+
**解锁**:#5492 step 2 的前像门可以按 provenance 分层合成 —— `abstain` 回落平台所有权
44+
地板、`allow` 按声明顶替地板、`deny` 维持拒绝 —— 而不必在 security 侧重算一份
45+
owner/depth/share/bypass(那会是同一契约的第二份实现)。#5491#5492 同批落地。

packages/plugins/plugin-sharing/src/sharing-service.test.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,3 +1452,242 @@ describe('[#5859] resolveOwnerScopeIds fills the AUTHORITATIVE organization', ()
14521452
expect(seen).toHaveLength(0);
14531453
});
14541454
});
1455+
1456+
// ─────────────────────────────────────────────────────────────────────
1457+
// [#6428] Tri-state write verdicts (#5492 ruling B, step 1).
1458+
//
1459+
// `canEdit` answered ONE `true` for two different facts — "I permit this
1460+
// write" and "record sharing does not enforce on this row at all". #5492's E2
1461+
// experiment delegated a pre-image write gate to it and measured the cost: on
1462+
// an object with NO `owner_id` column, an ordinary member's cross-creator
1463+
// UPDATE came back `ok: true` where `main` answers 403, because the platform's
1464+
// `created_by` ownership floor is that object's only row-level write gate and
1465+
// an abstaining `true` overrode it.
1466+
//
1467+
// `checkEdit` / `checkDelete` separate the two; `canEdit` / `canDelete` stay
1468+
// exactly as they were (`verdict !== 'deny'`), which the parity block below
1469+
// pins branch by branch so a two-state caller cannot drift under this change.
1470+
// ─────────────────────────────────────────────────────────────────────
1471+
1472+
describe('[#6428] SharingService.checkEdit / checkDelete — allow / abstain / deny', () => {
1473+
let engine: ReturnType<typeof makeFakeEngine>;
1474+
let svc: SharingService;
1475+
1476+
beforeEach(() => {
1477+
engine = makeFakeEngine({
1478+
account: ACCOUNT_SCHEMA, // private + owner_id → sharing enforces
1479+
whiteboard: CANON_PUBLIC_RW_SCHEMA, // public_read_write → sharing does not
1480+
note: ORPHAN_SCHEMA, // private, NO owner_id → the E2 shape
1481+
sys_record_share: { name: 'sys_record_share' },
1482+
});
1483+
svc = new SharingService({ engine });
1484+
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
1485+
engine._tables.whiteboard = [{ id: 'w1', name: 'Board', owner_id: 'alice' }];
1486+
engine._tables.note = [{ id: 'n1', body: 'an object with no owner column' }];
1487+
});
1488+
1489+
it('allow — the record owner, on both verbs', async () => {
1490+
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
1491+
expect(await svc.checkDelete('account', 'a1', { userId: 'alice' })).toBe('allow');
1492+
});
1493+
1494+
it('deny — a stranger on a sharing-enforced object', async () => {
1495+
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
1496+
expect(await svc.checkDelete('account', 'a1', { userId: 'bob' })).toBe('deny');
1497+
});
1498+
1499+
it('deny — a principal-less context (no userId is not "no opinion")', async () => {
1500+
expect(await svc.checkEdit('account', 'a1', {})).toBe('deny');
1501+
expect(await svc.checkDelete('account', 'a1', {})).toBe('deny');
1502+
});
1503+
1504+
it('deny — a read-level share', async () => {
1505+
await svc.grant(
1506+
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'read' },
1507+
{ isSystem: true },
1508+
);
1509+
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
1510+
});
1511+
1512+
it('[ADR-0111 D3] an edit share is allow on update and DENY on delete — the verb boundary survives the tri-state', async () => {
1513+
await svc.grant(
1514+
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' },
1515+
{ isSystem: true },
1516+
);
1517+
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');
1518+
// NOT `abstain`: the delete gate has a real opinion here and it is "no".
1519+
// An abstain would hand the row to a caller's fallback and let an edit
1520+
// share leak the delete verb through the back door.
1521+
expect(await svc.checkDelete('account', 'a1', { userId: 'bob' })).toBe('deny');
1522+
});
1523+
1524+
it('[#5492 E2] abstain — an object with NO owner_id column, where the boolean said `true`', async () => {
1525+
// THE pin this card exists for. The verdict is "I do not enforce here",
1526+
// so #5492 step 2 can keep the platform `created_by` floor in force…
1527+
expect(await svc.checkEdit('note', 'n1', { userId: 'bob' })).toBe('abstain');
1528+
expect(await svc.checkDelete('note', 'n1', { userId: 'bob' })).toBe('abstain');
1529+
// …while the two-state projection every current caller reads is unchanged.
1530+
expect(await svc.canEdit('note', 'n1', { userId: 'bob' })).toBe(true);
1531+
expect(await svc.canDelete('note', 'n1', { userId: 'bob' })).toBe(true);
1532+
});
1533+
1534+
it('abstain — a public object, and an object this engine has no schema for', async () => {
1535+
expect(await svc.checkEdit('whiteboard', 'w1', { userId: 'bob' })).toBe('abstain');
1536+
expect(await svc.checkDelete('whiteboard', 'w1', { userId: 'bob' })).toBe('abstain');
1537+
expect(await svc.checkEdit('ghost', 'g1', { userId: 'bob' })).toBe('abstain');
1538+
});
1539+
1540+
it('the two bypass reasons split: a system context ALLOWS, a bypass-listed object ABSTAINS', async () => {
1541+
// Merging these was the ambiguity in miniature. A platform-internal writer
1542+
// is positively permitted; `sys_user` is merely not sharing-enforced, and
1543+
// saying `allow` there would invite a composing caller to skip the gate
1544+
// that actually guards those tables.
1545+
expect(await svc.checkEdit('account', 'a1', { isSystem: true })).toBe('allow');
1546+
expect(await svc.checkDelete('account', 'a1', { isSystem: true })).toBe('allow');
1547+
expect(await svc.checkEdit('sys_user', 'u1', { userId: 'bob' })).toBe('abstain');
1548+
expect(await svc.checkDelete('sys_user', 'u1', { userId: 'bob' })).toBe('abstain');
1549+
});
1550+
1551+
it('[#4647] allow — Modify All Data, still asked LAST', async () => {
1552+
let probeCalls = 0;
1553+
const withBypass = new SharingService({
1554+
engine,
1555+
securityService: () => ({
1556+
hasWriteBypass: async () => { probeCalls++; return true; },
1557+
}),
1558+
});
1559+
expect(await withBypass.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');
1560+
expect(await withBypass.checkDelete('account', 'a1', { userId: 'bob' })).toBe('allow');
1561+
expect(probeCalls).toBe(2);
1562+
// The owner never pays for the probe: ownership answers first.
1563+
probeCalls = 0;
1564+
expect(await withBypass.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
1565+
expect(probeCalls).toBe(0);
1566+
});
1567+
});
1568+
1569+
describe('[#6428] fail-closed: an unresolvable verdict is DENY, never abstain', () => {
1570+
let engine: ReturnType<typeof makeFakeEngine>;
1571+
let logged: any[];
1572+
let svc: SharingService;
1573+
1574+
beforeEach(() => {
1575+
engine = makeFakeEngine({
1576+
account: ACCOUNT_SCHEMA,
1577+
sys_record_share: { name: 'sys_record_share' },
1578+
});
1579+
logged = [];
1580+
svc = new SharingService({
1581+
engine,
1582+
logger: { error: (...args: any[]) => { logged.push(args); } },
1583+
});
1584+
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
1585+
});
1586+
1587+
it('a throwing ownership lookup denies — and says so in the log', async () => {
1588+
// Non-vacuity: this caller is ALLOWED while the engine works.
1589+
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
1590+
1591+
engine.find = async () => { throw new Error('engine down'); };
1592+
1593+
// `abstain` here would be the fail-open: it tells a composing caller
1594+
// "nobody objects", on a row this service is supposed to be guarding.
1595+
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('deny');
1596+
expect(await svc.checkDelete('account', 'a1', { userId: 'alice' })).toBe('deny');
1597+
// The boolean projection converges with it: a denial, not a thrown 500.
1598+
expect(await svc.canEdit('account', 'a1', { userId: 'alice' })).toBe(false);
1599+
expect(await svc.canDelete('account', 'a1', { userId: 'alice' })).toBe(false);
1600+
1601+
expect(logged.length).toBeGreaterThan(0);
1602+
expect(String(logged[0][0])).toContain('fail-closed');
1603+
expect(String(logged[0][0])).toContain('#6428');
1604+
});
1605+
1606+
it('a throwing SHARE lookup denies too — the whole evaluation is covered, not just the first query', async () => {
1607+
await svc.grant(
1608+
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' },
1609+
{ isSystem: true },
1610+
);
1611+
// Non-vacuity: the share is what makes this caller `allow`…
1612+
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('allow');
1613+
1614+
const realFind = engine.find.bind(engine);
1615+
engine.find = async (object: string, options?: any) => {
1616+
if (object === 'sys_record_share') throw new Error('share table unavailable');
1617+
return realFind(object, options);
1618+
};
1619+
1620+
// …so losing exactly that lookup must refuse, never fall back to "I have
1621+
// no opinion about a row I was gating a second ago".
1622+
expect(await svc.checkEdit('account', 'a1', { userId: 'bob' })).toBe('deny');
1623+
});
1624+
});
1625+
1626+
describe('[#6428] the boolean projection does not drift (compatibility clause)', () => {
1627+
let engine: ReturnType<typeof makeFakeEngine>;
1628+
let svc: SharingService;
1629+
1630+
beforeEach(async () => {
1631+
engine = makeFakeEngine({
1632+
account: ACCOUNT_SCHEMA,
1633+
whiteboard: CANON_PUBLIC_RW_SCHEMA,
1634+
note: ORPHAN_SCHEMA,
1635+
sys_record_share: { name: 'sys_record_share' },
1636+
});
1637+
svc = new SharingService({ engine });
1638+
engine._tables.account = [{ id: 'a1', name: 'Acme', owner_id: 'alice' }];
1639+
engine._tables.whiteboard = [{ id: 'w1', name: 'Board', owner_id: 'alice' }];
1640+
engine._tables.note = [{ id: 'n1', body: 'no owner column' }];
1641+
await svc.grant(
1642+
{ object: 'account', recordId: 'a1', recipientId: 'carol', accessLevel: 'edit' },
1643+
{ isSystem: true },
1644+
);
1645+
});
1646+
1647+
// Every branch of both gates, with the boolean answer `main` gives today.
1648+
// `resolveSharingCanEdit` (plugin-security :3484) and the `sys_attachment`
1649+
// parent gate read exactly this column, so a single flipped cell here is a
1650+
// silent enforcement change in packages this PR does not touch.
1651+
const CASES: Array<{
1652+
what: string;
1653+
object: string;
1654+
id: string;
1655+
ctx: any;
1656+
edit: boolean;
1657+
del: boolean;
1658+
}> = [
1659+
{ what: 'owner', object: 'account', id: 'a1', ctx: { userId: 'alice' }, edit: true, del: true },
1660+
{ what: 'stranger', object: 'account', id: 'a1', ctx: { userId: 'bob' }, edit: false, del: false },
1661+
{ what: 'edit-share holder', object: 'account', id: 'a1', ctx: { userId: 'carol' }, edit: true, del: false },
1662+
{ what: 'no principal', object: 'account', id: 'a1', ctx: {}, edit: false, del: false },
1663+
{ what: 'system context', object: 'account', id: 'a1', ctx: { isSystem: true }, edit: true, del: true },
1664+
{ what: 'public object', object: 'whiteboard', id: 'w1', ctx: { userId: 'bob' }, edit: true, del: true },
1665+
{ what: 'no owner column', object: 'note', id: 'n1', ctx: { userId: 'bob' }, edit: true, del: true },
1666+
{ what: 'unknown object', object: 'ghost', id: 'g1', ctx: { userId: 'bob' }, edit: true, del: true },
1667+
{ what: 'bypass-listed object', object: 'sys_user', id: 'u1', ctx: { userId: 'bob' }, edit: true, del: true },
1668+
];
1669+
1670+
it('canEdit / canDelete are `verdict !== deny` on every branch, and unchanged from the two-state era', async () => {
1671+
for (const c of CASES) {
1672+
const editVerdict = await svc.checkEdit(c.object, c.id, c.ctx);
1673+
const deleteVerdict = await svc.checkDelete(c.object, c.id, c.ctx);
1674+
expect(await svc.canEdit(c.object, c.id, c.ctx), `canEdit — ${c.what}`).toBe(c.edit);
1675+
expect(await svc.canDelete(c.object, c.id, c.ctx), `canDelete — ${c.what}`).toBe(c.del);
1676+
expect(editVerdict !== 'deny', `projection parity, canEdit — ${c.what}`).toBe(c.edit);
1677+
expect(deleteVerdict !== 'deny', `projection parity, canDelete — ${c.what}`).toBe(c.del);
1678+
}
1679+
});
1680+
1681+
it('the abstain set is exactly where the boolean `true` carried no permission', async () => {
1682+
// Anti-vacuity for the table above: three of its `true` cells are abstains,
1683+
// not allows — which is the whole content of this change. If a later edit
1684+
// made these `allow`, the parity test would stay green and the fail-open
1685+
// would be back.
1686+
for (const [object, id] of [['whiteboard', 'w1'], ['note', 'n1'], ['ghost', 'g1'], ['sys_user', 'u1']]) {
1687+
expect(await svc.checkEdit(object, id, { userId: 'bob' }), `${object} must abstain`).toBe('abstain');
1688+
}
1689+
// …and the ones that are real permissions stay `allow`.
1690+
expect(await svc.checkEdit('account', 'a1', { userId: 'alice' })).toBe('allow');
1691+
expect(await svc.checkEdit('account', 'a1', { isSystem: true })).toBe('allow');
1692+
});
1693+
});

0 commit comments

Comments
 (0)