diff --git a/docs/sharing.md b/docs/sharing.md index 7f278e28220..70f5b9166bc 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -1246,6 +1246,8 @@ This route is used to add the read-only flag on a recipient of a sharing. **Note**: 0 is not accepted for `index`, as it is the sharer him-self. +It returns 400 if the member belongs to any non-revoked group. + ##### Request ```http @@ -1300,6 +1302,8 @@ This route is used to remove the read-only flag on a recipient of a sharing. **Note**: 0 is not accepted for `index`, as it is the sharer him-self. +It returns 400 if the member belongs to any non-revoked group. + ##### Request ```http @@ -1313,6 +1317,60 @@ Host: alice.example.net HTTP/1.1 204 No Content ``` +### POST /sharings/:sharing-id/groups/:group-index/readonly + +Downgrade all the members of a group to read-only. + +- The owner can always call this route. +- A non-owner can call it only if: + - they are a member of the target group, + - the group is currently read-write, + - the sharing is not an OrgDrive. +- The change is propagated to recipients via the standard credentials + exchange. + +#### Request + +```http +POST /sharings/ce8835a061d0ef68947afe69a0046722/groups/0/readonly HTTP/1.1 +Host: alice.example.net +``` + +#### Response + +```http +HTTP/1.1 204 No Content +``` + +It returns 400 `group_read_only_conflict` if: + +- a member of the group also belongs to another group whose read-only state + differs from the target, or +- a member of the group was added individually (`only_in_groups: false`) and + their individual `read_only` flag conflicts with the target state. + +### DELETE /sharings/:sharing-id/groups/:group-index/readonly + +Upgrade all the members of a group to read-write. + +- Only the owner can call this route. + +It returns 400 `group_read_only_conflict` under the same conditions as +`POST /sharings/:sharing-id/groups/:group-index/readonly`. + +#### Request + +```http +DELETE /sharings/ce8835a061d0ef68947afe69a0046722/groups/0/readonly HTTP/1.1 +Host: alice.example.net +``` + +#### Response + +```http +HTTP/1.1 204 No Content +``` + ### DELETE /sharings/:sharing-id/recipients/self/readonly This is an internal route for the stack. It's used to inform the recipient's diff --git a/model/sharing/error.go b/model/sharing/error.go index baa39e43051..a283ba33aef 100644 --- a/model/sharing/error.go +++ b/model/sharing/error.go @@ -79,4 +79,7 @@ var ( ErrFileInTrash = errors.New("Cannot share trashed file") // ErrSystemFolder is used when trying to share a system folder ErrSystemFolder = errors.New("Cannot share system folder") + // ErrGroupReadOnlyConflict is used when changing a group's read-only flag + // would conflict with another group's state for a member belonging to both. + ErrGroupReadOnlyConflict = errors.New("Cannot change group read-only flag: a member also belongs to another group with a conflicting read-only state") ) diff --git a/model/sharing/group_readonly.go b/model/sharing/group_readonly.go new file mode 100644 index 00000000000..c3272fb4ef4 --- /dev/null +++ b/model/sharing/group_readonly.go @@ -0,0 +1,215 @@ +package sharing + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cozy/cozy-stack/client/request" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/pkg/couchdb" + multierror "github.com/hashicorp/go-multierror" + "github.com/labstack/echo/v4" +) + +func (s *Sharing) CheckMemberGroupReadOnlyConsistency(memberIndex int) error { + for _, gidx := range s.Members[memberIndex].Groups { + if gidx < 0 || gidx >= len(s.Groups) { + continue + } + if s.Groups[gidx].Revoked { + continue + } + return ErrGroupReadOnlyConflict + } + return nil +} + +func (s *Sharing) checkGroupReadOnlyChangeConsistency(groupIndex int, targetReadOnly bool) error { + for _, m := range s.Members { + inGroup := false + for _, idx := range m.Groups { + if idx == groupIndex { + inGroup = true + break + } + } + if !inGroup { + continue + } + for _, otherIdx := range m.Groups { + if otherIdx == groupIndex { + continue + } + if otherIdx < 0 || otherIdx >= len(s.Groups) { + continue + } + if s.Groups[otherIdx].Revoked { + continue + } + if s.Groups[otherIdx].ReadOnly != targetReadOnly { + return ErrGroupReadOnlyConflict + } + } + } + return nil +} + +func (s *Sharing) checkGroupMembersIndividualConsistency(groupIndex int, targetReadOnly bool) error { + for _, m := range s.Members { + inGroup := false + for _, idx := range m.Groups { + if idx == groupIndex { + inGroup = true + break + } + } + if !inGroup { + continue + } + if !m.OnlyInGroups && m.ReadOnly != targetReadOnly { + return ErrGroupReadOnlyConflict + } + } + return nil +} + +func (s *Sharing) AddReadOnlyFlagToGroup(inst *instance.Instance, groupIndex int) error { + if !s.Owner { + return ErrInvalidSharing + } + if groupIndex < 0 || groupIndex >= len(s.Groups) { + return ErrInvalidSharing + } + if s.Groups[groupIndex].Revoked { + return ErrInvalidSharing + } + if s.Groups[groupIndex].ReadOnly { + return nil + } + if err := s.checkGroupReadOnlyChangeConsistency(groupIndex, true); err != nil { + return err + } + if err := s.checkGroupMembersIndividualConsistency(groupIndex, true); err != nil { + return err + } + var errm error + for i, m := range s.Members { + if i == 0 { + continue + } + inGroup := false + for _, idx := range m.Groups { + if idx == groupIndex { + inGroup = true + break + } + } + if !inGroup { + continue + } + if s.Members[i].ReadOnly { + continue + } + if err := s.AddReadOnlyFlag(inst, i); err != nil { + errm = multierror.Append(errm, err) + } + } + if errm == nil { + s.Groups[groupIndex].ReadOnly = true + if err := couchdb.UpdateDoc(inst, s); err != nil { + errm = err + } + } + return errm +} + +func (s *Sharing) RemoveReadOnlyFlagFromGroup(inst *instance.Instance, groupIndex int) error { + if !s.Owner { + return ErrInvalidSharing + } + if groupIndex < 0 || groupIndex >= len(s.Groups) { + return ErrInvalidSharing + } + if s.Groups[groupIndex].Revoked { + return ErrInvalidSharing + } + if !s.Groups[groupIndex].ReadOnly { + return nil + } + if err := s.checkGroupReadOnlyChangeConsistency(groupIndex, false); err != nil { + return err + } + if err := s.checkGroupMembersIndividualConsistency(groupIndex, false); err != nil { + return err + } + var errm error + for i, m := range s.Members { + if i == 0 { + continue + } + inGroup := false + for _, idx := range m.Groups { + if idx == groupIndex { + inGroup = true + break + } + } + if !inGroup { + continue + } + if !s.Members[i].ReadOnly { + continue + } + if err := s.RemoveReadOnlyFlag(inst, i); err != nil { + errm = multierror.Append(errm, err) + } + } + if errm == nil { + s.Groups[groupIndex].ReadOnly = false + if err := couchdb.UpdateDoc(inst, s); err != nil { + errm = err + } + } + return errm +} + +func (s *Sharing) DelegateAddReadOnlyFlagToGroup(inst *instance.Instance, groupIndex int) error { + if len(s.Credentials) != 1 { + return ErrInvalidSharing + } + m := &s.Members[0] + u, ok := m.InstanceURL() + if !ok { + return ErrInvalidSharing + } + c := &s.Credentials[0] + if c.AccessToken == nil { + return ErrInvalidSharing + } + opts := &request.Options{ + Method: http.MethodPost, + Scheme: u.Scheme, + Domain: u.Host, + Path: fmt.Sprintf("/sharings/%s/groups/%d/readonly", s.SID, groupIndex), + Headers: request.Headers{ + echo.HeaderAuthorization: "Bearer " + c.AccessToken.AccessToken, + }, + ParseError: ParseRequestError, + } + res, err := request.Req(opts) + if res != nil && res.StatusCode/100 == 4 { + res, err = RefreshToken(inst, res, err, s, m, c, opts, nil) + } + if err != nil { + if res != nil && res.StatusCode == http.StatusBadRequest { + if reqErr, ok := err.(*request.Error); ok && strings.Contains(reqErr.Detail, ErrGroupReadOnlyConflict.Error()) { + return ErrGroupReadOnlyConflict + } + return ErrInvalidURL + } + return err + } + res.Body.Close() + return nil +} diff --git a/model/sharing/group_readonly_test.go b/model/sharing/group_readonly_test.go new file mode 100644 index 00000000000..63cb9183c76 --- /dev/null +++ b/model/sharing/group_readonly_test.go @@ -0,0 +1,730 @@ +package sharing + +import ( + "testing" + "time" + + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupReadOnly(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + + config.UseTestFile(t) + testutils.NeedCouchdb(t) + setup := testutils.NewSetup(t, t.Name()) + inst := setup.GetTestInstance(&lifecycle.Options{ + Email: "alice@example.net", + PublicName: "Alice", + }) + + t.Run("checkGroupReadOnlyChangeConsistency", func(t *testing.T) { + t.Run("no_other_groups", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, true) + assert.NoError(t, err) + }) + + t.Run("other_group_same_state", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + {Name: "Family", ReadOnly: true}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, true) + assert.NoError(t, err) + }) + + t.Run("other_group_same_state_upgrade", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + {Name: "Family", ReadOnly: true}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, false) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("other_group_conflicting_state_downgrade", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + {Name: "Family", ReadOnly: false}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, true) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("other_group_conflicting_state_upgrade", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + {Name: "Family", ReadOnly: true}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, false) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("conflicting_but_revoked_group_skipped", func(t *testing.T) { + s := &Sharing{ + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + {Name: "Family", ReadOnly: true, Revoked: true}, + }, + } + err := s.checkGroupReadOnlyChangeConsistency(0, true) + assert.NoError(t, err) + }) + }) + + t.Run("checkGroupMembersIndividualConsistency", func(t *testing.T) { + t.Run("all_only_in_groups", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + {Title: "Test", DocType: "io.cozy.tests", Values: []string{uuidv7()}}, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + err := s.checkGroupMembersIndividualConsistency(0, true) + assert.NoError(t, err) + }) + + t.Run("individual_matches_target_true", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: false}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + {Title: "Test", DocType: "io.cozy.tests", Values: []string{uuidv7()}}, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + err := s.checkGroupMembersIndividualConsistency(0, true) + assert.NoError(t, err) + }) + + t.Run("individual_matches_target_false", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: false}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + {Title: "Test", DocType: "io.cozy.tests", Values: []string{uuidv7()}}, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + err := s.checkGroupMembersIndividualConsistency(0, false) + assert.NoError(t, err) + }) + + t.Run("individual_conflicts_downgrade", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: false}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + {Title: "Test", DocType: "io.cozy.tests", Values: []string{uuidv7()}}, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + err := s.checkGroupMembersIndividualConsistency(0, true) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("individual_conflicts_upgrade", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: false}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + {Title: "Test", DocType: "io.cozy.tests", Values: []string{uuidv7()}}, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + err := s.checkGroupMembersIndividualConsistency(0, false) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + }) + + t.Run("CheckMemberGroupReadOnlyConsistency", func(t *testing.T) { + t.Run("member_not_in_any_group", func(t *testing.T) { + s := &Sharing{ + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + } + err := s.CheckMemberGroupReadOnlyConsistency(1) + assert.NoError(t, err) + }) + + t.Run("member_in_read_write_group_rejected", func(t *testing.T) { + s := &Sharing{ + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + } + err := s.CheckMemberGroupReadOnlyConsistency(1) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("member_in_read_only_group_rejected", func(t *testing.T) { + s := &Sharing{ + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + } + err := s.CheckMemberGroupReadOnlyConsistency(1) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("member_in_revoked_read_only_group_allowed", func(t *testing.T) { + s := &Sharing{ + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true, Revoked: true}, + }, + } + err := s.CheckMemberGroupReadOnlyConsistency(1) + assert.NoError(t, err) + }) + }) + + t.Run("AddReadOnlyFlagToGroup", func(t *testing.T) { + t.Run("happy_path", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test group readonly", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + {Status: MemberStatusMailNotSent, Name: "Charlie", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + require.NoError(t, err) + + assert.True(t, s.Groups[0].ReadOnly) + assert.True(t, s.Members[1].ReadOnly) + assert.True(t, s.Members[2].ReadOnly) + }) + + t.Run("idempotent", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test idempotent", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + assert.NoError(t, err) + }) + + t.Run("non_owner_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: false, + Description: "Test non-owner", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + assert.ErrorIs(t, err, ErrInvalidSharing) + }) + + t.Run("group_already_ro_is_noop", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test already ro", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + assert.NoError(t, err) + }) + + t.Run("group_revoked_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test revoked group", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false, Revoked: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + assert.ErrorIs(t, err, ErrInvalidSharing) + }) + + t.Run("conflict_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test conflict", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + {Name: "Family", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + + t.Run("conflict_skips_revoked_group", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test conflict skip revoked", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + {Name: "Family", ReadOnly: true, Revoked: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + require.NoError(t, err) + assert.True(t, s.Groups[0].ReadOnly) + assert.True(t, s.Members[1].ReadOnly) + }) + + t.Run("member_already_ro_skipped", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test member already ro", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + {Status: MemberStatusMailNotSent, Name: "Charlie", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + require.NoError(t, err) + assert.True(t, s.Groups[0].ReadOnly) + assert.True(t, s.Members[1].ReadOnly) + assert.True(t, s.Members[2].ReadOnly) + }) + + t.Run("partial_failure_keeps_group_flag_unchanged", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test partial failure keeps group flag", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + {Status: MemberStatusReady, Name: "Charlie", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.AddReadOnlyFlagToGroup(inst, 0) + require.Error(t, err) + assert.False(t, s.Groups[0].ReadOnly) + assert.True(t, s.Members[1].ReadOnly) + assert.False(t, s.Members[2].ReadOnly) + }) + }) + + t.Run("RemoveReadOnlyFlagFromGroup", func(t *testing.T) { + t.Run("happy_path", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test group upgrade", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + {Status: MemberStatusMailNotSent, Name: "Charlie", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.RemoveReadOnlyFlagFromGroup(inst, 0) + require.NoError(t, err) + + assert.False(t, s.Groups[0].ReadOnly) + assert.False(t, s.Members[1].ReadOnly) + assert.False(t, s.Members[2].ReadOnly) + }) + + t.Run("idempotent", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test idempotent upgrade", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: false, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: false}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.RemoveReadOnlyFlagFromGroup(inst, 0) + assert.NoError(t, err) + }) + + t.Run("non_owner_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: false, + Description: "Test non-owner upgrade", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.RemoveReadOnlyFlagFromGroup(inst, 0) + assert.ErrorIs(t, err, ErrInvalidSharing) + }) + + t.Run("group_revoked_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test upgrade revoked group", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true, Revoked: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.RemoveReadOnlyFlagFromGroup(inst, 0) + assert.ErrorIs(t, err, ErrInvalidSharing) + }) + + t.Run("conflict_error", func(t *testing.T) { + now := time.Now() + s := &Sharing{ + Active: true, + Owner: true, + Description: "Test upgrade conflict", + Members: []Member{ + {Status: MemberStatusOwner, Name: "Alice", Email: "alice@cozy.tools"}, + {Status: MemberStatusMailNotSent, Name: "Bob", ReadOnly: true, Groups: []int{0, 1}, OnlyInGroups: true}, + }, + Groups: []Group{ + {Name: "Friends", ReadOnly: true}, + {Name: "Family", ReadOnly: true}, + }, + Rules: []Rule{ + { + Title: "Test", + DocType: "io.cozy.tests", + Values: []string{uuidv7()}, + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, couchdb.CreateDoc(inst, s)) + + err := s.RemoveReadOnlyFlagFromGroup(inst, 0) + assert.ErrorIs(t, err, ErrGroupReadOnlyConflict) + }) + }) +} diff --git a/web/sharings/group_readonly.go b/web/sharings/group_readonly.go new file mode 100644 index 00000000000..a7e9d096952 --- /dev/null +++ b/web/sharings/group_readonly.go @@ -0,0 +1,122 @@ +package sharings + +import ( + "errors" + "net/http" + "strconv" + + "github.com/cozy/cozy-stack/model/sharing" + "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/web/middlewares" + "github.com/labstack/echo/v4" +) + +func AddReadOnlyToGroup(c echo.Context) error { + inst := middlewares.GetInstance(c) + sharingID := c.Param("sharing-id") + s, err := sharing.FindSharing(inst, sharingID) + if err != nil { + return wrapErrors(err) + } + _, err = checkCreatePermissions(c, s) + if err != nil { + if err = authorizeDelegatedGroupReadOnlyChange(c, s); err != nil { + return err + } + } + groupIndex, err := strconv.Atoi(c.Param("index")) + if err != nil { + return jsonapi.InvalidParameter("index", err) + } + if groupIndex < 0 || groupIndex >= len(s.Groups) { + return jsonapi.InvalidParameter("index", errors.New("Invalid index")) + } + if s.Owner { + if err = s.AddReadOnlyFlagToGroup(inst, groupIndex); err != nil { + return wrapErrors(err) + } + go s.NotifyRecipients(inst, nil) + } else { + if err = s.DelegateAddReadOnlyFlagToGroup(inst, groupIndex); err != nil { + return wrapErrors(err) + } + } + return c.NoContent(http.StatusNoContent) +} + +func RemoveReadOnlyFromGroup(c echo.Context) error { + inst := middlewares.GetInstance(c) + sharingID := c.Param("sharing-id") + s, err := sharing.FindSharing(inst, sharingID) + if err != nil { + return wrapErrors(err) + } + _, err = checkCreatePermissions(c, s) + if err != nil { + return err + } + groupIndex, err := strconv.Atoi(c.Param("index")) + if err != nil { + return jsonapi.InvalidParameter("index", err) + } + if groupIndex < 0 || groupIndex >= len(s.Groups) { + return jsonapi.InvalidParameter("index", errors.New("Invalid index")) + } + if err = s.RemoveReadOnlyFlagFromGroup(inst, groupIndex); err != nil { + return wrapErrors(err) + } + go s.NotifyRecipients(inst, nil) + return c.NoContent(http.StatusNoContent) +} + +func authorizeDelegatedGroupReadOnlyChange(c echo.Context, s *sharing.Sharing) error { + if err := hasSharingWritePermissions(c); err != nil { + return err + } + + member, err := requestMember(c, s) + if err != nil { + return wrapErrors(err) + } + + if member.ReadOnly { + return echo.NewHTTPError(http.StatusForbidden) + } + + if s.OrgDrive { + return echo.NewHTTPError(http.StatusForbidden) + } + + groupIndex, err := strconv.Atoi(c.Param("index")) + if err != nil { + return jsonapi.InvalidParameter("index", err) + } + if groupIndex < 0 || groupIndex >= len(s.Groups) { + return jsonapi.InvalidParameter("index", errors.New("Invalid index")) + } + + if s.Groups[groupIndex].Revoked { + return echo.NewHTTPError(http.StatusBadRequest) + } + + if c.Request().Method == http.MethodDelete { + return echo.NewHTTPError(http.StatusForbidden) + } + + inGroup := false + for _, idx := range member.Groups { + if idx == groupIndex { + inGroup = true + break + } + } + if !inGroup { + return echo.NewHTTPError(http.StatusForbidden) + } + + if s.Groups[groupIndex].ReadOnly { + return echo.NewHTTPError(http.StatusForbidden) + } + + return nil +} diff --git a/web/sharings/group_readonly_test.go b/web/sharings/group_readonly_test.go new file mode 100644 index 00000000000..64f47ee7f21 --- /dev/null +++ b/web/sharings/group_readonly_test.go @@ -0,0 +1,234 @@ +package sharings_test + +import ( + "testing" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/pkg/assets/dynamic" + build "github.com/cozy/cozy-stack/pkg/config" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/tests/testutils" + "github.com/cozy/cozy-stack/web" + "github.com/cozy/cozy-stack/web/errors" + "github.com/cozy/cozy-stack/web/files" + "github.com/cozy/cozy-stack/web/middlewares" + "github.com/cozy/cozy-stack/web/sharings" + "github.com/cozy/cozy-stack/web/statik" + "github.com/gavv/httpexpect/v2" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func TestGroupReadOnlyHandlers(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + + config.UseTestFile(t) + build.BuildMode = build.ModeDev + config.GetConfig().Assets = "../../assets" + _ = web.LoadSupportedLocales() + testutils.NeedCouchdb(t) + render, _ := statik.NewDirRenderer("../../assets") + middlewares.BuildTemplates() + require.NoError(t, dynamic.InitDynamicAssetFS(config.FsURL().String())) + + ownerSetup := testutils.NewSetup(t, t.Name()+"_owner") + ownerInstance := ownerSetup.GetTestInstance(&lifecycle.Options{ + Email: "owner@example.net", + PublicName: "Owner", + }) + ownerAppToken := generateAppToken(ownerInstance, "drive", consts.Files) + + // Create contacts and groups + group := createContactGroup(t, ownerInstance, "Friends") + createContactInGroup(t, ownerInstance, "Bob", group.ID()) + createContactInGroup(t, ownerInstance, "Charlie", group.ID()) + + tsOwner := ownerSetup.GetTestServerMultipleRoutes(map[string]func(*echo.Group){ + "/sharings": sharings.Routes, + "/files": files.Routes, + }) + tsOwner.Config.Handler.(*echo.Echo).Renderer = render + tsOwner.Config.Handler.(*echo.Echo).HTTPErrorHandler = errors.ErrorHandler + t.Cleanup(tsOwner.Close) + + eOwner := httpexpect.Default(t, tsOwner.URL) + + // Create a shared directory + dirID := eOwner.POST("/files/"). + WithQuery("Name", "Shared Folder"). + WithQuery("Type", "directory"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.data.id").String().NotEmpty().Raw() + + // Create a sharing with a read-write group + sharingObj := eOwner.POST("/sharings/"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(`{ + "data": { + "type": "io.cozy.sharings", + "attributes": { + "description": "Test Sharing for Group ReadOnly", + "open_sharing": true, + "rules": [{ + "title": "Shared Folder", + "doctype": "io.cozy.files", + "values": ["` + dirID + `"], + "add": "sync", + "update": "sync", + "remove": "sync" + }] + }, + "relationships": { + "recipients": { + "data": [{ + "id": "` + group.ID() + `", + "type": "io.cozy.contacts.groups" + }] + } + } + } + }`)). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + testSharingID := sharingObj.Path("$.data.id").String().NotEmpty().Raw() + + t.Run("AddReadOnlyToGroup_InvalidIndex", func(t *testing.T) { + e := httpexpect.Default(t, tsOwner.URL) + + // Negative group index returns 422 + e.POST("/sharings/"+testSharingID+"/groups/-1/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + + // Group index out of range returns 422 + e.POST("/sharings/"+testSharingID+"/groups/99/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + + // Non-numeric index returns 422 + e.POST("/sharings/"+testSharingID+"/groups/invalid/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + }) + + t.Run("RemoveReadOnlyFromGroup_InvalidIndex", func(t *testing.T) { + e := httpexpect.Default(t, tsOwner.URL) + + // Negative group index returns 422 + e.DELETE("/sharings/"+testSharingID+"/groups/-1/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + + // Group index out of range returns 422 + e.DELETE("/sharings/"+testSharingID+"/groups/99/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + + // Non-numeric index returns 422 + e.DELETE("/sharings/"+testSharingID+"/groups/invalid/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(422) + }) + + // Create a sharing with a read-only group to test per-member consistency + roGroup := createContactGroup(t, ownerInstance, "RO Team") + createContactInGroup(t, ownerInstance, "Dave", roGroup.ID()) + + roSharingObj := eOwner.POST("/sharings/"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(`{ + "data": { + "type": "io.cozy.sharings", + "attributes": { + "description": "Test RO Group consistency", + "open_sharing": true, + "rules": [{ + "title": "Shared Folder", + "doctype": "io.cozy.files", + "values": ["` + dirID + `"], + "add": "sync", + "update": "sync", + "remove": "sync" + }] + }, + "relationships": { + "read_only_recipients": { + "data": [{ + "id": "` + roGroup.ID() + `", + "type": "io.cozy.contacts.groups" + }] + } + } + } + }`)). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + roSharingID := roSharingObj.Path("$.data.id").String().NotEmpty().Raw() + + t.Run("PerMemberReadOnly_BlockedByGroup", func(t *testing.T) { + // Dave is member index 1, in a RO group. Both per-member readonly + // routes should be blocked because he belongs to a group. + e := httpexpect.Default(t, tsOwner.URL) + + // Upgrade to RW blocked (was RO, in a RO group) + e.DELETE("/sharings/"+roSharingID+"/recipients/1/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(400) + + // Downgrade to RO also blocked (in a group, even if already RO) + e.POST("/sharings/"+roSharingID+"/recipients/1/readonly"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + Expect().Status(400) + }) +} + +func createContactGroup(t *testing.T, inst *instance.Instance, name string) *couchdb.JSONDoc { + t.Helper() + g := couchdb.JSONDoc{ + Type: consts.Groups, + M: map[string]interface{}{ + "name": name, + }, + } + require.NoError(t, couchdb.CreateDoc(inst, &g)) + return &g +} + +func createContactInGroup(t *testing.T, inst *instance.Instance, contactName, groupID string) *couchdb.JSONDoc { + t.Helper() + email := contactName + "@example.net" + c := couchdb.JSONDoc{ + Type: consts.Contacts, + M: map[string]interface{}{ + "fullname": contactName, + "email": []interface{}{map[string]interface{}{ + "address": email, + }}, + "relationships": map[string]interface{}{ + "groups": map[string]interface{}{ + "data": []interface{}{ + map[string]interface{}{ + "_id": groupID, + "_type": consts.Groups, + }, + }, + }, + }, + }, + } + require.NoError(t, couchdb.CreateDoc(inst, &c)) + return &c +} diff --git a/web/sharings/readonly.go b/web/sharings/readonly.go index 5e0fc06b14b..c8ba5e3b960 100644 --- a/web/sharings/readonly.go +++ b/web/sharings/readonly.go @@ -34,6 +34,9 @@ func AddReadOnly(c echo.Context) error { return jsonapi.InvalidParameter("index", errors.New("Invalid index")) } if s.Owner { + if err = s.CheckMemberGroupReadOnlyConsistency(index); err != nil { + return jsonapi.BadRequest(errors.New("This member belongs to a group, change the group read-only flag instead")) + } if err = s.AddReadOnlyFlag(inst, index); err != nil { return wrapErrors(err) } @@ -88,6 +91,9 @@ func RemoveReadOnly(c echo.Context) error { return jsonapi.InvalidParameter("index", errors.New("Invalid index")) } if s.Owner { + if err = s.CheckMemberGroupReadOnlyConsistency(index); err != nil { + return jsonapi.BadRequest(errors.New("This member belongs to a group, change the group read-only flag instead")) + } if err = s.RemoveReadOnlyFlag(inst, index); err != nil { return wrapErrors(err) } diff --git a/web/sharings/sharings.go b/web/sharings/sharings.go index 1d08c4bb62a..d71cb3b91b4 100644 --- a/web/sharings/sharings.go +++ b/web/sharings/sharings.go @@ -1110,6 +1110,8 @@ func Routes(router *echo.Group) { router.POST("/:sharing-id/recipients/self/readonly", DowngradeToReadOnly, checkSharingWritePermissions) // On the recipient router.DELETE("/:sharing-id/recipients/:index/readonly", RemoveReadOnly) // On the sharer router.DELETE("/:sharing-id/recipients/self/readonly", UpgradeToReadWrite, checkSharingWritePermissions) // On the recipient + router.POST("/:sharing-id/groups/:index/readonly", AddReadOnlyToGroup) // On the sharer + router.DELETE("/:sharing-id/groups/:index/readonly", RemoveReadOnlyFromGroup) // On the sharer router.DELETE("/:sharing-id", RevocationRecipientNotif) // On the recipient router.DELETE("/:sharing-id/recipients/self", RevokeRecipientBySelf) // On the recipient router.DELETE("/:sharing-id/answer", RevocationOwnerNotif) @@ -1291,7 +1293,7 @@ func wrapErrors(err error) error { return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", err) case permission.ErrExpiredToken: return jsonapi.BadRequest(err) - case sharing.ErrGroupCannotBeAddedTwice, sharing.ErrMemberAlreadyAdded, sharing.ErrMemberAlreadyInGroup: + case sharing.ErrGroupCannotBeAddedTwice, sharing.ErrMemberAlreadyAdded, sharing.ErrMemberAlreadyInGroup, sharing.ErrGroupReadOnlyConflict: return jsonapi.BadRequest(err) case sharing.ErrFolderAlreadyShared: return jsonapi.Conflict(err)