Skip to content
Open
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
89 changes: 55 additions & 34 deletions model/sharing/effective_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sharing
import (
"os"
"path"
"sort"

"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/model/permission"
Expand Down Expand Up @@ -82,19 +83,58 @@ func (r *AccessResolver) Resolve(targetID string) (*EffectiveAccess, error) {
return ea, nil
}

// rootInfo describes a shared root applying to a target: the directory (or
// file) that is the root of a sharing scope.
type rootInfo struct {
RootID string
RootPath string
RootName string
}

// scopesFor loads the target, builds ancestor paths, finds shared roots on
// the path, adds the target's own file share if any, bulk-loads sharings,
// filters to active additive ones where the current instance is a member,
// and returns the resulting scopes.
func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) {
sharings, rootBySharing, err := r.applicableSharings(targetID)
if err != nil {
return nil, err
}

scopes := make([]SharingScope, 0, len(sharings))
for _, s := range sharings {
info, ok := rootBySharing[s.SID]
if !ok {
continue
}
member := s.MemberFor(r.inst)
if member == nil {
continue
}
scopes = append(scopes, SharingScope{
SharingID: s.SID,
RootID: info.RootID,
RootPath: info.RootPath,
AccessMode: s.EffectiveAccessMode(),
ReadOnly: member.ReadOnly,
})
}
return scopes, nil
}

// applicableSharings resolves the active additive sharings applying to the
// target: its own share (if it is a shared file) plus the shares of every
// ancestor directory, without any membership filtering. It also returns the
// root info of each sharing, keyed by sharing ID.
func (r *AccessResolver) applicableSharings(targetID string) ([]*Sharing, map[string]rootInfo, error) {
fs := r.inst.VFS()

dir, file, err := fs.DirOrFileByID(targetID)
if err != nil {
return nil, err
return nil, nil, err
}
if dir == nil && file == nil {
return nil, os.ErrNotExist
return nil, nil, os.ErrNotExist
}

var targetPath string
Expand All @@ -103,7 +143,7 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) {
} else {
targetPath, err = file.Path(fs)
if err != nil {
return nil, err
return nil, nil, err
}
}

Expand All @@ -119,6 +159,7 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) {
type sharedRootDoc struct {
ID string `json:"_id"`
Path string `json:"path"`
Name string `json:"name"`
ReferencedBy []couchdb.DocReference `json:"referenced_by"`
}
var roots []sharedRootDoc
Expand All @@ -130,25 +171,21 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) {
mango.Equal("type", consts.DirType),
mango.Exists(couchdb.SelectorReferencedBy),
),
Fields: []string{"_id", "path", "referenced_by"},
Fields: []string{"_id", "path", "name", "referenced_by"},
Limit: len(paths),
}
if err := couchdb.FindDocs(r.inst, consts.Files, req, &roots); err != nil {
return nil, err
return nil, nil, err
}
}

// Collect (sharingID -> {rootID, rootPath}) from ancestor dir roots.
type rootInfo struct {
RootID string
RootPath string
}
// Collect (sharingID -> rootInfo) from ancestor dir roots.
rootBySharing := make(map[string]rootInfo)
for _, root := range roots {
for _, ref := range root.ReferencedBy {
if ref.Type == consts.Sharings {
if _, ok := rootBySharing[ref.ID]; !ok {
rootBySharing[ref.ID] = rootInfo{RootID: root.ID, RootPath: root.Path}
rootBySharing[ref.ID] = rootInfo{RootID: root.ID, RootPath: root.Path, RootName: root.Name}
}
}
}
Expand All @@ -161,45 +198,29 @@ func (r *AccessResolver) scopesFor(targetID string) ([]SharingScope, error) {
for _, ref := range file.ReferencedBy {
if ref.Type == consts.Sharings {
if _, ok := rootBySharing[ref.ID]; !ok {
rootBySharing[ref.ID] = rootInfo{RootID: file.DocID, RootPath: targetPath}
rootBySharing[ref.ID] = rootInfo{RootID: file.DocID, RootPath: targetPath, RootName: file.DocName}
}
}
}
}

if len(rootBySharing) == 0 {
return nil, nil
return nil, nil, nil
}

sharingIDs := make([]string, 0, len(rootBySharing))
for id := range rootBySharing {
sharingIDs = append(sharingIDs, id)
}
// Stable order: the recipient list derived from these sharings must not
// change between identical calls.
sort.Strings(sharingIDs)

sharings, err := r.loadSharings(sharingIDs)
if err != nil {
return nil, err
return nil, nil, err
}

scopes := make([]SharingScope, 0, len(sharings))
for _, s := range sharings {
info, ok := rootBySharing[s.SID]
if !ok {
continue
}
member := s.MemberFor(r.inst)
if member == nil {
continue
}
scopes = append(scopes, SharingScope{
SharingID: s.SID,
RootID: info.RootID,
RootPath: info.RootPath,
AccessMode: s.EffectiveAccessMode(),
ReadOnly: member.ReadOnly,
})
}
return scopes, nil
return sharings, rootBySharing, nil
}

// ancestorPaths returns the directory paths to query for shared roots. When
Expand Down
191 changes: 191 additions & 0 deletions model/sharing/effective_recipients.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package sharing

import (
"fmt"
"strings"
)

// RecipientSource describes one sharing scope through which a recipient has
// access to the target: which sharing, which root, and how the access can be
// managed from the target's share modal.
type RecipientSource struct {
SharingID string `json:"sharing_id"`
RootID string `json:"root_id"`
RootName string `json:"root_name"`
Kind string `json:"kind"` // "self" | "ancestor"
MemberIndex int `json:"member_index"`
ReadOnly bool `json:"read_only"`
Manageable bool `json:"manageable"`
}

// EffectiveRecipient is a deduplicated person who can access the target,
// either through the target's own share ("self") or inherited from a shared
// ancestor ("ancestor"). ReadOnly is merged across sources with read-write
// winning over read-only. CanEditHere is true when at least one source is
// the target's own share.
type EffectiveRecipient struct {
Name string `json:"name"`
Email string `json:"email"`
Instance string `json:"instance"`
Status string `json:"status"`
ReadOnly bool `json:"read_only"`
CanEditHere bool `json:"can_edit_here"`
Sources []RecipientSource `json:"sources"`
}

// EffectiveRecipients returns the combined list of people who can access the
// given file or folder: the direct members of every active additive sharing
// scope applying to the target (its own share plus inherited ancestor
// shares). Revoked members are excluded. Recipients are deduplicated by
// instance, with an email fallback. It is a read-only view: no sharing
// document is mutated and no inherited member is copied anywhere.
func (r *AccessResolver) EffectiveRecipients(targetID string) ([]EffectiveRecipient, error) {
sharings, rootBySharing, err := r.applicableSharings(targetID)
if err != nil {
return nil, err
}

var recipients []EffectiveRecipient
index := make(map[string]int)
dropped := make(map[int]bool)
for _, s := range sharings {
info, ok := rootBySharing[s.SID]
if !ok {
continue
}
kind := "ancestor"
if info.RootID == targetID {
kind = "self"
}
// Same condition as authorizeRevokeRecipient: the owner can manage
// members, and a drive recipient can too (delegated to the owner)
// only with write access — read-only drive recipients get a 403
// from hasSharingWritePermissions when they try. A classic-sharing
// recipient cannot revoke.
canManage := s.Owner
if !canManage && s.Drive {
if self := s.MemberFor(r.inst); self != nil && !self.ReadOnly {
canManage = true
}
}
for i := range s.Members {
m := &s.Members[i]
if m.Status == MemberStatusRevoked {
continue
}
candidate := EffectiveRecipient{
Name: m.PrimaryName(),
Email: m.Email,
Instance: m.Instance,
Status: m.Status,
ReadOnly: m.ReadOnly,
Sources: []RecipientSource{{
SharingID: s.SID,
RootID: info.RootID,
RootName: info.RootName,
Kind: kind,
MemberIndex: i,
ReadOnly: m.ReadOnly,
// i != 0: RevokeRecipient rejects the owner entry (index 0),
// so it must not be reported as manageable.
Manageable: kind == "self" && canManage && i != 0,
}},
}
candidate.CanEditHere = kind == "self"

key, aliases := recipientKeys(s.SID, i, m)
pos, ok := index[key]
if !ok {
// The canonical key may miss when an earlier sharing
// only registered an alias for this person: fall back
// to the aliases before creating a duplicate.
for _, alias := range aliases {
if p, found := index[alias]; found {
pos, ok = p, true
break
}
}
}
if ok {
recipients[pos].absorb(&candidate)
index[key] = pos
for _, alias := range aliases {
if q, found := index[alias]; found && q != pos {
// The alias was registered by another recipient that
// turns out to be the same person (e.g. known by
// instance in one share, by email in another, and a
// third share bridges the two): merge it instead of
// leaving an orphaned duplicate behind.
recipients[pos].absorb(&recipients[q])
dropped[q] = true
for k, v := range index {
if v == q {
index[k] = pos
}
}
}
index[alias] = pos
}
continue
}
pos = len(recipients)
index[key] = pos
for _, alias := range aliases {
index[alias] = pos
}
recipients = append(recipients, candidate)
}
}
if len(dropped) > 0 {
kept := recipients[:0]
for i := range recipients {
if !dropped[i] {
kept = append(kept, recipients[i])
}
}
recipients = kept
}
return recipients, nil
}

// absorb merges another occurrence of the same person into the recipient:
// read-write wins over read-only, the most advanced status is kept, missing
// identity fields are filled in, and sources are combined.
func (rc *EffectiveRecipient) absorb(other *EffectiveRecipient) {
rc.ReadOnly = rc.ReadOnly && other.ReadOnly // read-write wins
rc.CanEditHere = rc.CanEditHere || other.CanEditHere
if statusRank(other.Status) > statusRank(rc.Status) {
rc.Status = other.Status
}
if (rc.Name == "" || rc.Name == rc.Email) && other.Name != "" {
// The current name is empty or just the email fallback:
// prefer an actual name when another source has one.
rc.Name = other.Name
}
if rc.Email == "" {
rc.Email = other.Email
}
if rc.Instance == "" {
rc.Instance = other.Instance
}
rc.Sources = append(rc.Sources, other.Sources...)
}

// recipientKeys builds the dedup keys for a member: the instance host when
// known plus the lowercased email, so a person invited by email in one share
// and known by instance in another is still merged. The first key is the
// canonical one (instance preferred over email). When neither is set, a
// per-member unique key prevents merging nameless pending members.
func recipientKeys(sharingID string, memberIndex int, m *Member) (string, []string) {
var keys []string
if host := m.InstanceHost(); host != "" {
keys = append(keys, "instance:"+host)
}
if m.Email != "" {
keys = append(keys, "email:"+strings.ToLower(m.Email))
}
if len(keys) == 0 {
return fmt.Sprintf("member:%s:%d", sharingID, memberIndex), nil
}
return keys[0], keys[1:]
}
Loading
Loading