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
21 changes: 21 additions & 0 deletions .changeset/32754-member-attachment-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/spec': patch
'@objectstack/plugin-security': patch
'@objectstack/service-storage': patch
---

fix(service-storage, plugin-security): every signed-in member can attach files to the records they may edit, and see those of the records they may read — the storage service contributes its own attachment set to the member baseline, beside the row gates that scope it

Clause-②: yes (widening)
Metadata-protocol: none

A member who could edit a record could not attach a file to it: no permission set granted the attachment object, so every attach was refused before the attachment row gates were consulted. The storage service now contributes `storage_attachment_member` — read and create on `sys_attachment`, nothing else, no edit and no delete — and every signed-in member holds it through the `everyone` anchor, beside `member_default` (ADR-0090 D5). `member_default` itself is unchanged (ADR-0090 D9). `GET /security/explain` names the set as an additive-baseline contributor.

The set is contributed only once all four attachment gates are installed: the per-object opt-in gate (`enable.files`), the write rule (attach only to a record you may edit), the read-visibility gate (see only the attachments of records you may read) and the download door's parent-record check. All four are the storage service's own: it now enforces the `enable.files` opt-in itself, on every attachment insert and re-point, wherever it runs. A kernel whose data engine cannot carry a gate, or that composes no auth service for the download check, contributes nothing and says at warn level which gate is missing and what to add, so the object stays refused rather than ungated. A host without the storage service, a deployment that declares no member baseline (`fallbackPermissionSet: null`) and external users gain nothing. An agent principal gains nothing in its own right; an agent acting for a member is bounded by that member and by its own scope, so it can now attach where the member can, within that scope.

**Upgrade-day changes.**

- Attachments already stored become visible again to every member who can read the parent record, and a member can attach files to records they can edit. A member who can read a record but not edit it is now refused an attach with `ATTACHMENT_PARENT_ACCESS` instead of `PERMISSION_DENIED` (still 403). Managing other people's attachments — edit and delete — still needs an explicit set distributed by position.
- On a composition that did not mount the capability that enforced the `enable.files` opt-in before, an object that never declared the opt-in now refuses attachments for everyone, administrators and the system path included (`FILES_DISABLED`, 403), as its declaration (default off) says. Attachments already stored on such an object stay as they are: the opt-in judges only a new attachment and a re-point. An object that should take attachments declares `enable: { files: true }`.

`ISecurityService` gains an optional member, `contributePlatformBaselineSet(contributor, set)`, the seam a platform service contributes its baseline set through. It admits only read and create, only on objects the contributing package registered itself, one set per contributor, and only during boot; a wildcard, any other permission bit, a row policy, another package's object or a late call is refused, and every admission and refusal is logged. Feature-detect it: a security service without it contributes nothing, and the contributor keeps its object refused. It is for platform services, not tenant packages.
63 changes: 53 additions & 10 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,39 @@ attachment to it. Enforcement is layered, and every gate is fail-closed.
stored) and never create a `sys_attachment` row, so nothing on this page
applies to them.

## Who holds the object grant

The gates on this page decide the rows. The object grant on `sys_attachment`
only opens the door to them, and every member holds its **read and create**
half with no grant from the app: the storage service
(`@objectstack/service-storage`) contributes a set of its own,
`storage_attachment_member`, to the member baseline that every signed-in member
holds through the `everyone` anchor. It contributes the set beside the gates,
and only once all four are present: the write rule, the read-visibility gate,
the download door's parent-record check and the per-object opt-in gate
(`enable.files`, below). So a member attaches a file to a record they can edit,
and sees the files of a record they can read, and `security/explain` names
`storage_attachment_member` as the baseline that grants it.

The set reaches no one else:

- an external member (`org_external`) resolves its own baseline instead, and an
agent principal resolves no member baseline; an agent acting for a member
gets no more than that member, within its own scope;
- a deployment that declares no member baseline (`fallbackPermissionSet: null`)
hands it to no one;
- a host that registers `sys_attachment` without the storage service gets no
set, and a member's attach is refused `PERMISSION_DENIED` (403). All four
gates are the storage service's own, so wherever it runs they are present,
unless its data engine cannot carry them (no hook or middleware seam) or no
auth service is composed for the download check; then it contributes no set
either, and the boot log names the missing gate and what to add.

The set carries **no edit and no delete** (ADR-0090 D5: a set every member holds
carries no delete bit). Editing or deleting attachments still needs an explicit
grant on `sys_attachment` (see Delete below). An app can also grant read and
create explicitly, as before; the baseline set adds nothing to such a grant.

## The opt-in gate — `enable.files`

Attachments are **opt-in per object** (spec default `false`). A `sys_attachment`
Expand All @@ -33,10 +66,13 @@ Attachments panel renders only for such objects. Any other target is rejected:

| Code | Status | When |
| --- | --- | --- |
| `FILES_DISABLED` | 403 | Creating a `sys_attachment` whose `parent_object` is a registered object that does not declare `enable.files: true` |
| `FILES_DISABLED` | 403 | Creating a `sys_attachment` whose `parent_object` does not declare `enable.files: true` (an object that is not registered included), or re-pointing an existing one onto such an object |

This is enforced by a `beforeInsert` hook on `sys_attachment` and is
independent of the record-level checks below.
The storage service enforces it on every `sys_attachment` insert and re-point,
wherever it runs, for every caller, the system path and an administrator
included. It reads the object's current definition on each write, so an object
switched to `files: true` is attachable at once. It is independent of the
record-level checks below, and answers before them.

## Create — parent-edit access & provenance

Expand Down Expand Up @@ -164,12 +200,14 @@ floor binds still cannot edit another user's attachment row
(`PERMISSION_DENIED`), even as a parent editor.

<Callout type="info">
The platform baseline permission set (`member_default`, the `everyone`
anchor) grants **no delete** on `sys_attachment` (ADR-0090 D5 — delete is not a
baseline right). Attachment management is therefore enabled by an ordinary,
position-distributed permission set that grants `sys_attachment` CRUD. Until a
member holds such a set, a delete is refused by RBAC (`PERMISSION_DENIED`)
before the attachment-level gate above is even consulted.
The member baseline (the `everyone` anchor: `member_default` and the storage
service's `storage_attachment_member`) grants **read and create** on
`sys_attachment` and **no edit or delete** (ADR-0090 D5 — delete is not a
baseline right). Managing attachments — editing or deleting them — is therefore
enabled by an ordinary, position-distributed permission set that grants edit
and delete on `sys_attachment`. Until a member holds such a set, a delete is
refused by RBAC (`PERMISSION_DENIED`) before the attachment-level gate above is
even consulted.
</Callout>

## Download — authenticated & parent-scoped
Expand Down Expand Up @@ -254,13 +292,18 @@ can be shared across records). Reclamation is handled by the platform LifecycleS

## Enforcement summary

Every operation also needs its object grant on `sys_attachment`, refused
`PERMISSION_DENIED` (403) without it. Read and create come with the member
baseline (`storage_attachment_member`, where every attachment gate is
present); edit and delete need an explicit grant.

| Operation | Requirement | Deny code |
| --- | --- | --- |
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
| Attach on upload completion (`attachTo`) | the caller's own pending, unattached `attachments`-scope upload, then every create check | `PERMISSION_DENIED` (403) / `RESOURCE_CONFLICT` (409), then the create codes |
| Attach (create), re-point | a by-id update of the parent record by the caller would be admitted | `ATTACHMENT_PARENT_ACCESS` (403); `OBJECT_NOT_FOUND` (404) when the parent object is not registered |
| List / read | inherits parent read visibility | *(filtered out)* |
| 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 |
| Delete | uploader or parent editor (+ an explicit delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |

## See also
Expand Down
8 changes: 8 additions & 0 deletions content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@ the boot writes no row for them:
| `member_default` | Per-org | Standard end-user CRUD; **writes owner-scoped** via `positions: ['org_member']`-domained RLS | The additive baseline — applies to every authenticated request *in addition to* explicit grants (ADR-0090 D5) |
| `viewer_readonly` | Per-org | Read access only | Auditors / read-only stakeholders |

A platform service can contribute a baseline set of its own beside
`member_default`, through the security service, during boot only, and only for
read and create on objects it registers itself. The storage service contributes
`storage_attachment_member` (read and create on `sys_attachment`), which every
member holds through the `everyone` anchor wherever the storage service installs
its attachment gates; see
[Who holds the object grant](/docs/permissions/attachments-access#who-holds-the-object-grant).

**Who holds `admin_full_access` is not (only) a grant row.** `PLATFORM_ADMIN`
standing is derived at a single site from **two anchors**. Which of them is
sufficient depends on the deployment's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -625,8 +625,11 @@ describe('[ADR-0131 D3/D4] SecurityPlugin runs the report at kernel:bootstrapped
]);

const handlers = b.hooks.get('kernel:bootstrapped') ?? [];
// This report alone: the grant-name backfill that ran ahead of it is retired (ADR-0131 D10).
expect(handlers).toHaveLength(1);
// The platform baseline seam's window close (it re-derives the everyone
// anchor only when a set joined after the derivation began — none here),
// then this report, last. The grant-name backfill that ran ahead of it is
// retired (ADR-0131 D10).
expect(handlers).toHaveLength(2);
for (const handler of handlers.slice(0, -1)) await handler();
const writes = [
vi.spyOn(b.engine, 'insert'), vi.spyOn(b.engine, 'update'), vi.spyOn(b.engine, 'delete'),
Expand Down
Loading
Loading