Skip to content

Commit 49bfece

Browse files
authored
Shared drive auto acceptance (#4614)
Automatic acceptance of Drive sharings from trusted members, eliminating manual approval for trusted contacts. Features - Domain-based trust: Configure trusted domains (e.g., *.company.com) to auto-accept sharings within organizations - Contact-based trust: After manually accepting a sharing once, future sharings from that sender are auto-accepted Configuration ```yaml sharing: auto_accept_trusted: true auto_accept_trusted_contacts: true contexts: default: trusted_domains: ["linagora.com"] ```
2 parents 8f6085a + 8fa3fa3 commit 49bfece

File tree

15 files changed

+849
-5
lines changed

15 files changed

+849
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ node_modules
99
/scripts/golangci-lint
1010
/storage
1111
tmp
12+
/.idea
1213

1314
*.log
1415
*.enc

docs/sharing.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,67 @@ be used for two scenarios:
673673
2. This request will be used to create a shortcut (in that case, a query-string
674674
parameter `shortcut=true&url=...` will be added).
675675

676+
Drive sharings always include in the payload a credentials entry containing the
677+
OAuth state generated for each recipient. When the recipient Cozy is configured
678+
with `sharing.auto_accept_trusted = true`, receiving this state enqueues a
679+
background job that performs the same handshake as a manual acceptance and
680+
POSTs the answer back to the owner's Cozy.
681+
682+
Trusted members are determined by the recipient Cozy only. The recipient checks
683+
that the sender belongs to a trusted domain (or has been marked as a trusted
684+
contact) before launching the auto-accept flow.
685+
686+
Members are trusted when their instance domain exactly matches or is a
687+
subdomain of any of the configured trusted domains. The behaviour can be tuned
688+
via the configuration:
689+
690+
```yaml
691+
sharing:
692+
auto_accept_trusted: true
693+
auto_accept_trusted_contacts: true
694+
contexts:
695+
default:
696+
trusted_domains:
697+
- example.com
698+
white-label:
699+
auto_accept_trusted: false
700+
```
701+
702+
`trusted_domains` must be defined inside a context. Use the `default` context
703+
to configure a global rule, or override it per custom context.
704+
705+
If `auto_accept_trusted` is disabled on the recipient Cozy, the request is kept
706+
in the pending state even if the sharer provided the state in the credentials
707+
payload.
708+
709+
### Contact-Based Trust
710+
711+
In addition to domain-based trust, contacts can be marked as trusted when you
712+
accept a sharing from them. This provides a more granular trust model:
713+
714+
- **First sharing**: User manually accepts, sender's contact is automatically marked as trusted
715+
- **Subsequent sharings**: Automatically accepted because the contact is trusted
716+
717+
Contact-based trust works independently of domain-based trust (and can be
718+
disabled via `sharing.auto_accept_trusted_contacts` or the corresponding
719+
context override):
720+
- A contact from an untrusted domain can still be trusted
721+
- This allows trusting specific individuals without trusting entire domains
722+
- Contact trust takes effect even if domain-based trust is not configured
723+
724+
**Example workflow:**
725+
1. Alice receives a Drive sharing from `bob@external.com` (untrusted domain)
726+
2. Alice manually accepts the sharing
727+
3. Bob's contact is automatically marked as trusted in Alice's Cozy
728+
4. Future Drive sharings from Bob are auto-accepted (if `auto_accept_trusted` is enabled)
729+
730+
**Trust determination:**
731+
A member is considered trusted if **either**:
732+
- Their instance domain matches a configured trusted domain (domain-based trust)
733+
- Their contact has been marked as trusted (contact-based trust)
734+
735+
The contact's `trustedForSharing` field is set to `true` when a sharing is accepted.
736+
676737
#### Request
677738

678739
```http

docs/workers.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,12 +348,13 @@ in the config file, via the `fs.auto_clean_trashed_after` parameter.
348348

349349
## share workers
350350

351-
The stack have 4 workers to power the sharings (internal usage only):
351+
The stack have 5 workers to power the sharings (internal usage only):
352352

353353
1. `share-group`, to add/remove members to a sharing
354354
2. `share-track`, to update the `io.cozy.shared` database
355355
3. `share-replicate`, to start a replicator for most documents
356356
4. `share-upload`, to upload files
357+
5. `share-autoaccept`, to automatically accept a sharing for trusted members
357358

358359
### Share-group
359360

@@ -372,6 +373,15 @@ optionaly the old version of this document.
372373
The message is composed of a sharing ID and a count of the number of errors
373374
(i.e. the number of times this job was retried).
374375

376+
### Share-autoaccept
377+
378+
The message is composed of the sharing ID and the OAuth state assigned to the
379+
recipient. When the job runs on the recipient Cozy it calls the owner's
380+
`/sharings/:sharing-id/answer` endpoint (the state is provided in the Drive
381+
sharing credentials payload in
382+
[PUT /sharings/:sharing-id](sharing.md#put-sharingssharing-id)) to perform the
383+
acceptance flow without user interaction.
384+
375385
## notes-save
376386

377387
This is another worker for the interal usage of the stack. It allows to write

model/contact/contacts.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,66 @@ func CreateMyself(inst *instance.Instance, settings *couchdb.JSONDoc) (*Contact,
294294
return doc, nil
295295
}
296296

297+
// CreateFromSharingMember creates a contact from sharing member information.
298+
// This is useful when accepting a sharing and we want to create a contact for the sender.
299+
func CreateFromSharingMember(inst *instance.Instance, email, name, cozyURL string) (*Contact, error) {
300+
if email == "" {
301+
return nil, ErrNoMailAddress
302+
}
303+
304+
doc := New()
305+
doc.JSONDoc.M["email"] = []map[string]interface{}{
306+
{"address": email, "primary": true},
307+
}
308+
309+
displayName := name
310+
if name == "" {
311+
parts := strings.SplitN(email, "@", 2)
312+
name = parts[0]
313+
displayName = email
314+
}
315+
if name != "" {
316+
doc.JSONDoc.M["fullname"] = name
317+
}
318+
doc.JSONDoc.M["displayName"] = displayName
319+
320+
if cozyURL != "" {
321+
doc.JSONDoc.M["cozy"] = []map[string]interface{}{
322+
{"url": cozyURL, "primary": true},
323+
}
324+
}
325+
326+
index := email
327+
if cozyURL != "" {
328+
index = cozyURL
329+
}
330+
doc.JSONDoc.M["indexes"] = map[string]interface{}{
331+
"byFamilyNameGivenNameEmailCozyUrl": index,
332+
}
333+
334+
if err := couchdb.CreateDoc(inst, doc); err != nil {
335+
return nil, err
336+
}
337+
return doc, nil
338+
}
339+
340+
// TrustedForSharingKey is the key used to mark a contact as trusted for auto-accepting sharings
341+
const TrustedForSharingKey = "trustedForSharing"
342+
343+
// IsTrusted returns true if this contact has been marked as trusted for auto-accepting sharings
344+
func (c *Contact) IsTrusted() bool {
345+
if val, ok := c.M[TrustedForSharingKey].(bool); ok {
346+
return val
347+
}
348+
return false
349+
}
350+
351+
// MarkAsTrusted marks this contact as trusted for auto-accepting sharings
352+
func (c *Contact) MarkAsTrusted(inst *instance.Instance) error {
353+
c.M[TrustedForSharingKey] = true
354+
return couchdb.UpdateDoc(inst, c)
355+
}
356+
297357
// GetMyself returns the myself contact document, or an ErrNotFound error.
298358
func GetMyself(db prefixer.Prefixer) (*Contact, error) {
299359
var docs []*Contact

model/sharing/autoaccept_job.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package sharing
2+
3+
import (
4+
"errors"
5+
6+
"github.com/cozy/cozy-stack/model/instance"
7+
"github.com/cozy/cozy-stack/model/job"
8+
)
9+
10+
// AutoAcceptMsg is the payload for auto-accepting a drive sharing from a trusted sender
11+
type AutoAcceptMsg struct {
12+
SharingID string `json:"sharing_id"`
13+
State string `json:"state"`
14+
}
15+
16+
// EnqueueAutoAccept schedules a job to auto-accept a drive sharing
17+
func EnqueueAutoAccept(inst *instance.Instance, sharingID, state string) error {
18+
if inst == nil || sharingID == "" || state == "" {
19+
return ErrInvalidSharing
20+
}
21+
22+
msg, err := job.NewMessage(&AutoAcceptMsg{
23+
SharingID: sharingID,
24+
State: state,
25+
})
26+
if err != nil {
27+
return err
28+
}
29+
30+
_, err = job.System().PushJob(inst, &job.JobRequest{
31+
WorkerType: "share-autoaccept",
32+
Message: msg,
33+
})
34+
return err
35+
}
36+
37+
// HandleAutoAccept executes the auto-acceptance for a Drive sharing.
38+
// The OAuth state must be provided by the owner in the sharing request.
39+
func HandleAutoAccept(inst *instance.Instance, msg *AutoAcceptMsg) error {
40+
if inst == nil || msg == nil || msg.SharingID == "" || msg.State == "" {
41+
return ErrInvalidSharing
42+
}
43+
44+
s, err := FindSharing(inst, msg.SharingID)
45+
if err != nil {
46+
return err
47+
}
48+
49+
// Send the acceptance answer using the state provided by the owner
50+
if err := s.SendAnswer(inst, msg.State); err != nil {
51+
if errors.Is(err, ErrAlreadyAccepted) {
52+
return nil // Already accepted, not an error
53+
}
54+
return err
55+
}
56+
return nil
57+
}

model/sharing/oauth.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/cozy/cozy-stack/model/permission"
1919
csettings "github.com/cozy/cozy-stack/model/settings"
2020
"github.com/cozy/cozy-stack/model/vfs"
21+
"github.com/cozy/cozy-stack/pkg/config/config"
2122
"github.com/cozy/cozy-stack/pkg/consts"
2223
"github.com/cozy/cozy-stack/pkg/couchdb"
2324
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
@@ -89,7 +90,10 @@ func (m *Member) CreateSharingRequest(inst *instance.Instance, s *Sharing, c *Cr
8990
if err != nil {
9091
return err
9192
}
92-
var list interface{} = []Credentials{{DriveToken: token}}
93+
var list interface{} = []Credentials{{
94+
DriveToken: token,
95+
State: c.State,
96+
}}
9397
creds = &list
9498
}
9599

@@ -468,6 +472,43 @@ func (s *Sharing) SendAnswer(inst *instance.Instance, state string) error {
468472
s.Credentials[0].Client = creds.Client
469473
s.Active = true
470474
s.Initial = s.NbFiles > 0
475+
476+
options := config.GetConfig().Sharing.OptionsForContext(inst.ContextName)
477+
// Mark the sender's contact as trusted since we accepted their sharing
478+
if *options.AutoAcceptTrustedContacts && len(s.Members) > 0 && s.Members[0].Email != "" {
479+
c, err := contact.FindByEmail(inst, s.Members[0].Email)
480+
if err != nil {
481+
// Contact doesn't exist, create it using the standardized method
482+
c, err = contact.CreateFromSharingMember(inst, s.Members[0].Email, s.Members[0].Name, s.Members[0].Instance)
483+
if err != nil {
484+
if couchdb.IsConflictError(err) {
485+
c, err = contact.FindByEmail(inst, s.Members[0].Email)
486+
if err != nil {
487+
inst.Logger().WithNamespace("sharing").
488+
Debugf("Contact creation conflict and retry failed: %s", err)
489+
c = nil
490+
}
491+
} else {
492+
inst.Logger().WithNamespace("sharing").
493+
Warnf("Could not create contact for sender: %s", err)
494+
}
495+
} else {
496+
inst.Logger().WithNamespace("sharing").
497+
Infof("Created contact for sender %s", s.Members[0].Email)
498+
}
499+
}
500+
501+
if c != nil && !c.IsTrusted() {
502+
if err := c.MarkAsTrusted(inst); err != nil {
503+
inst.Logger().WithNamespace("sharing").
504+
Warnf("Could not mark contact as trusted: %s", err)
505+
} else {
506+
inst.Logger().WithNamespace("sharing").
507+
Infof("Marked contact %s as trusted after accepting sharing", s.Members[0].Email)
508+
}
509+
}
510+
}
511+
471512
return couchdb.UpdateDoc(inst, s)
472513
}
473514

model/sharing/trust.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package sharing
2+
3+
import (
4+
"strings"
5+
6+
"github.com/cozy/cozy-stack/model/contact"
7+
"github.com/cozy/cozy-stack/model/instance"
8+
"github.com/cozy/cozy-stack/pkg/config/config"
9+
"github.com/cozy/cozy-stack/pkg/utils"
10+
)
11+
12+
// IsTrustedMember checks if a member is trusted for auto-accepting sharings.
13+
// Trust is determined by:
14+
// - Domain matching: member's domain matches configured trusted domains (or subdomains)
15+
// - Contact trust: member's contact has been marked as trusted (by previously accepting a sharing)
16+
//
17+
// Contact-based trust takes precedence and works even if domain-based trust is not configured.
18+
func IsTrustedMember(inst *instance.Instance, member *Member) bool {
19+
if inst == nil || member == nil {
20+
return false
21+
}
22+
options := config.GetConfig().Sharing.OptionsForContext(inst.ContextName)
23+
24+
if options.AutoAcceptTrusted == nil || !*options.AutoAcceptTrusted {
25+
return false
26+
}
27+
28+
// Extract the member's instance domain
29+
memberDomain := utils.ExtractInstanceHost(member.Instance)
30+
if memberDomain == "" {
31+
return false
32+
}
33+
34+
// Check if member's domain matches any trusted domain
35+
for _, domain := range options.TrustedDomains {
36+
if domain == "" {
37+
continue
38+
}
39+
d := utils.NormalizeDomain(domain)
40+
if d == "" {
41+
continue
42+
}
43+
if memberDomain == d || strings.HasSuffix(memberDomain, "."+d) {
44+
inst.Logger().WithNamespace("sharing-trust").
45+
Infof("Member %s trusted (trusted domain: %s)", member.Instance, domain)
46+
return true
47+
}
48+
}
49+
50+
// Check if this member is a trusted contact
51+
if options.AutoAcceptTrustedContacts == nil || !*options.AutoAcceptTrustedContacts {
52+
return false
53+
}
54+
if isTrustedContact(inst, member) {
55+
inst.Logger().WithNamespace("sharing-trust").
56+
Infof("Member %s trusted (trusted contact)", member.Instance)
57+
return true
58+
}
59+
60+
return false
61+
}
62+
63+
// isTrustedContact checks if a member is marked as a trusted contact
64+
func isTrustedContact(inst *instance.Instance, member *Member) bool {
65+
if member.Email == "" {
66+
return false
67+
}
68+
69+
c, err := contact.FindByEmail(inst, member.Email)
70+
if err != nil {
71+
return false
72+
}
73+
74+
return c.IsTrusted()
75+
}

0 commit comments

Comments
 (0)