Skip to content

Commit 0ee5cd5

Browse files
committed
feat: Shared drive recipients can modify shared drive members
1 parent 6ec3afb commit 0ee5cd5

File tree

7 files changed

+411
-59
lines changed

7 files changed

+411
-59
lines changed

docs/shared-drives.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,10 +676,63 @@ drive.
676676
The following routes manage share-by-link permissions scoped to files inside a
677677
shared drive:
678678

679+
- `GET /sharings/drives/:id/permissions?ids=...`
679680
- `POST /sharings/drives/:id/permissions`
680681
- `PATCH /sharings/drives/:id/permissions/:perm-id`
681682
- `DELETE /sharings/drives/:id/permissions/:perm-id`
682683

684+
### GET /sharings/drives/:id/permissions?ids=...
685+
686+
Lists the share-by-link permissions for the requested file or folder IDs inside
687+
the shared drive.
688+
689+
Authorization rules:
690+
691+
- The shared-drive owner can list all matching links.
692+
- A write-capable recipient can list writable and read-only links.
693+
- A read-only recipient only sees read-only links.
694+
695+
Validation:
696+
697+
- `ids` is required and must be a comma-separated list of file or folder IDs.
698+
- Every requested ID must belong to the shared drive.
699+
700+
Status codes:
701+
702+
- `200 OK` listed
703+
- `403 Forbidden` caller cannot access shared-drive permissions
704+
- `422 Unprocessable Entity` missing or invalid `ids`
705+
706+
### POST /sharings/drives/:id/permissions
707+
708+
Creates a share-by-link permission for one file or folder in the shared drive.
709+
The request body uses the same JSON:API shape as [`POST /permissions`](permissions.md#post-permissions).
710+
711+
Authorization rules:
712+
713+
- The shared-drive owner can create a link.
714+
- A write-capable recipient can create a link.
715+
- A read-only recipient cannot create a link.
716+
717+
Validation:
718+
719+
- The permission set must target exactly one file or folder.
720+
- The target type must be `io.cozy.files`.
721+
- Selectors are not supported.
722+
- The target must belong to the shared drive and must be readable by the
723+
caller.
724+
- Only one share-by-link permission can exist per target. A second creation
725+
attempt on the same target returns a conflict, regardless of which member
726+
created the existing link.
727+
728+
Status codes:
729+
730+
- `200 OK` created
731+
- `400 Bad Request` invalid permission set or invalid target
732+
- `403 Forbidden` caller lacks access to the target or is read-only on the
733+
shared drive
734+
- `409 Conflict` a share-by-link permission already exists for this target
735+
683736
### PATCH /sharings/drives/:id/permissions/:perm-id
684737

685738
Updates an existing share-by-link permission.
@@ -696,12 +749,19 @@ Allowed updates:
696749

697750
- `password`
698751
- `expires_at`
752+
- `permissions` (same target only)
699753

700754
Validation:
701755

702756
- `password` must be a string (empty string clears the password).
703757
- `expires_at` must be a string (empty string clears expiration, otherwise
704758
RFC3339 date-time).
759+
- `permissions`, when provided, must still target the same file or folder
760+
inside the shared drive.
761+
- A write-capable creator or the owner can promote a read-only link to a
762+
writable link if their current token grants those verbs.
763+
- A read-only shared-drive recipient cannot patch a permission set to add
764+
writable verbs.
705765

706766
Status codes:
707767

@@ -721,6 +781,7 @@ Authorization rules:
721781
- The shared-drive owner can revoke any share-by-link permission.
722782
- The creator of a share-by-link permission can revoke the permission they
723783
created.
784+
- A read-only shared-drive recipient cannot revoke a permission.
724785
- Public share tokens (`share`, `share-preview`) cannot revoke permissions.
725786

726787
Status codes:
@@ -731,6 +792,23 @@ Status codes:
731792
to this shared drive
732793
- `404 Not Found` permission ID does not exist
733794

795+
## Delegated email sharing
796+
797+
Members of a shared drive add new recipients through the sharing API, not
798+
through a drive-specific route:
799+
800+
- `POST /sharings/:sharing-id/recipients`
801+
802+
When that request is sent from a recipient Cozy, the stack delegates the
803+
operation to the owner Cozy internally.
804+
805+
Authorization rules:
806+
807+
- The shared-drive owner can add recipients as for any other sharing.
808+
- A write-capable recipient can invite read-write or read-only recipients.
809+
- A read-only recipient can invite only read-only recipients.
810+
- A read-only recipient receives `403 Forbidden` for a read-write invite.
811+
734812

735813
## Versions
736814

docs/sharing.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,9 +1046,13 @@ Content-Type: application/vnd.api+json
10461046
### POST /sharings/:sharing-id/recipients/delegated
10471047

10481048
This is an internal route for the stack. It is called by the recipient cozy on
1049-
the owner cozy to add recipients and groups to the sharing (`open_sharing:
1050-
true` only). Data for direct recipients should contain an email address but if
1051-
it is not known, an instance URL can also be provided.
1049+
the owner cozy to add recipients and groups to the sharing. It is used for
1050+
delegated recipient additions from recipient Cozys, including shared-drive
1051+
recipient invitations. Data for direct recipients should contain an email
1052+
address but if it is not known, an instance URL can also be provided. For
1053+
shared drives, each delegated recipient or group can also carry a `read_only`
1054+
flag, and the owner applies the drive-specific reshare rules when processing
1055+
the request.
10521056

10531057
#### Request
10541058

model/sharing/member.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,10 +374,14 @@ func (s *Sharing) SendDelegated(inst *instance.Instance, api *APIDelegateAddCont
374374
ParseError: ParseRequestError,
375375
}
376376
res, err := request.Req(opts)
377+
originalRes, originalErr := res, err
377378
if res != nil && res.StatusCode/100 == 4 {
378379
res, err = RefreshToken(inst, res, err, s, &s.Members[0], c, opts, body)
379380
}
380381
if err != nil {
382+
if originalRes != nil && originalRes.StatusCode == http.StatusForbidden {
383+
return preserveDelegatedRequestError(originalRes.StatusCode, originalErr)
384+
}
381385
return err
382386
}
383387
defer res.Body.Close()
@@ -433,8 +437,33 @@ func (s *Sharing) SendDelegated(inst *instance.Instance, api *APIDelegateAddCont
433437
return s.SendInvitationsToMembers(inst, api.members, states)
434438
}
435439

436-
// AddDelegatedContact adds a contact on the owner cozy, but for a contact from
437-
// a recipient (open_sharing: true only)
440+
func preserveDelegatedRequestError(status int, err error) error {
441+
reqErr, ok := err.(*request.Error)
442+
if !ok {
443+
return jsonapi.NewError(status, http.StatusText(status))
444+
}
445+
446+
var payload struct {
447+
Errors jsonapi.ErrorList `json:"errors"`
448+
}
449+
if parseErr := json.Unmarshal([]byte(reqErr.Detail), &payload); parseErr == nil && len(payload.Errors) > 0 {
450+
if payload.Errors[0].Status == 0 {
451+
payload.Errors[0].Status = status
452+
}
453+
if payload.Errors[0].Title == "" {
454+
payload.Errors[0].Title = http.StatusText(status)
455+
}
456+
return payload.Errors[0]
457+
}
458+
459+
if reqErr.Detail != "" {
460+
return jsonapi.NewError(status, reqErr.Detail)
461+
}
462+
return jsonapi.NewError(status, http.StatusText(status))
463+
}
464+
465+
// AddDelegatedContact adds a contact on the owner cozy for a contact proposed
466+
// by a recipient.
438467
func (s *Sharing) AddDelegatedContact(inst *instance.Instance, m Member) (string, error) {
439468
m.Status = MemberStatusPendingInvitation
440469
if m.Instance != "" || m.Email == "" {

web/sharings/drives_permissions_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,17 @@ func TestSharedDriveShareByLinkCreate(t *testing.T) {
355355
deleteSharedDrivePermissionExpectStatus(t, eBetty, f.sharingID, permID, bettyToken, http.StatusNoContent)
356356
})
357357

358+
t.Run("ReadOnlyRecipientCannotCreateShareByLink", func(t *testing.T) {
359+
eDave, daveToken := f.newDaveClient(t)
360+
sharingID, productID := createReadOnlyDriveForDave(t, f)
361+
fileID := createFile(t, f.eOwner, productID, "dave-create-link.txt", f.ownerAppToken)
362+
payload := makeSharedDrivePermissionPayload(t, consts.Files, []string{fileID}, "", nil)
363+
364+
createSharedDrivePermissionExpectStatus(
365+
t, eDave, sharingID, daveToken, "readonly-link", "", payload, http.StatusForbidden,
366+
)
367+
})
368+
358369
t.Run("FailOnFileOutsideSharedDrive", func(t *testing.T) {
359370
payload := makeSharedDrivePermissionPayload(
360371
t, consts.Files, []string{f.env.outsideOfShareID}, "", nil,
@@ -1029,6 +1040,24 @@ func TestSharedDriveShareByLinkRevoke(t *testing.T) {
10291040
deleteSharedDrivePermissionExpectStatus(t, f.eOwner, f.sharingID, permID, f.ownerAppToken, http.StatusNoContent)
10301041
})
10311042

1043+
t.Run("ReadOnlyRecipientCannotRevokePermission", func(t *testing.T) {
1044+
eDave, daveToken := f.newDaveClient(t)
1045+
sharingID, productID := createReadOnlyDriveForDave(t, f)
1046+
fileID := createFile(t, f.eOwner, productID, "dave-revoke-link.txt", f.ownerAppToken)
1047+
permID, _ := createSharedDrivePermission(
1048+
t,
1049+
f.eOwner,
1050+
sharingID,
1051+
f.ownerAppToken,
1052+
"readonly-link",
1053+
"",
1054+
makeSharedDrivePermissionPayload(t, consts.Files, []string{fileID}, "", nil),
1055+
)
1056+
1057+
deleteSharedDrivePermissionExpectStatus(t, eDave, sharingID, permID, daveToken, http.StatusForbidden)
1058+
deleteSharedDrivePermissionExpectStatus(t, f.eOwner, sharingID, permID, f.ownerAppToken, http.StatusNoContent)
1059+
})
1060+
10321061
t.Run("PublicShareTokenCannotRevokePermission", func(t *testing.T) {
10331062
payload := makeSharedDrivePermissionPayload(t, consts.Files, []string{f.fileID}, "", nil)
10341063
permID, attrs := createSharedDrivePermission(

0 commit comments

Comments
 (0)