Skip to content

Commit fd87add

Browse files
committed
feat: repair multiple permissions docs for a shared drive on documents get(permissions check)
1 parent 246b8e2 commit fd87add

6 files changed

Lines changed: 613 additions & 75 deletions

File tree

model/permission/permissions.go

Lines changed: 136 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,23 @@
33
package permission
44

55
import (
6+
"context"
67
"encoding/json"
78
"fmt"
89
"net/http"
910
"strings"
1011
"time"
1112

1213
build "github.com/cozy/cozy-stack/pkg/config"
14+
stackconfig "github.com/cozy/cozy-stack/pkg/config/config"
1315
"github.com/cozy/cozy-stack/pkg/consts"
1416
"github.com/cozy/cozy-stack/pkg/couchdb"
1517
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
1618
"github.com/cozy/cozy-stack/pkg/crypto"
1719
"github.com/cozy/cozy-stack/pkg/logger"
1820
"github.com/cozy/cozy-stack/pkg/metadata"
1921
"github.com/cozy/cozy-stack/pkg/prefixer"
22+
"github.com/cozy/cozy-stack/pkg/utils"
2023
"github.com/labstack/echo/v4"
2124
)
2225

@@ -243,9 +246,17 @@ func GetForSharePreview(db prefixer.Prefixer, sharingID string) (*Permission, er
243246
}
244247

245248
// GetForShareInteract retrieves the Permission doc for a given sharing to
246-
// read/write a note
249+
// read/write a note. It may repair legacy duplicate share-interact permission
250+
// docs as part of the read by creating/updating the canonical permission doc
251+
// and deleting duplicate legacy docs.
247252
func GetForShareInteract(db prefixer.Prefixer, sharingID string) (*Permission, error) {
248-
return getFromSource(db, TypeShareInteract, consts.Sharings, sharingID)
253+
mu := stackconfig.Lock().ReadWrite(db, shareInteractLockName(sharingID))
254+
if err := mu.Lock(); err != nil {
255+
return nil, err
256+
}
257+
defer mu.Unlock()
258+
259+
return getOrRepairShareInteractPermissions(db, sharingID)
249260
}
250261

251262
// ShareInteractPermissionID returns the canonical permission document ID for a
@@ -254,6 +265,10 @@ func ShareInteractPermissionID(sharingID string) string {
254265
return TypeShareInteract + "-" + sharingID
255266
}
256267

268+
func shareInteractLockName(sharingID string) string {
269+
return "permissions/share-interact/" + sharingID
270+
}
271+
257272
func getShareInteractPermissions(db prefixer.Prefixer, sharingID string) ([]Permission, error) {
258273
var res []Permission
259274
req := couchdb.FindRequest{
@@ -271,33 +286,44 @@ func getShareInteractPermissions(db prefixer.Prefixer, sharingID string) ([]Perm
271286
return res, nil
272287
}
273288

274-
// RepairShareInteractPermissions merges duplicate share-interact permission
275-
// documents for a sharing into the canonical document and deletes the legacy
276-
// duplicates.
277-
func RepairShareInteractPermissions(db prefixer.Prefixer, sharingID string) (*Permission, error) {
278-
var lastErr error
279-
for attempt := 0; attempt < 5; attempt++ {
280-
perm, retry, err := repairShareInteractPermissions(db, sharingID)
289+
func getOrRepairShareInteractPermissions(db prefixer.Prefixer, sharingID string) (*Permission, error) {
290+
return utils.RetryWithBackoffValue(context.Background(), shareInteractRetryOptions(), func() (*Permission, error) {
291+
perms, err := getShareInteractPermissions(db, sharingID)
292+
if err != nil {
293+
return nil, err
294+
}
295+
if canonical, needsRepair, err := shareInteractRepairState(perms, sharingID); err != nil || !needsRepair {
296+
return canonical, err
297+
}
298+
299+
perm, retry, err := repairShareInteractPermissions(db, sharingID, perms)
281300
if err == nil && !retry {
282301
return perm, nil
283302
}
284-
lastErr = err
285-
}
286-
if lastErr != nil {
287-
return nil, lastErr
288-
}
289-
return nil, &couchdb.Error{
290-
StatusCode: http.StatusConflict,
291-
Name: "conflict",
292-
Reason: "could not repair share-interact permissions after retries",
293-
}
303+
if retry && err == nil {
304+
return nil, &couchdb.Error{
305+
StatusCode: http.StatusConflict,
306+
Name: "conflict",
307+
Reason: "could not repair share-interact permissions after retries",
308+
}
309+
}
310+
return nil, err
311+
})
294312
}
295313

296-
func repairShareInteractPermissions(db prefixer.Prefixer, sharingID string) (*Permission, bool, error) {
297-
perms, err := getShareInteractPermissions(db, sharingID)
298-
if err != nil {
299-
return nil, false, err
314+
func shareInteractRetryOptions() utils.RetryOptions {
315+
return utils.RetryOptions{
316+
Attempts: 5,
317+
Delay: 10 * time.Millisecond,
318+
MaxDelay: 100 * time.Millisecond,
319+
JitterFactor: 0.25,
320+
ShouldRetry: func(err error) bool {
321+
return couchdb.IsConflictError(err) || couchdb.IsFileExists(err)
322+
},
300323
}
324+
}
325+
326+
func shareInteractRepairState(perms []Permission, sharingID string) (*Permission, bool, error) {
301327
if len(perms) == 0 {
302328
return nil, false, &couchdb.Error{
303329
StatusCode: http.StatusNotFound,
@@ -306,6 +332,28 @@ func repairShareInteractPermissions(db prefixer.Prefixer, sharingID string) (*Pe
306332
}
307333
}
308334

335+
canonicalID := ShareInteractPermissionID(sharingID)
336+
var canonical *Permission
337+
usable := 0
338+
for i := range perms {
339+
if perms[i].Expired() {
340+
continue
341+
}
342+
usable++
343+
if perms[i].ID() == canonicalID {
344+
canonical = &perms[i]
345+
}
346+
}
347+
if usable == 0 {
348+
return nil, false, ErrExpiredToken
349+
}
350+
if usable == 1 && canonical != nil {
351+
return canonical, false, nil
352+
}
353+
return nil, true, nil
354+
}
355+
356+
func repairShareInteractPermissions(db prefixer.Prefixer, sharingID string, perms []Permission) (*Permission, bool, error) {
309357
canonicalID := ShareInteractPermissionID(sharingID)
310358
merged := &Permission{
311359
PID: canonicalID,
@@ -318,15 +366,17 @@ func repairShareInteractPermissions(db prefixer.Prefixer, sharingID string) (*Pe
318366
duplicates := make([]*Permission, 0, len(perms))
319367
for i := range perms {
320368
p := &perms[i]
369+
if p.ID() == canonicalID {
370+
hasCanonical = true
371+
// If the canonical doc is expired, update that doc ID but rebuild its content from non-expired docs.
372+
merged.SetRev(p.Rev())
373+
}
321374
if p.Expired() {
322375
continue
323376
}
324377

325378
hasUsablePermission = true
326-
if p.ID() == canonicalID {
327-
hasCanonical = true
328-
merged.SetRev(p.Rev())
329-
} else {
379+
if p.ID() != canonicalID {
330380
duplicates = append(duplicates, p)
331381
}
332382

@@ -743,21 +793,75 @@ func CreateSharePreviewSet(db prefixer.Prefixer, sharingID string, codes, shortc
743793
return doc, nil
744794
}
745795

746-
// CreateShareInteractSet creates a Permission doc for reading/writing a note
747-
// inside a sharing
796+
// CreateShareInteractSet creates or updates the Permission doc for reading and
797+
// writing a note inside a sharing. When subdoc.Permissions is not empty, it
798+
// replaces the existing permission rules with that full set.
748799
func CreateShareInteractSet(db prefixer.Prefixer, sharingID string, codes map[string]string, subdoc Permission) (*Permission, error) {
749800
doc := &Permission{
801+
PID: ShareInteractPermissionID(sharingID),
750802
Type: TypeShareInteract,
751803
Permissions: subdoc.Permissions,
752804
Codes: codes,
753805
SourceID: consts.Sharings + "/" + sharingID,
754806
Metadata: subdoc.Metadata,
755807
}
756-
err := couchdb.CreateDoc(db, doc)
757-
if err != nil {
808+
809+
if doc.Codes == nil {
810+
doc.Codes = make(map[string]string)
811+
}
812+
813+
mu := stackconfig.Lock().ReadWrite(db, shareInteractLockName(sharingID))
814+
if err := mu.Lock(); err != nil {
758815
return nil, err
759816
}
760-
return doc, nil
817+
defer mu.Unlock()
818+
819+
return utils.RetryWithBackoffValue(context.Background(), shareInteractRetryOptions(), func() (*Permission, error) {
820+
existing, err := getOrRepairShareInteractPermissions(db, sharingID)
821+
if err != nil && !couchdb.IsNotFoundError(err) {
822+
return nil, err
823+
}
824+
if err == nil {
825+
merged := existing.Clone().(*Permission)
826+
merged.Type = TypeShareInteract
827+
merged.SourceID = consts.Sharings + "/" + sharingID
828+
merged.SetID(ShareInteractPermissionID(sharingID))
829+
if len(subdoc.Permissions) > 0 {
830+
// Callers pass the complete interact rule set, so replace instead of merging rules.
831+
merged.Permissions = subdoc.Permissions
832+
}
833+
if merged.Metadata == nil && subdoc.Metadata != nil {
834+
merged.Metadata = subdoc.Metadata.Clone()
835+
}
836+
if merged.Metadata != nil {
837+
merged.Metadata.ChangeUpdatedAt()
838+
}
839+
if merged.Codes == nil {
840+
merged.Codes = make(map[string]string)
841+
}
842+
for key, code := range codes {
843+
if key == "" {
844+
continue
845+
}
846+
if existingCode, ok := merged.Codes[key]; ok && existingCode != code {
847+
logger.WithDomain(db.DomainName()).WithNamespace("permissions").
848+
Warnf("keeping existing share-interact code for %s in sharing %s", key, sharingID)
849+
continue
850+
}
851+
merged.Codes[key] = code
852+
}
853+
if err := couchdb.UpdateDoc(db, merged); err != nil {
854+
return nil, err
855+
}
856+
return merged, nil
857+
}
858+
859+
err = couchdb.CreateNamedDoc(db, doc)
860+
if err == nil {
861+
return doc, nil
862+
}
863+
return nil, err
864+
})
761865
}
762866

763867
// ForceWebapp creates or updates a Permission doc for a given webapp

model/permission/permissions_test.go

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"encoding/json"
66
"strings"
77
"testing"
8+
"time"
89

910
"github.com/cozy/cozy-stack/pkg/config/config"
1011
"github.com/cozy/cozy-stack/pkg/consts"
1112
"github.com/cozy/cozy-stack/pkg/couchdb"
13+
"github.com/cozy/cozy-stack/pkg/metadata"
1214
"github.com/cozy/cozy-stack/pkg/prefixer"
1315
"github.com/labstack/echo/v4"
1416
"github.com/stretchr/testify/assert"
@@ -430,7 +432,7 @@ func TestShareSetPermissions(t *testing.T) {
430432
assert.Error(t, err)
431433
}
432434

433-
func TestRepairShareInteractPermissionsMergesDuplicateDocs(t *testing.T) {
435+
func TestGetForShareInteractRepairsDuplicateDocs(t *testing.T) {
434436
if testing.Short() {
435437
t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
436438
}
@@ -448,16 +450,26 @@ func TestRepairShareInteractPermissionsMergesDuplicateDocs(t *testing.T) {
448450
}},
449451
}
450452

451-
_, err := CreateShareInteractSet(testPrefix, sharingID, map[string]string{
452-
"alice@example.test": "alice-token",
453-
}, perms)
453+
err := couchdb.CreateDoc(testPrefix, &Permission{
454+
Type: TypeShareInteract,
455+
Permissions: perms.Permissions,
456+
Codes: map[string]string{
457+
"alice@example.test": "alice-token",
458+
},
459+
SourceID: consts.Sharings + "/" + sharingID,
460+
})
454461
require.NoError(t, err)
455-
_, err = CreateShareInteractSet(testPrefix, sharingID, map[string]string{
456-
"bob@example.test": "bob-token",
457-
}, perms)
462+
err = couchdb.CreateDoc(testPrefix, &Permission{
463+
Type: TypeShareInteract,
464+
Permissions: perms.Permissions,
465+
Codes: map[string]string{
466+
"bob@example.test": "bob-token",
467+
},
468+
SourceID: consts.Sharings + "/" + sharingID,
469+
})
458470
require.NoError(t, err)
459471

460-
repaired, err := RepairShareInteractPermissions(testPrefix, sharingID)
472+
repaired, err := GetForShareInteract(testPrefix, sharingID)
461473
require.NoError(t, err)
462474
require.Equal(t, ShareInteractPermissionID(sharingID), repaired.ID())
463475
require.Equal(t, map[string]string{
@@ -470,7 +482,7 @@ func TestRepairShareInteractPermissionsMergesDuplicateDocs(t *testing.T) {
470482
require.Len(t, all, 1)
471483
require.Equal(t, ShareInteractPermissionID(sharingID), all[0].ID())
472484

473-
repaired, err = RepairShareInteractPermissions(testPrefix, sharingID)
485+
repaired, err = GetForShareInteract(testPrefix, sharingID)
474486
require.NoError(t, err)
475487
require.Equal(t, map[string]string{
476488
"alice@example.test": "alice-token",
@@ -482,6 +494,52 @@ func TestRepairShareInteractPermissionsMergesDuplicateDocs(t *testing.T) {
482494
require.Len(t, all, 1)
483495
}
484496

497+
func TestCreateShareInteractSetUsesCanonicalDoc(t *testing.T) {
498+
if testing.Short() {
499+
t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
500+
}
501+
502+
config.UseTestFile(t)
503+
require.NoError(t, couchdb.ResetDB(testPrefix, consts.Permissions))
504+
505+
const sharingID = "sharing-canonical-interact-permissions"
506+
md := metadata.New()
507+
md.UpdatedAt = time.Now().Add(-time.Hour)
508+
perms := Permission{
509+
Permissions: Set{{
510+
Title: "Shared drive",
511+
Type: consts.Files,
512+
Values: []string{"shared-drive-root"},
513+
Verbs: ALL,
514+
}},
515+
Metadata: md,
516+
}
517+
518+
first, err := CreateShareInteractSet(testPrefix, sharingID, map[string]string{
519+
"alice@example.test": "alice-token",
520+
}, perms)
521+
require.NoError(t, err)
522+
require.Equal(t, ShareInteractPermissionID(sharingID), first.ID())
523+
524+
second, err := CreateShareInteractSet(testPrefix, sharingID, map[string]string{
525+
"bob@example.test": "bob-token",
526+
}, perms)
527+
require.NoError(t, err)
528+
require.Equal(t, ShareInteractPermissionID(sharingID), second.ID())
529+
require.Equal(t, map[string]string{
530+
"alice@example.test": "alice-token",
531+
"bob@example.test": "bob-token",
532+
}, second.Codes)
533+
require.NotNil(t, first.Metadata)
534+
require.NotNil(t, second.Metadata)
535+
require.True(t, second.Metadata.UpdatedAt.After(first.Metadata.UpdatedAt))
536+
537+
all, err := getShareInteractPermissions(testPrefix, sharingID)
538+
require.NoError(t, err)
539+
require.Len(t, all, 1)
540+
require.Equal(t, ShareInteractPermissionID(sharingID), all[0].ID())
541+
}
542+
485543
func TestCreateShareSetBlocklist(t *testing.T) {
486544
s := Set{Rule{Type: "io.cozy.notifications"}}
487545
subdoc := Permission{

0 commit comments

Comments
 (0)