Skip to content

Commit 3eb38ae

Browse files
fix(service-storage): a parent-record editor may delete another user's attachment (#21753)
Fixes #21729 Clause-②: yes (widening) ## What this changes A member holding `org_member` who can edit a record may now delete another user's attachment on it. The attachment gate (`installAttachmentAccessHooks`, delete branch) has always declared this rule: the uploader OR a user who can edit the parent record. Before this PR, the platform's row-level delete floor in `member_default` (`owner_only_deletes`: `created_by == current_user.id`, positions `['org_member']`) refused the delete in the security middleware's by-id pre-image step (`security-plugin.ts`, the `PermissionDeniedError` of step 2.7). The gate's parent-editor branch was never reached. This follows the maintainer's ruling A on the card (comment 5979859114): a delete-only alternate match, contributed TOGETHER with the gate that judges it. - **The seam (`@objectstack/plugin-security`).** Before this PR, no plugin other than plugin-security could contribute a row policy. Permission sets resolve by name from metadata, the bootstrap list and `sys_permission_set`, and the human baseline is `member_default` plus an app `isDefault` set. The new method is `contributeOwnershipFloorAlternates(plugin, alternates)` on the registered `security` service. It extends `ISecurityService` the same way `getMetadataReadableFields` and `discardPermissionSetOverlay` do, so there is no `packages/spec` edit. Contributions are keyed by the contributing plugin: a second call replaces the first, and an empty list withdraws it. The seam throws on three kinds of contribution: - an object of `'*'`, which would withdraw the floor instead of relieving it; - an operation that is not one floor limb (`all`, `select`, `insert`); - a policy that does not parse as `RowLevelSecurityPolicySchema`. - **Placement.** `collectRLSPolicies` puts each alternate beside every enabled shipped floor policy of its limb (`withOwnershipFloorAlternates`), and copies that floor policy's `positions`. The alternate therefore reaches exactly the principals the floor binds. It can only add an OR match to a write class the floor already occupies, so it can never make an empty write class non-empty. An empty write class is what triggers the derive-from-select rule, which is the reason `sys_comment_moderation`'s comment gives for its load-bearing domain. Each materialized alternate also carries the floor's provenance (`isPlatformOwnershipFloorPolicy`). Every composition that drops the floor drops its relief too, and `checkAuthoredRowWrite` never reads an alternate as an app-authored widener. - **The contribution (`@objectstack/service-storage`).** `contributeAttachmentDeleteFloorAlternate` contributes `sys_attachment_parent_editor_delete` (`object: 'sys_attachment'`, `operation: 'delete'`, `using: 'id != null'`). Its domain is inherited from the floor: `['org_member']`. The call sits in the same `kernel:ready` branch that installs the gate, right after `installAttachmentAccessHooks`. Without the gate there is no relief. If the security service lacks the seam or refuses the contribution, the plugin logs it at `warn` and the floor stays in force (fail closed). - **Not changed:** `owner_only_deletes`, `member_default`, the floor's edit limb, `attachment-access-hooks.ts`, and `packages/spec`. ## Measured at the door Measured on an org-bound dogfood boot (`orgContext: true`, both members armed with `org_member` and the delete grant): | caller, row | before | after | | --- | --- | --- | | parent editor, not the uploader, `DELETE` | 403 `PERMISSION_DENIED` (floor) | **200**, row gone | | non-uploader who can READ but not EDIT the parent, `DELETE` | 403 `PERMISSION_DENIED` (floor) | 403 `ATTACHMENT_DELETE_DENIED` (gate) | | non-uploader who cannot READ the parent, `DELETE` | 403 `PERMISSION_DENIED` (floor) | 403 `PERMISSION_DENIED` (pre-image: the row is invisible to the caller) | | parent editor, not the uploader, `PATCH` (edit limb) | 403 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED`, row unchanged | | boot WITHOUT service-storage (`SysAttachment` registered by a host plugin): parent editor `DELETE` / creator `DELETE` | n/a | 403 `PERMISSION_DENIED` / 200 | **One deviation from the expected pin, measured and kept.** The REST pin at case (a), memberB on the private `att_secret` parent, now asserts ONE code. That code is `PERMISSION_DENIED`, not `ATTACHMENT_DELETE_DENIED`. memberB cannot read that parent, so read visibility (`installAttachmentReadVisibility`) hides the attachment from them: its by-id GET answers 404. The pre-image re-read refuses a row the caller cannot see before the gate runs. The gate's refusal names the parent object and id, so this order also keeps them from a caller who cannot read that record. The gate's own code is pinned on a parent the caller CAN read (`att_readonly`): `ATTACHMENT_DELETE_DENIED`. Getting the gate's code for an invisible parent would mean bypassing read visibility in the pre-image, and would disclose the parent's identity. ## Pins - `packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts` (declared cross-lane path, test only; no assertion deleted). The matrix boots org-bound with an `assertArmed` precondition. Before this PR it booted org-less, so the floor never applied and case (a) accepted "either" code. The case (a) assertion now checks one code. Three new cases cover the parent-editor 200, the readable non-editor `ATTACHMENT_DELETE_DENIED`, and the untouched edit limb. A new describe boots without service-storage. - `plugin-security/src/ownership-floor-alternates.test.ts` covers the seam's refusals and plugin keying, and the placement beside the enabled shipped floor only, with floor provenance. It also checks **byte-identity**: on a real `SecurityPlugin`, every (principal × object × operation) cell's collected policies AND compiled filter are compared with and without the contribution. The principals are member, org-less, org admin and an MCP agent holding `org_member`. The objects are `sys_attachment`, `sys_comment` and an ordinary object. The operations are delete, update, find and insert. Exactly one cell moves: the member's `sys_attachment` delete. Withdrawing the contribution restores every cell. - `service-storage`: `attachment-delete-floor-alternate.test.ts` covers the contribution and its no-security, no-seam and refused outcomes. `storage-service-plugin.test.ts` checks that the contribution happens exactly once and after the gate, and that nothing is contributed on an engine without a hook seam. ## Ablations Each ablation was run through `scripts/ablation-replace.mjs` with a trap restore, a rebuild and `ablation-dist-preflight.mjs`. Each was restored, and the restore was proven: blob equals HEAD, `git diff HEAD` is empty, the dist marker is back, and every pin is green again. 1. **Drop the contribution.** Prediction: two dogfood cells red, not one. Measured: 2 red. The parent-editor delete answered 403 `PERMISSION_DENIED` instead of 200, and the readable non-editor answered `PERMISSION_DENIED` instead of `ATTACHMENT_DELETE_DENIED`. One storage wiring pin was also red. The other 17 dogfood cells stayed green, including the edit limb and the no-storage boot. 2. **Widen the alternate to `operation: 'all'`.** Prediction: the seam refuses it, so the result matches ablation 1 and the edit-limb pin stays green. Measured: the seam refused it ("is not one floor limb"), the same 2 dogfood cells were red, and the edit-limb pin was green. 2b, relieving the edit limb as well by contributing a second `update` alternate: only the edit-limb pin went red (PATCH answered 200). 3. **Drop the inherited domain in `withOwnershipFloorAlternates`.** Measured: the byte-identity pin went red. The cells that moved were the org-less principal's and the org admin's `sys_attachment` delete, the derive-from-select regression. ## Verification at `158aa1a3dd` - **Gates:** `dispatch-gates --repo objectstack-ai/objectstack --commands` derived 104 families, and all 104 exited 0. `dispatch-gates --ran` reports 104 accounted for and 0 NOT-MEASURED. Three gates needed work before passing: - `check:skill-examples` and `check:dual-build-cjs-loads` first exited 3 (prerequisite: unbuilt dists). They are green after building those packages. - `check:engine-double-contract` was red once, on the new test's engine double. The fix routes `findOne` through `assertEngineFindOnePredicate` and records the ledger row. - **Dogfood:** the attachments dogfood file passes 19 tests, with 1 skipped (block (g) is `skipIf` the organizations package is unavailable). - **Package suites** (at `a90a609a20`; only a dogfood comment changed after it): - `@objectstack/service-storage` `test`: 42 files, 640 tests pass. - `@objectstack/plugin-security` `test`: 165 files, 3551 tests pass and 45 are skipped. - `typecheck` is clean for both packages and for `@objectstack/dogfood`. - `pnpm lint` was not run locally; it is left to CI. ## Docs `content/docs/permissions/attachments-access.mdx`: the delete section now names which refusal answers when, and says that the floor relief exists only where service-storage installs the gate. No `skills/**` sentence is made false. `skills/objectstack-data/rules/security.md` still correctly recommends `owner_only_writes` / `owner_only_deletes`. ## Acceptance notes - **Out of scope, reported to the seat to file (class a, a possible data leak).** This is a pre-existing problem and this PR neither touches nor widens it. A principal outside the floor's `org_member` domain can send a delete for an attachment whose parent it cannot read. The gate's delete-branch refusal (`ATTACHMENT_DELETE_DENIED`) then answers, not the platform's not-visible refusal, and that refusal names the parent record. Members inside the domain are unaffected: read visibility refuses first in the pre-image. The measurement is in the dev report on the card. - The triage note said `sys_attachment` is "registered by platform-objects". Measured: it is DEFINED there and publicly exported (`@objectstack/platform-objects/audit`). In this repo it is REGISTERED only by service-storage's manifest. The ruling's concern still holds, because a host can register the exported object without the storage plugin. The new describe block does exactly that. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7a60e1 commit 3eb38ae

12 files changed

Lines changed: 1055 additions & 13 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
A user who can edit a record may delete another user's attachment on it, as the attachment gate declares (#21729).
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was refused.** The attachment gate's delete rule is "the uploader OR a user who can edit the parent record". For every member holding `org_member`, the platform's row-level delete floor in `member_default` (`owner_only_deletes`: only the rows you created) answered first, so a parent editor's delete of someone else's attachment was refused with `PERMISSION_DENIED` before the gate ran.
11+
- **`@objectstack/service-storage`** contributes a delete-only alternate match for `sys_attachment` (`sys_attachment_parent_editor_delete`, every row) when it installs the attachment gate, and only then. The gate decides: a parent editor's delete answers 200, and a caller who can read the attachment but neither uploaded it nor can edit the parent is refused with `ATTACHMENT_DELETE_DENIED`. A caller who cannot read the parent cannot see the attachment, and is still refused with `PERMISSION_DENIED` before the gate runs, so the parent is not named to them.
12+
- **Without `@objectstack/service-storage`** nothing is contributed. A deployment that registers `sys_attachment` without the storage service keeps the floor, and only a row's creator may delete it.
13+
- **The edit limb is unchanged.** Editing another user's attachment row is still refused by the floor for a member it binds, a parent editor included.
14+
- **`@objectstack/plugin-security`** gains the seam: `contributeOwnershipFloorAlternates(plugin, alternates)` on the registered `security` service, an extension of `ISecurityService` that callers feature-detect. Each alternate names one object (never `'*'`), one floor limb (`update` or `delete`; `all` is refused) and a `using` predicate. It lands beside each enabled floor policy of that limb, in that policy's own `positions` domain, so it reaches only the principals the floor binds. A plugin's second call replaces its first, and an empty list withdraws it. A contribution that breaks these rules throws.
15+
16+
Nothing that was admitted before is refused now. No principal outside the floor's domain, and no other object or operation, changes.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,19 @@ editable by design). A multi-delete requires *every* matched row to pass.
7070

7171
| Code | Status | When |
7272
| --- | --- | --- |
73-
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller is neither the uploader nor able to edit the parent record |
73+
| `ATTACHMENT_DELETE_DENIED` | 403 | The caller can read the attachment, but is neither the uploader nor able to edit the parent record |
74+
| `PERMISSION_DENIED` | 403 | The caller cannot read the parent record, so the attachment is not visible to them; the delete is refused before the attachment gate runs, and the parent is not named |
75+
76+
The platform baseline also ships a parent-blind row-level delete floor
77+
(`owner_only_deletes`: you may delete only the rows you created, for members
78+
holding `org_member`). It would refuse a parent editor before this gate is
79+
asked, so the storage service contributes a delete-only alternate match beside
80+
the floor when it installs the gate, and the gate decides. The alternate match
81+
exists only where the gate does: a deployment that registers `sys_attachment`
82+
without `@objectstack/service-storage` keeps the floor, and there only the row's
83+
creator may delete it. The floor's edit limb is not relieved: a member the
84+
floor binds still cannot edit another user's attachment row
85+
(`PERMISSION_DENIED`), even as a parent editor.
7486

7587
<Callout type="info">
7688
The platform baseline permission set (`member_default`, the `everyone`
@@ -151,7 +163,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
151163
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
152164
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
153165
| List / read | inherits parent read visibility | *(filtered out)* |
154-
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` / `PERMISSION_DENIED` (403) |
166+
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
155167
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
156168

157169
## See also
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21729] The ownership-floor alternate seam — how a plugin other than this
5+
* one stops the platform's `created_by` write floor pre-empting its own row
6+
* gate, registered beside that gate (`ownership-floor-alternates.ts`).
7+
*
8+
* Three things are pinned here, each where it can fail on its own:
9+
*
10+
* 1. THE RULES a contribution is held to — a wildcard object, a non-limb
11+
* operation (`all` included) and a malformed policy are refused loudly, and
12+
* a plugin's contributions are keyed by that plugin.
13+
* 2. THE PLACEMENT — the alternate lands beside an ENABLED SHIPPED floor
14+
* policy of its limb, in that policy's own `positions` domain, and carries
15+
* the floor's provenance; nowhere else does anything change.
16+
* 3. THE BYTE-IDENTITY of every other principal — driven through the real
17+
* plugin's resolution and composition: with the `sys_attachment` delete
18+
* alternate contributed, the ONLY (principal, object, operation) cell whose
19+
* collected policies or compiled filter moves is the in-domain member's
20+
* `sys_attachment` delete. That is the `positions`-domain argument
21+
* `sys_comment_moderation`'s comment makes (an empty write class is the
22+
* derive-from-select trigger), held by construction rather than by a
23+
* hand-written domain.
24+
*/
25+
26+
import { describe, it, expect, vi } from 'vitest';
27+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
28+
import type { RowLevelSecurityPolicy } from '@objectstack/spec/security';
29+
30+
import { SecurityPlugin } from './security-plugin.js';
31+
import { OwnershipFloorAlternates } from './ownership-floor-alternates.js';
32+
import {
33+
isPlatformOwnershipFloorPolicy,
34+
withOwnershipFloorAlternates,
35+
type OwnershipFloorAlternate,
36+
} from './platform-ownership-policies.js';
37+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
38+
39+
/** Written out rather than imported from service-storage: the pin must not move with the module. */
40+
const ATTACHMENT_DELETE: OwnershipFloorAlternate = {
41+
name: 'sys_attachment_parent_editor_delete',
42+
object: 'sys_attachment',
43+
operation: 'delete',
44+
using: 'id != null',
45+
};
46+
47+
const setByName = (name: string) => {
48+
const ps = defaultPermissionSets.find((p) => p.name === name);
49+
if (!ps) throw new Error(`default permission set '${name}' not found`);
50+
return ps;
51+
};
52+
const memberDefaultPolicies = (): RowLevelSecurityPolicy[] =>
53+
(setByName('member_default').rowLevelSecurity ?? []) as RowLevelSecurityPolicy[];
54+
55+
describe('the seam refuses what is not one limb of the floor on one object', () => {
56+
const contribute = (alternate: Record<string, unknown>) => () =>
57+
new OwnershipFloorAlternates().contribute('com.example.contributor', [alternate as never]);
58+
59+
it("object '*' — that withdraws the floor instead of relieving it", () => {
60+
expect(contribute({ ...ATTACHMENT_DELETE, object: '*' })).toThrow(/'com\.example\.contributor'.*object '\*' would withdraw the floor/);
61+
});
62+
63+
it.each(['all', 'select', 'insert', 'purge'])("operation '%s' — not one floor limb", (operation) => {
64+
expect(contribute({ ...ATTACHMENT_DELETE, operation })).toThrow(/is not one floor limb/);
65+
});
66+
67+
it('a policy that does not parse (name not snake_case), an empty predicate, no object', () => {
68+
expect(contribute({ ...ATTACHMENT_DELETE, name: 'Parent Editor Delete' })).toThrow(/not a well-formed row-level security policy/);
69+
expect(contribute({ ...ATTACHMENT_DELETE, using: ' ' })).toThrow(/carries no `using` predicate/);
70+
expect(contribute({ ...ATTACHMENT_DELETE, object: '' })).toThrow(/names no object/);
71+
});
72+
73+
it('an unnamed contributor', () => {
74+
expect(() => new OwnershipFloorAlternates().contribute('', [ATTACHMENT_DELETE])).toThrow(/contributing plugin is not named/);
75+
});
76+
77+
it('keys contributions by plugin: a second call replaces, an empty list withdraws, plugins coexist', () => {
78+
const reg = new OwnershipFloorAlternates();
79+
expect(reg.all()).toEqual([]);
80+
reg.contribute('com.example.a', [ATTACHMENT_DELETE]);
81+
reg.contribute('com.example.a', [ATTACHMENT_DELETE]);
82+
expect(reg.all()).toEqual([ATTACHMENT_DELETE]);
83+
const other = { ...ATTACHMENT_DELETE, name: 'other_delete', object: 'other_object' };
84+
reg.contribute('com.example.b', [other]);
85+
expect(reg.all().map((a) => a.name)).toEqual(['sys_attachment_parent_editor_delete', 'other_delete']);
86+
reg.contribute('com.example.a', []);
87+
expect(reg.all().map((a) => a.name)).toEqual(['other_delete']);
88+
});
89+
90+
it('a refused contribution leaves what was in force untouched', () => {
91+
const reg = new OwnershipFloorAlternates();
92+
reg.contribute('com.example.a', [ATTACHMENT_DELETE]);
93+
expect(() => reg.contribute('com.example.a', [{ ...ATTACHMENT_DELETE, operation: 'all' as never }])).toThrow();
94+
expect(reg.all()).toEqual([ATTACHMENT_DELETE]);
95+
});
96+
});
97+
98+
describe('placement: beside the enabled shipped floor of the limb, in the floor’s domain', () => {
99+
it('lands once, beside owner_only_deletes, domained to org_member, carrying floor provenance', () => {
100+
const out = withOwnershipFloorAlternates(memberDefaultPolicies(), [ATTACHMENT_DELETE]);
101+
const added = out.filter((p) => !memberDefaultPolicies().includes(p as never));
102+
expect(added).toEqual([
103+
{
104+
name: 'sys_attachment_parent_editor_delete',
105+
object: 'sys_attachment',
106+
operation: 'delete',
107+
using: 'id != null',
108+
positions: ['org_member'],
109+
},
110+
]);
111+
const floorIndex = out.findIndex((p) => p.name === 'owner_only_deletes');
112+
expect(out[floorIndex + 1]).toBe(added[0]);
113+
expect(isPlatformOwnershipFloorPolicy(added[0]!)).toBe(true);
114+
// The edit limb gained nothing.
115+
expect(out.filter((p) => p.operation === 'update')).toEqual(
116+
memberDefaultPolicies().filter((p) => p.operation === 'update'),
117+
);
118+
});
119+
120+
it('a set that ships no floor is returned as-is (same array, nothing added)', () => {
121+
for (const name of ['viewer_readonly', 'admin_full_access', 'organization_admin']) {
122+
const policies = (setByName(name).rowLevelSecurity ?? []) as RowLevelSecurityPolicy[];
123+
expect(withOwnershipFloorAlternates(policies, [ATTACHMENT_DELETE]), name).toBe(policies);
124+
}
125+
});
126+
127+
it('a switched-off floor has nothing to relieve', () => {
128+
const policies = memberDefaultPolicies().map((p) => (p.name === 'owner_only_deletes' ? { ...p, enabled: false } : p));
129+
// Still the shipped floor by provenance key — but switched off, so not evaluated.
130+
expect(withOwnershipFloorAlternates(policies, [ATTACHMENT_DELETE])).toBe(policies);
131+
});
132+
133+
it('an authored policy spelling the floor predicate is not the floor, and gains no alternate', () => {
134+
const authored: RowLevelSecurityPolicy[] = [
135+
{ name: 'my_owner_deletes', object: '*', operation: 'delete', using: 'created_by == current_user.id', positions: ['org_member'] },
136+
];
137+
expect(withOwnershipFloorAlternates(authored, [ATTACHMENT_DELETE])).toBe(authored);
138+
});
139+
140+
it('floor provenance is identity, not spelling: an authored copy of the alternate is not the floor', () => {
141+
const authoredTwin = { ...ATTACHMENT_DELETE, positions: ['org_member'] };
142+
expect(isPlatformOwnershipFloorPolicy(authoredTwin)).toBe(false);
143+
});
144+
145+
it('no contribution: the input array itself', () => {
146+
const policies = memberDefaultPolicies();
147+
expect(withOwnershipFloorAlternates(policies, [])).toBe(policies);
148+
});
149+
});
150+
151+
// ── 3. byte-identity through the real plugin ──────────────────────────────
152+
153+
const FIELDS = (extra: string[]) =>
154+
Object.fromEntries(['id', 'created_by', 'organization_id', ...extra].map((f) => [f, { name: f, type: 'text' }]));
155+
const SCHEMAS: Record<string, unknown> = {
156+
sys_attachment: { name: 'sys_attachment', isSystem: true, fields: FIELDS(['parent_object', 'parent_id', 'uploaded_by']) },
157+
sys_comment: { name: 'sys_comment', isSystem: true, fields: FIELDS(['thread_id', 'body']) },
158+
account: { name: 'account', fields: FIELDS(['name']) },
159+
};
160+
161+
const MEMBER = { userId: 'u_member', tenantId: 'org-1', positions: ['org_member', 'everyone'], permissions: [], posture: 'MEMBER' };
162+
const ORGLESS = { userId: 'u_orgless', positions: ['everyone'], permissions: [], posture: 'MEMBER' };
163+
const ORG_ADMIN = { userId: 'u_admin', tenantId: 'org-1', positions: ['org_admin', 'everyone'], permissions: ['organization_admin'], posture: 'ORG_ADMIN' };
164+
const AGENT = {
165+
userId: 'u_agent',
166+
tenantId: 'org-1',
167+
positions: ['org_member', 'everyone'],
168+
permissions: ['mcp_agent_data_write'],
169+
principalKind: 'agent',
170+
posture: 'MEMBER',
171+
};
172+
const PRINCIPALS = { MEMBER, ORGLESS, ORG_ADMIN, AGENT } as const;
173+
const OBJECTS = ['sys_attachment', 'sys_comment', 'account'] as const;
174+
const OPERATIONS = ['delete', 'update', 'find', 'insert'] as const;
175+
176+
async function boot() {
177+
const services: Record<string, unknown> = {
178+
manifest: { register: vi.fn() },
179+
objectql: {
180+
registerMiddleware: vi.fn(),
181+
getSchema: (name: string) => SCHEMAS[name],
182+
find: vi.fn(async () => []),
183+
findOne: vi.fn(async (object: string, options: unknown) => {
184+
// As strict as `ObjectQL.findOne`: this fake never serves a row, so the
185+
// only thing it can get wrong is accepting a call the engine refuses.
186+
assertEngineFindOnePredicate(object, options as never);
187+
return null;
188+
}),
189+
},
190+
metadata: {
191+
get: async (_type: string, name: string) => SCHEMAS[name],
192+
list: async () => [],
193+
},
194+
};
195+
const registerService = vi.fn();
196+
const ctx: Record<string, unknown> = {
197+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
198+
registerService,
199+
getService: (name: string) => {
200+
if (!(name in services)) throw new Error(`service not registered: ${name}`);
201+
return services[name];
202+
},
203+
};
204+
const plugin = new SecurityPlugin();
205+
await plugin.init(ctx as any);
206+
await plugin.start(ctx as any);
207+
const security = registerService.mock.calls.find((c: any[]) => c[0] === 'security')?.[1];
208+
if (typeof security?.contributeOwnershipFloorAlternates !== 'function') {
209+
throw new Error('the registered security service does not expose contributeOwnershipFloorAlternates');
210+
}
211+
return { plugin: plugin as any, security };
212+
}
213+
214+
/** Every cell's collected policies AND compiled filter, as one comparable string per cell. */
215+
async function measure(plugin: any): Promise<Record<string, string>> {
216+
const cells: Record<string, string> = {};
217+
for (const [who, context] of Object.entries(PRINCIPALS)) {
218+
const sets = await plugin.resolvePermissionSetsForContext({ ...context });
219+
for (const object of OBJECTS) {
220+
for (const operation of OPERATIONS) {
221+
const collected = plugin.collectRLSPolicies(sets, object, operation, context.positions);
222+
const filter = await plugin.computeRlsFilter(sets, object, operation, { ...context });
223+
cells[`${who} ${object} ${operation}`] = JSON.stringify({ collected, filter });
224+
}
225+
}
226+
}
227+
return cells;
228+
}
229+
230+
describe('byte-identity: only the in-domain member’s sys_attachment delete moves', () => {
231+
it('contributing the delete alternate changes exactly one cell, and withdrawing it restores every cell', async () => {
232+
const { plugin, security } = await boot();
233+
const before = await measure(plugin);
234+
235+
security.contributeOwnershipFloorAlternates('com.objectstack.service.storage', [ATTACHMENT_DELETE]);
236+
const after = await measure(plugin);
237+
238+
const moved = Object.keys(before).filter((cell) => before[cell] !== after[cell]);
239+
expect(moved).toEqual(['MEMBER sys_attachment delete']);
240+
241+
// The moved cell gained the alternate as an OR beside the floor — the
242+
// floor itself is still there, so it can only widen that class.
243+
const was = JSON.parse(before['MEMBER sys_attachment delete']!);
244+
const now = JSON.parse(after['MEMBER sys_attachment delete']!);
245+
expect(was.collected.map((p: any) => p.name)).toEqual(['owner_only_deletes']);
246+
expect(now.collected.map((p: any) => p.name)).toEqual(['owner_only_deletes', 'sys_attachment_parent_editor_delete']);
247+
expect(now.collected[1].positions).toEqual(['org_member']);
248+
expect(JSON.stringify(now.filter)).not.toBe(JSON.stringify(was.filter));
249+
250+
security.contributeOwnershipFloorAlternates('com.objectstack.service.storage', []);
251+
expect(await measure(plugin)).toEqual(before);
252+
});
253+
254+
it('the seam refuses a widening to both limbs at once, and nothing moves', async () => {
255+
const { plugin, security } = await boot();
256+
const before = await measure(plugin);
257+
expect(() =>
258+
security.contributeOwnershipFloorAlternates('com.objectstack.service.storage', [{ ...ATTACHMENT_DELETE, operation: 'all' }]),
259+
).toThrow(/is not one floor limb/);
260+
expect(await measure(plugin)).toEqual(before);
261+
});
262+
});

0 commit comments

Comments
 (0)