Skip to content

Commit 265f305

Browse files
committed
feat(storage): parent-derived attachment access + upload auth; drop dead share_type/visibility (#2755)
Part 2 of #2755 (enforce-or-remove, ADR-0049). sys_attachment rows ride the generic data path where the default member sets grant wildcard CRUD — any member could attach files to records they cannot see and delete other users' attachments; uploaded_by was client-spoofable; the storage upload routes were fully anonymous. - service-storage attachment access hooks (Salesforce parent-derived semantics): beforeInsert requires caller READ visibility of the parent record (403 ATTACHMENT_PARENT_ACCESS, caller-scoped findOne so RLS/OWD/ sharing apply) and server-stamps uploaded_by from the session; beforeDelete requires uploader-or-parent-editor (sharing.canEdit; 403 ATTACHMENT_DELETE_DENIED; multi-deletes need every row to pass; read- visibility fallback when no sharing service). System context bypasses. - storage routes: optional resolveSession gate — with an auth service wired, the six upload entry points 401 anonymous callers (fail-closed on resolver errors) and new sys_file rows are stamped with owner_id; without one (bare kernels/tests) routes stay open. Download capability URLs stay anonymous (tracked follow-up). - sys_attachment: share_type/visibility removed — modeled in v1 with zero runtime consumers (ADR-0049 state 3); translation bundles regenerated. - rest: mapDataError branches for the two new 403 codes (#2707 lesson). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187NT3Qer9oep5dCRb9b8Lt
1 parent 10385bd commit 265f305

14 files changed

Lines changed: 454 additions & 99 deletions

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
473473
},
474474
_views: {
475475
mine: {
476-
label: "My Memberships"
476+
label: "My Memberships",
477+
emptyState: {
478+
title: "No organizations yet",
479+
message: "You haven't joined any organizations."
480+
}
477481
}
478482
},
479483
_actions: {
@@ -1354,7 +1358,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
13541358
},
13551359
_views: {
13561360
recent: {
1357-
label: "Recent"
1361+
label: "Recent",
1362+
emptyState: {
1363+
title: "No events",
1364+
message: "No notification events have been emitted."
1365+
}
13581366
},
13591367
by_topic: {
13601368
label: "By Topic"
@@ -1391,23 +1399,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
13911399
size: {
13921400
label: "Size (bytes)"
13931401
},
1394-
share_type: {
1395-
label: "Share Type",
1396-
help: "viewer | collaborator | inferred (inherited from parent record)",
1397-
options: {
1398-
viewer: "viewer",
1399-
collaborator: "collaborator",
1400-
inferred: "inferred"
1401-
}
1402-
},
1403-
visibility: {
1404-
label: "Visibility",
1405-
options: {
1406-
internal: "internal",
1407-
all_users: "all_users",
1408-
shared_users: "shared_users"
1409-
}
1410-
},
14111402
uploaded_by: {
14121403
label: "Uploaded By"
14131404
},

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
473473
},
474474
_views: {
475475
mine: {
476-
label: "My Memberships"
476+
label: "My Memberships",
477+
emptyState: {
478+
title: "No organizations yet",
479+
message: "You haven't joined any organizations."
480+
}
477481
}
478482
},
479483
_actions: {
@@ -1354,7 +1358,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
13541358
},
13551359
_views: {
13561360
recent: {
1357-
label: "Recent"
1361+
label: "Recent",
1362+
emptyState: {
1363+
title: "No events",
1364+
message: "No notification events have been emitted."
1365+
}
13581366
},
13591367
by_topic: {
13601368
label: "By Topic"
@@ -1391,23 +1399,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
13911399
size: {
13921400
label: "Tamaño (bytes)"
13931401
},
1394-
share_type: {
1395-
label: "Tipo de compartición",
1396-
help: "visualizador | colaborador | inferido (heredado del registro principal)",
1397-
options: {
1398-
viewer: "Visualizador",
1399-
collaborator: "Colaborador",
1400-
inferred: "Inferido"
1401-
}
1402-
},
1403-
visibility: {
1404-
label: "Visibilidad",
1405-
options: {
1406-
internal: "Interno",
1407-
all_users: "Todos los usuarios",
1408-
shared_users: "Usuarios compartidos"
1409-
}
1410-
},
14111402
uploaded_by: {
14121403
label: "Subido por"
14131404
},

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
473473
},
474474
_views: {
475475
mine: {
476-
label: "My Memberships"
476+
label: "My Memberships",
477+
emptyState: {
478+
title: "No organizations yet",
479+
message: "You haven't joined any organizations."
480+
}
477481
}
478482
},
479483
_actions: {
@@ -1354,7 +1358,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
13541358
},
13551359
_views: {
13561360
recent: {
1357-
label: "Recent"
1361+
label: "Recent",
1362+
emptyState: {
1363+
title: "No events",
1364+
message: "No notification events have been emitted."
1365+
}
13581366
},
13591367
by_topic: {
13601368
label: "By Topic"
@@ -1391,23 +1399,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
13911399
size: {
13921400
label: "サイズ(バイト)"
13931401
},
1394-
share_type: {
1395-
label: "共有タイプ",
1396-
help: "viewer | collaborator | inferred(親レコードから継承)",
1397-
options: {
1398-
viewer: "閲覧者",
1399-
collaborator: "共同編集者",
1400-
inferred: "推定"
1401-
}
1402-
},
1403-
visibility: {
1404-
label: "公開範囲",
1405-
options: {
1406-
internal: "内部",
1407-
all_users: "すべてのユーザー",
1408-
shared_users: "共有ユーザー"
1409-
}
1410-
},
14111402
uploaded_by: {
14121403
label: "アップロード者"
14131404
},

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
473473
},
474474
_views: {
475475
mine: {
476-
label: "My Memberships"
476+
label: "My Memberships",
477+
emptyState: {
478+
title: "No organizations yet",
479+
message: "You haven't joined any organizations."
480+
}
477481
}
478482
},
479483
_actions: {
@@ -1354,7 +1358,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
13541358
},
13551359
_views: {
13561360
recent: {
1357-
label: "Recent"
1361+
label: "Recent",
1362+
emptyState: {
1363+
title: "No events",
1364+
message: "No notification events have been emitted."
1365+
}
13581366
},
13591367
by_topic: {
13601368
label: "By Topic"
@@ -1391,23 +1399,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
13911399
size: {
13921400
label: "大小(字节)"
13931401
},
1394-
share_type: {
1395-
label: "共享类型",
1396-
help: "viewer | collaborator | inferred(继承自父记录)",
1397-
options: {
1398-
viewer: "查看者",
1399-
collaborator: "协作者",
1400-
inferred: "推断"
1401-
}
1402-
},
1403-
visibility: {
1404-
label: "可见性",
1405-
options: {
1406-
internal: "内部",
1407-
all_users: "全部用户",
1408-
shared_users: "共享用户"
1409-
}
1410-
},
14111402
uploaded_by: {
14121403
label: "上传人"
14131404
},

packages/platform-objects/src/audit/sys-attachment.object.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1717
* - `parent_id` is the parent record's primary key.
1818
* - `(parent_object, parent_id)` is the natural index for the
1919
* "files on this record" lookup.
20-
* - `share_type` follows the Salesforce convention: V = Viewer,
21-
* C = Collaborator, I = Inferred (inherited from parent record).
20+
* - Access is derived from the PARENT record (Salesforce semantics),
21+
* enforced by service-storage's attachment access hooks (#2755).
22+
* Salesforce's `ShareType`/`Visibility` dials were modeled here in v1
23+
* but had no runtime consumer — removed per ADR-0049 enforce-or-remove;
24+
* reintroduce them together with their enforcement if ever needed.
2225
*
2326
* @namespace sys
2427
*/
@@ -90,26 +93,6 @@ export const SysAttachment = ObjectSchema.create({
9093
group: 'File',
9194
}),
9295

93-
// ── Sharing ────────────────────────────────────────────────
94-
share_type: Field.select(
95-
['viewer', 'collaborator', 'inferred'],
96-
{
97-
label: 'Share Type',
98-
defaultValue: 'viewer',
99-
description: 'viewer | collaborator | inferred (inherited from parent record)',
100-
group: 'Sharing',
101-
},
102-
),
103-
104-
visibility: Field.select(
105-
['internal', 'all_users', 'shared_users'],
106-
{
107-
label: 'Visibility',
108-
defaultValue: 'internal',
109-
group: 'Sharing',
110-
},
111-
),
112-
11396
// ── Authoring ──────────────────────────────────────────────
11497
uploaded_by: Field.lookup('sys_user', {
11598
label: 'Uploaded By',

packages/rest/src/rest-server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export function mapDataError(error: any, object?: string): { status: number; bod
106106
},
107107
};
108108
}
109+
// Attachment access gates (#2755): service-storage's engine hooks reject
110+
// sys_attachment writes fail-closed when the caller cannot see the parent
111+
// record (create) or is neither the uploader nor a parent editor
112+
// (delete). Same mapping rationale as the capability gates above.
113+
if (error?.code === 'ATTACHMENT_PARENT_ACCESS' || error?.code === 'ATTACHMENT_DELETE_DENIED') {
114+
return {
115+
status: 403,
116+
body: {
117+
error: error?.message ?? 'Attachment access denied',
118+
code: error.code,
119+
...(error?.object || object ? { object: error?.object ?? object } : {}),
120+
},
121+
};
122+
}
109123
// Short-circuit: explicit security denial → 403. Match by `code` /
110124
// `name` to avoid pulling a runtime dependency on plugin-security.
111125
if (

packages/rest/src/rest.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,6 +2110,36 @@ describe('mapDataError — schema/constraint envelopes', () => {
21102110
expect(r.body.object).toBe('crm_lead');
21112111
});
21122112

2113+
// #2755: attachment access gates from service-storage's engine hooks —
2114+
// same generic-data-path rationale as the capability gates above.
2115+
it('maps ATTACHMENT_PARENT_ACCESS → 403 with the parent object', () => {
2116+
const r = mapDataError(
2117+
Object.assign(new Error('Cannot attach to crm_lead/rec1: the parent record does not exist or you do not have access to it'), {
2118+
code: 'ATTACHMENT_PARENT_ACCESS',
2119+
status: 403,
2120+
object: 'crm_lead',
2121+
}),
2122+
'sys_attachment',
2123+
);
2124+
expect(r.status).toBe(403);
2125+
expect(r.body.code).toBe('ATTACHMENT_PARENT_ACCESS');
2126+
expect(r.body.object).toBe('crm_lead');
2127+
});
2128+
2129+
it('maps ATTACHMENT_DELETE_DENIED → 403', () => {
2130+
const r = mapDataError(
2131+
Object.assign(new Error('Cannot delete attachment a1: only the uploader or a user who can edit the parent record (crm_lead/rec1) may delete it'), {
2132+
code: 'ATTACHMENT_DELETE_DENIED',
2133+
status: 403,
2134+
object: 'crm_lead',
2135+
}),
2136+
'sys_attachment',
2137+
);
2138+
expect(r.status).toBe(403);
2139+
expect(r.body.code).toBe('ATTACHMENT_DELETE_DENIED');
2140+
expect(r.body.object).toBe('crm_lead');
2141+
});
2142+
21132143
it('maps SQLite "has no column named" → 400 INVALID_FIELD with the field', () => {
21142144
const r = mapDataError(
21152145
sqliteError(

0 commit comments

Comments
 (0)