Skip to content

Commit 8f0f8f7

Browse files
author
flash
committed
feat: immutable attribute + container-specific permissions
Add two features to the decomposedfs storage driver: 1. Container-specific permissions (cs3org/cs3apis#272): - DeleteContainer/MoveContainer checks in Delete/Move handlers - ACL encoding: +dc/!dc, +mc/!mc (with substring collision fix) - Roles updated: Editor/Manager/Coowner get DeleteContainer/MoveContainer - SetImmutableFile/SetImmutableContainer permissions on Manager/Coowner 2. Immutable attribute (freeze/protect): - xattr: user.oc.immutable - File (freeze): content fixed, irreversible - Container (protect): structure fixed, reversible by managers - Self vs parent rule: ImmutableState = Frozen/Protected/None - Node methods: FreezeFile, ProtectContainer, UnprotectContainer - Storage interface: SetImmutable/UnsetImmutable with permission checks - GRPC handlers: storageprovider + gateway pass-through - Handler checks: Delete, Move, CreateDir, Upload - Stat: ResourceInfo.Immutable + Opaque immutable-state - WebDAV: oc:immutable property + D/NV strip from oc:permissions - OwnerPermissions/AddPermissions updated Tests: 21 new (node, handler, grants ACL), all pass. 2 pre-existing failures (UpdateGrant/DenyGrant ACL round-trip).
1 parent 11d88fa commit 8f0f8f7

28 files changed

Lines changed: 1102 additions & 51 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Patch Documentation: Immutable & Container-Specific Permissions
2+
3+
## Status
4+
5+
- **cs3org/cs3apis#272**: MERGED (4 Jun 2026)
6+
- **cs3org/go-cs3apis**: Regenerated (c3fdb0aa5e9e)
7+
- **opencloud-eu/reva#674**: go-cs3apis update PR (Housekeeping, waiting for review)
8+
- **cs3org/reva#5628**: Open, ACL encoding
9+
- **opencloud-eu/opencloud#2841**: EditorLitePlus role (independent, ready)
10+
- Feature branch ready, compiles with new go-cs3apis.
11+
12+
## Overview
13+
14+
Two features spanning 4 repositories:
15+
16+
1. **Container-specific permissions** (`DeleteContainer`, `MoveContainer`) — separate file and directory delete/move operations
17+
2. **Immutable attribute** (freeze/protect) — persistent xattr to protect resources from modification
18+
19+
Reference: [cs3org/cs3apis#272](https://github.com/cs3org/cs3apis/pull/272)
20+
21+
## Repositories and Branches
22+
23+
| Repo | Branch | PR | Purpose |
24+
|------|--------|-----|---------|
25+
| `flash7777/reva` | `chore/update-go-cs3apis` | [opencloud-eu/reva#674](https://github.com/opencloud-eu/reva/pull/674) | go-cs3apis update + stubs (Housekeeping) |
26+
| `flash7777/reva` | `feature/immutable-decomposedfs` | Pending (after #674) | decomposedfs, roles, ACL, WebDAV, tests |
27+
| `flash7777/opencloud` | `feature/immutable-and-container-permissions` | Pending | Graph API actions |
28+
| `flash7777/opencloud` | `feature/editor-light-role` | [opencloud-eu/opencloud#2841](https://github.com/opencloud-eu/opencloud/pull/2841) | EditorLitePlus role |
29+
| `flash7777/web` | `feature/immutable-ui` | Pending | Frontend: Resource model, icons, sidebar |
30+
31+
## Release Strategy
32+
33+
```
34+
cs3org/cs3apis#272 MERGED ✓
35+
36+
cs3org/go-cs3apis regenerated ✓ (c3fdb0aa5e9e)
37+
38+
PR 1: opencloud-eu/reva#674 — go-cs3apis update + stubs (waiting for review)
39+
40+
PR 2: opencloud-eu/reva — feature/immutable-decomposedfs (rebase on #674, then submit)
41+
42+
PR 3: opencloud-eu/opencloud — Graph API actions (after reva release with PR 2)
43+
44+
PR 4: opencloud-eu/web — Frontend (independent, can merge anytime)
45+
```
46+
47+
Note: `flash7777/reva` is a fork of cs3org/reva (same GitHub fork network as
48+
opencloud-eu/reva), which allows cross-repo PRs against opencloud-eu/reva.
49+
`flash7777/reva-eu` is a standalone repo (cannot create PRs).
50+
51+
## What was changed — by layer
52+
53+
### Layer 1: CS3 Proto Spec
54+
55+
**cs3org/cs3apis#272** — APPROVED
56+
57+
`ResourcePermissions` new fields:
58+
- `delete_container` (21) — can delete containers
59+
- `move_container` (22) — can move/rename containers
60+
- `set_immutable_file` (23) — can freeze files (irreversible)
61+
- `set_immutable_container` (24) — can protect/unprotect containers
62+
63+
`ResourceInfo` new field:
64+
- `immutable` (20) — resource is frozen/protected
65+
66+
`ProviderAPI` new RPCs:
67+
- `SetImmutable` / `UnsetImmutable`
68+
69+
Immutable semantics documented in `docs/proposals/immutable-overview.md`:
70+
- File (freeze): content fixed, irreversible
71+
- Container (protect): structure fixed, reversible by manager/admin
72+
- Self vs. parent rule: object is immutable if own OR parent attribute is set
73+
74+
### Layer 2: Go Bindings
75+
76+
**cs3org/go-cs3apis** — will be regenerated by CERN after cs3apis#272 merge.
77+
78+
`flash7777/go-cs3apis` was used for local testing only. The reva branch
79+
has **no** `replace` directive — it references the upstream go-cs3apis and
80+
will compile once the fields are available.
81+
82+
### Layer 3: Reva Storage (opencloud-eu/reva)
83+
84+
#### Roles (`pkg/conversions/role.go`)
85+
86+
| Role | DeleteContainer | MoveContainer | SetImmutableFile | SetImmutableContainer |
87+
|------|:-:|:-:|:-:|:-:|
88+
| Viewer | - | - | - | - |
89+
| EditorLite | - | - | - | - |
90+
| Editor | **true** | **true** | - | - |
91+
| SpaceEditor | **true** | **true** | - | - |
92+
| SpaceEditorWithoutVersions | **true** | **true** | - | - |
93+
| Coowner | **true** | **true** | **true** | **true** |
94+
| Manager | **true** | **true** | **true** | **true** |
95+
96+
**Breaking change:** Directory delete/move requires explicit `DeleteContainer`/`MoveContainer`.
97+
98+
#### Storage Interface (`pkg/storage/storage.go`)
99+
100+
New methods: `SetImmutable(ctx, ref)`, `UnsetImmutable(ctx, ref)`
101+
102+
#### decomposedfs Implementation
103+
104+
**xattr:** `user.oc.immutable` (`prefixes.go`)
105+
106+
**Node methods** (`node/node.go`):
107+
- `ImmutableState` enum: `ImmutableNone` (0), `ImmutableProtected` (1), `ImmutableFrozen` (2)
108+
- `IsImmutable()` — check self
109+
- `GetImmutableState()` — effective state (self + parent)
110+
- `FreezeFile()` — irreversible, rejects dirs
111+
- `ProtectContainer()` — rejects files
112+
- `UnprotectContainer()` — rejects files
113+
114+
**Handler checks** (`decomposedfs.go`):
115+
- `Delete`: `DeleteContainer` check + immutable self/parent
116+
- `Move`: `MoveContainer` check + immutable source/target
117+
- `CreateDir`: parent immutable check
118+
- `SetImmutable()`: permission check (`SetImmutableFile`/`SetImmutableContainer`)
119+
- `UnsetImmutable()`: rejects files, checks permission
120+
121+
**Upload** (`upload.go`):
122+
- Frozen file → no overwrite
123+
- Protected parent → no new file, no modification
124+
125+
**Stat** (`node.go` `AsResourceInfo()`):
126+
- `ResourceInfo.Immutable` set from xattr
127+
- `immutable-state` in Opaque: `frozen` or `protected`
128+
129+
#### ACL Encoding (`pkg/storage/utils/grants/grants.go`)
130+
131+
New flags: `+dc`/`!dc`, `+mc`/`!mc`
132+
Fix: strip container flags before parsing `!d` to avoid substring collision.
133+
134+
#### WebDAV PROPFIND (`internal/http/services/owncloud/ocdav/propfind/`)
135+
136+
- `oc:permissions`: strip `D` (delete) and `NV` (rename/move) when effectively immutable
137+
- `oc:immutable`: new property — `frozen`, `protected`, or NotFound
138+
- Both allprops and named property requests supported
139+
140+
#### Legacy drivers
141+
142+
All non-decomposedfs drivers get `NotSupported` stubs.
143+
144+
### Layer 4: Graph API (opencloud-eu/opencloud)
145+
146+
New LibreGraph actions:
147+
- `libre.graph/driveItem/container/delete`
148+
- `libre.graph/driveItem/container/move`
149+
- `libre.graph/driveItem/immutable/file/set`
150+
- `libre.graph/driveItem/immutable/container/set`
151+
152+
Bidirectional mapping in `conversion.go`.
153+
Vendor patches for go-cs3apis and reva role.go (temporary until dependency update).
154+
155+
### Layer 5: Web Frontend (opencloud-eu/web)
156+
157+
- `DavProperty.Immutable`: new WebDAV property, added to Default request list
158+
- `Resource.immutableState`: `'frozen' | 'protected' | undefined`
159+
- `canBeDeleted()` / `canRename()`: return `false` when immutableState is set
160+
- `FileDetails.vue` sidebar: shield icon (filled = frozen, outline = protected) with label
161+
162+
### Layer 6: EditorLitePlus Role (independent)
163+
164+
**opencloud-eu/opencloud#2841** — new sharing role: edit without delete.
165+
Uses only existing CS3 permissions, no dependency on cs3apis#272.
166+
167+
## Tests
168+
169+
| Suite | Tests | Status |
170+
|-------|:-----:|:------:|
171+
| Node (IsImmutable, GetImmutableState, Freeze, Protect, Unprotect, Parent rule) | 6 | PASS |
172+
| Handler (Delete/Move/CreateDir frozen/protected, Upload protected, SetImmutable permissions) | 7 | PASS |
173+
| Grants ACL (+dc/!dc/+mc/!mc encoding, substring collision) | 8 | PASS |
174+
| Existing tests (recycle, delete) updated with DeleteContainer | 7 fixes | PASS |
175+
| Total new/modified tests | **28** | **All PASS** |
176+
177+
`posix/tree` failure is pre-existing on main.
178+
179+
## Concepts: Lock vs. Protected vs. Frozen
180+
181+
| | Lock | Protected | Frozen |
182+
|---|---|---|---|
183+
| Purpose | Collaborative editing | Structure protection (parent) | Content protection (self) |
184+
| Duration | Temporary (expires) | Until manager removes | **Permanent** (files) |
185+
| Set by | Any user with write access | Manager/Admin | Manager/Admin |
186+
| Affects | Parallel writes | Delete/move/rename of object | All modifications |
187+
| Reversible | Yes (expires or unlock) | Yes (UnprotectContainer) | **No** (files) / Yes (containers) |
188+
| Icon | 🔒 Lock | 🛡️ Shield outline | 🛡️ Shield filled |
189+
| WebDAV | `d:lockdiscovery` | `oc:immutable="protected"` | `oc:immutable="frozen"` |
190+
191+
## Migration
192+
193+
No data migration needed. The `user.oc.immutable` xattr is only set explicitly via `SetImmutable()`. Existing deployments are unaffected.
194+
195+
`DeleteContainer`/`MoveContainer` are added to all existing roles that have `Delete`/`Move`. No grants break — OpenCloud uses Unified Roles exclusively, not manual permission grants.
196+
197+
## Dependency chain
198+
199+
```
200+
cs3org/cs3apis#272 → cs3org/go-cs3apis → opencloud-eu/reva → opencloud-eu/opencloud
201+
→ opencloud-eu/web
202+
```
203+
204+
No personal forks in any dependency. The flash7777/* forks were used for
205+
development and local testing only. PRs will be created against the
206+
upstream repos once go-cs3apis has the new fields.

internal/grpc/services/gateway/storageprovider.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,40 @@ func (s *svc) Unlock(ctx context.Context, req *provider.UnlockRequest) (*provide
919919
return res, nil
920920
}
921921

922+
func (s *svc) SetImmutable(ctx context.Context, req *provider.SetImmutableRequest) (*provider.SetImmutableResponse, error) {
923+
c, _, err := s.find(ctx, req.Ref)
924+
if err != nil {
925+
return &provider.SetImmutableResponse{
926+
Status: status.NewStatusFromErrType(ctx, "SetImmutable ref="+req.Ref.String(), err),
927+
}, nil
928+
}
929+
res, err := c.SetImmutable(ctx, req)
930+
if err != nil {
931+
if gstatus.Code(err) == codes.PermissionDenied {
932+
return &provider.SetImmutableResponse{Status: &rpc.Status{Code: rpc.Code_CODE_PERMISSION_DENIED}}, nil
933+
}
934+
return nil, errors.Wrap(err, "gateway: error calling SetImmutable")
935+
}
936+
return res, nil
937+
}
938+
939+
func (s *svc) UnsetImmutable(ctx context.Context, req *provider.UnsetImmutableRequest) (*provider.UnsetImmutableResponse, error) {
940+
c, _, err := s.find(ctx, req.Ref)
941+
if err != nil {
942+
return &provider.UnsetImmutableResponse{
943+
Status: status.NewStatusFromErrType(ctx, "UnsetImmutable ref="+req.Ref.String(), err),
944+
}, nil
945+
}
946+
res, err := c.UnsetImmutable(ctx, req)
947+
if err != nil {
948+
if gstatus.Code(err) == codes.PermissionDenied {
949+
return &provider.UnsetImmutableResponse{Status: &rpc.Status{Code: rpc.Code_CODE_PERMISSION_DENIED}}, nil
950+
}
951+
return nil, errors.Wrap(err, "gateway: error calling UnsetImmutable")
952+
}
953+
return res, nil
954+
}
955+
922956
// Stat returns the Resoure info for a given resource by forwarding the request to the responsible provider.
923957
func (s *svc) Stat(ctx context.Context, req *provider.StatRequest) (*provider.StatResponse, error) {
924958
c, _, ref, err := s.findAndUnwrap(ctx, req.Ref)

internal/grpc/services/storageprovider/storageprovider.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,14 +325,16 @@ func (s *Service) ListResourcesForLabel(ctx context.Context, req *labels.ListRes
325325
}
326326

327327
func (s *Service) SetImmutable(ctx context.Context, req *provider.SetImmutableRequest) (*provider.SetImmutableResponse, error) {
328+
err := s.Storage.SetImmutable(ctx, req.Ref)
328329
return &provider.SetImmutableResponse{
329-
Status: status.NewUnimplemented(ctx, nil, "SetImmutable not yet implemented"),
330+
Status: status.NewStatusFromErrType(ctx, "set immutable", err),
330331
}, nil
331332
}
332333

333334
func (s *Service) UnsetImmutable(ctx context.Context, req *provider.UnsetImmutableRequest) (*provider.UnsetImmutableResponse, error) {
335+
err := s.Storage.UnsetImmutable(ctx, req.Ref)
334336
return &provider.UnsetImmutableResponse{
335-
Status: status.NewUnimplemented(ctx, nil, "UnsetImmutable not yet implemented"),
337+
Status: status.NewStatusFromErrType(ctx, "unset immutable", err),
336338
}, nil
337339
}
338340

internal/http/services/owncloud/ocdav/propfind/propfind.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,17 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p
11361136
false,
11371137
isPublic,
11381138
)
1139+
// Strip delete/move flags from permissions if resource is effectively immutable
1140+
isEffectivelyImmutable := md.Immutable
1141+
if !isEffectivelyImmutable && md.Opaque != nil {
1142+
if v, ok := md.Opaque.Map["immutable-state"]; ok && string(v.Value) == "protected" {
1143+
isEffectivelyImmutable = true
1144+
}
1145+
}
1146+
if isEffectivelyImmutable {
1147+
wdp = strings.ReplaceAll(wdp, "D", "")
1148+
wdp = strings.ReplaceAll(wdp, "NV", "")
1149+
}
11391150
}
11401151

11411152
// replace fileid of /public/{token} mountpoint with grant fileid
@@ -1229,6 +1240,15 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p
12291240
appendToOK(prop.Escaped("oc:permissions", wdp))
12301241
}
12311242

1243+
// Immutable state — always return in allprops if set
1244+
if md.Immutable {
1245+
appendToOK(prop.Escaped("oc:immutable", "frozen"))
1246+
} else if md.Opaque != nil {
1247+
if v, ok := md.Opaque.Map["immutable-state"]; ok && string(v.Value) == "protected" {
1248+
appendToOK(prop.Escaped("oc:immutable", "protected"))
1249+
}
1250+
}
1251+
12321252
// always return size, well nearly always ... public link shares are a little weird
12331253
if md.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER {
12341254
appendToOK(prop.Raw("d:resourcetype", "<d:collection/>"))
@@ -1362,6 +1382,18 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p
13621382
// M = Mounted
13631383
// in contrast, the ocs:share-permissions further down below indicate clients the maximum permissions that can be granted
13641384
appendToOK(prop.Escaped("oc:permissions", wdp))
1385+
case "immutable":
1386+
if md.Immutable {
1387+
appendToOK(prop.Escaped("oc:immutable", "frozen"))
1388+
} else if md.Opaque != nil {
1389+
if v, ok := md.Opaque.Map["immutable-state"]; ok && string(v.Value) == "protected" {
1390+
appendToOK(prop.Escaped("oc:immutable", "protected"))
1391+
} else {
1392+
appendToNotFound(prop.NotFound("oc:immutable"))
1393+
}
1394+
} else {
1395+
appendToNotFound(prop.NotFound("oc:immutable"))
1396+
}
13651397
case "public-link-permission": // only on a share root node
13661398
if ls != nil && md.PermissionSet != nil {
13671399
appendToOK(prop.Escaped("oc:public-link-permission", role.OCSPermissions().String()))

0 commit comments

Comments
 (0)