Skip to content

Commit 54f09fb

Browse files
committed
test(dogfood): attachments non-admin permission matrix + orphan lifecycle e2e (#2755)
The dogfood the issue asked for: the #2742 E2E only ever drove a seeded admin. New in-process matrix boots a purpose-built fixture (att_case public / att_secret private+owner / att_nofiles) with the REAL serve pairing (StorageServicePlugin + AuditPlugin) via a new BootOptions.extraPlugins seam in @objectstack/verify, signs up genuine members, and drives the presigned three-step upload + /data path. Pinned findings: - anonymous upload 401; owner_id + uploaded_by server-stamped (spoof attempt loses) - FILES_DISABLED enforced e2e for the first time - ATTACHMENT_PARENT_ACCESS: member cannot attach to an invisible record - DOGFOOD FINDING: the everyone baseline (member_default) carries NO delete bit (ADR-0090 D5) — an ungranted member cannot delete even their own attachment (PERMISSION_DENIED before the attachment gate); apps enable attachment management via an ordinary granted set (the fixture ships one), after which uploader-delete works and stranger-delete stays 403 - KNOWN GAP pins with follow-up pointers: sys_attachment listing does not inherit parent visibility; downloads stay anonymous capability URLs - Part 1 e2e: tombstone on last ref, shared-file survival, re-attach un-tombstone, sweep reaps expired tombstones AND deletes the bytes on disk, NULL deleted_at / fresh tombstones / committed rows survive, hook-bypass re-reference is un-tombstoned by sweep-time re-verification, abandoned pending uploads reaped - multiTenant cross-org block (skips without the enterprise package) Also: guarded reap now deletes per id — the engine's delete path reads where.id as a scalar target, so the previous $in filter was bound as an object by the SQLite driver. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent 265f305 commit 54f09fb

7 files changed

Lines changed: 600 additions & 10 deletions

File tree

packages/dogfood/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
"@objectstack/example-crm": "workspace:*",
1313
"@objectstack/example-showcase": "workspace:*",
1414
"@objectstack/objectql": "workspace:*",
15+
"@objectstack/plugin-audit": "workspace:*",
1516
"@objectstack/plugin-auth": "workspace:*",
1617
"@objectstack/plugin-security": "workspace:*",
18+
"@objectstack/service-storage": "workspace:*",
1719
"@objectstack/spec": "workspace:*",
1820
"@objectstack/verify": "workspace:*"
1921
},

packages/dogfood/test/attachments-permission-matrix.dogfood.test.ts

Lines changed: 468 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Attachments permission-matrix fixture (#2755).
4+
//
5+
// Three tiny objects spanning the enforcement axes of the generic
6+
// Attachments surface (#2727):
7+
//
8+
// att_case — enable.files + public sharing model: any member can read
9+
// (and per Salesforce "can edit parent ⇒ can manage its
10+
// attachments", any member can detach from it).
11+
// att_secret — enable.files + DEFAULT sharing model (omitted ⇒ private,
12+
// ADR-0090) + an `owner_id` field: the sharing service
13+
// read-filters rows to the owner, so a fresh member cannot
14+
// read (→ cannot attach to) another user's record, and
15+
// `canEdit` denies non-owners (→ cannot delete another
16+
// user's attachments on it).
17+
// att_nofiles — NO enable.files: the #2727 opt-in gate (FILES_DISABLED).
18+
//
19+
// No custom SecurityPlugin: a fresh signUp member falls back to the real
20+
// `member_default` wildcard-CRUD set — exactly the posture the issue's
21+
// permission matrix questions (the gates under test are the ones layered on
22+
// TOP of that wildcard).
23+
24+
import { defineStack } from '@objectstack/spec';
25+
import { ObjectSchema, Field } from '@objectstack/spec/data';
26+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
27+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
28+
29+
export const AttCase = ObjectSchema.create({
30+
name: 'att_case',
31+
label: 'Attachment Case',
32+
pluralLabel: 'Attachment Cases',
33+
sharingModel: 'public_read_write',
34+
enable: { files: true },
35+
fields: {
36+
name: Field.text({ label: 'Name', required: true }),
37+
},
38+
});
39+
40+
export const AttSecret = ObjectSchema.create({
41+
name: 'att_secret',
42+
label: 'Attachment Secret',
43+
pluralLabel: 'Attachment Secrets',
44+
// sharingModel omitted — custom object defaults to PRIVATE (ADR-0090);
45+
// owner_id is the sharing service's owner anchor.
46+
enable: { files: true },
47+
fields: {
48+
name: Field.text({ label: 'Name', required: true }),
49+
owner_id: Field.text({ label: 'Owner' }),
50+
},
51+
});
52+
53+
export const AttNoFiles = ObjectSchema.create({
54+
name: 'att_nofiles',
55+
label: 'No Files Here',
56+
pluralLabel: 'No Files Here',
57+
sharingModel: 'public_read_write',
58+
fields: {
59+
name: Field.text({ label: 'Name', required: true }),
60+
},
61+
});
62+
63+
/**
64+
* The domain grant a real app ships when it turns the attachments panel on
65+
* for members: `member_default` (the `everyone` anchor baseline) carries NO
66+
* `allowDelete` (ADR-0090 D5 — delete is not a baseline right), so managing
67+
* attachments requires an ordinary position-distributed set with the delete
68+
* bit on `sys_attachment`. The matrix grants this to some members and pins
69+
* the no-grant baseline with another.
70+
*/
71+
export const attachmentManagerSet: PermissionSet = PermissionSetSchema.parse({
72+
name: 'att_attachment_manager',
73+
label: 'Attachments Fixture — attachment manager',
74+
objects: {
75+
sys_attachment: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
76+
},
77+
});
78+
79+
/** SecurityPlugin carrying the platform defaults + the fixture's domain set. */
80+
export function attachmentsFixtureSecurity(): SecurityPlugin {
81+
return new SecurityPlugin({
82+
defaultPermissionSets: [...securityDefaultPermissionSets, attachmentManagerSet],
83+
});
84+
}
85+
86+
export const attachmentsFixtureStack = defineStack({
87+
manifest: {
88+
id: 'com.dogfood.attachments_fixture',
89+
namespace: 'att',
90+
version: '0.0.0',
91+
type: 'app',
92+
name: 'Attachments Permission Matrix Fixture',
93+
description:
94+
'Three-object app exercising the #2755 attachment permission matrix: parent visibility, uploader/editor delete, enable.files gate.',
95+
},
96+
objects: [AttCase, AttSecret, AttNoFiles],
97+
});

packages/objectql/src/lifecycle/lifecycle-service.test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ describe('LifecycleService.sweep — reap guard', () => {
321321
{ name: 'sys_file', lifecycle: { class: 'transient', ttl: { field: 'deleted_at', expireAfter: '30d' } } },
322322
];
323323

324-
it('deletes only guard-confirmed ids, by $in, after fetching candidates with the cutoff filter', async () => {
324+
it('deletes only guard-confirmed ids, per id, after fetching candidates with the cutoff filter', async () => {
325325
const rows = [
326326
{ id: 'f1', deleted_at: '2020-01-01T00:00:00Z' },
327327
{ id: 'f2', deleted_at: '2020-01-02T00:00:00Z' },
@@ -338,9 +338,9 @@ describe('LifecycleService.sweep — reap guard', () => {
338338
expect(finds[0].where).toEqual({ deleted_at: { $lt: isoCutoff('30d') } });
339339
expect(finds[0].context).toEqual({ isSystem: true, positions: [], permissions: [] });
340340
expect(guard).toHaveBeenCalledWith('sys_file', rows);
341-
expect(deletes).toHaveLength(1);
342-
expect(deletes[0].where).toEqual({ id: { $in: ['f1', 'f3'] } });
343-
expect(deletes[0].multi).toBe(true);
341+
// Per-id deletes — the engine's delete path reads `where.id` as a scalar
342+
// target; a `{$in}` there would be bound as an object by the driver.
343+
expect(deletes.map((d) => d.where)).toEqual([{ id: 'f1' }, { id: 'f3' }]);
344344
expect(report.swept).toEqual([
345345
{ object: 'sys_file', class: 'transient', policy: 'ttl', cutoff: isoCutoff('30d'), deleted: 2 },
346346
]);
@@ -410,9 +410,7 @@ describe('LifecycleService.sweep — reap guard', () => {
410410
const report = await svc.sweep();
411411

412412
expect(finds).toHaveLength(2);
413-
expect(deletes).toHaveLength(2);
414-
expect((deletes[0].where.id.$in as string[]).length).toBe(500);
415-
expect((deletes[1].where.id.$in as string[]).length).toBe(10);
413+
expect(deletes).toHaveLength(510); // 500 confirmed from page 1 + 10 from page 2, per id
416414
expect(report.swept[0].deleted).toBe(510);
417415
});
418416
});

packages/objectql/src/lifecycle/lifecycle-service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -710,14 +710,17 @@ export class LifecycleService {
710710
});
711711
if (!rows?.length) break;
712712
const confirmed = (await guard(object, rows)).filter((id) => id !== null && id !== undefined);
713-
if (confirmed.length > 0) {
713+
// Per-id deletes (NOT a `{$in}` filter): the engine's delete path reads
714+
// `where.id` as a scalar target, and by-id deletes get referential
715+
// cascade handling a filter-delete would bypass.
716+
for (const id of confirmed) {
714717
await engine.delete(object, {
715-
where: { id: { $in: confirmed } },
718+
where: { id },
716719
multi: true,
717720
context: { ...SYSTEM_CTX },
718721
});
719-
total += confirmed.length;
720722
}
723+
total += confirmed.length;
721724
if (confirmed.length < rows.length || rows.length < REAP_GUARD_BATCH_SIZE) break;
722725
}
723726
return total;

packages/verify/src/harness.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ export interface BootOptions {
9393
* default boot stays lean for apps that don't exercise flows. Default `false`.
9494
*/
9595
automation?: boolean;
96+
/**
97+
* Extra plugins to register between the app/service pairs and the
98+
* SecurityPlugin — the slot where `objectstack dev` auto-loads optional
99+
* service pairs the lean harness omits (e.g. `StorageServicePlugin` +
100+
* `AuditPlugin` for the attachments surface). The caller instantiates the
101+
* plugins so `@objectstack/verify` gains no new dependencies. Registered in
102+
* array order. Default `[]`.
103+
*/
104+
extraPlugins?: unknown[];
96105
}
97106

98107
/**
@@ -189,6 +198,13 @@ export async function bootStack(
189198
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
190199
}
191200

201+
// Caller-supplied optional service pairs (see BootOptions.extraPlugins).
202+
// Before SecurityPlugin, mirroring the CLI's ordering for service pairs.
203+
for (const plugin of opts.extraPlugins ?? []) {
204+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
205+
await kernel.use(plugin as any);
206+
}
207+
192208
await kernel.use(opts.security ?? new SecurityPlugin());
193209
// Sharing service — apps that declare `requires: ['sharing']` rely on it for
194210
// record-share grants; without it their RLS/sharing rules are inert and the

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)