Skip to content

Commit 246b8e2

Browse files
committed
feat: repair multiple permissions docs for a shared drive
1 parent 96f0225 commit 246b8e2

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

model/permission/permissions.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/cozy/cozy-stack/pkg/couchdb"
1515
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
1616
"github.com/cozy/cozy-stack/pkg/crypto"
17+
"github.com/cozy/cozy-stack/pkg/logger"
1718
"github.com/cozy/cozy-stack/pkg/metadata"
1819
"github.com/cozy/cozy-stack/pkg/prefixer"
1920
"github.com/labstack/echo/v4"
@@ -247,6 +248,143 @@ func GetForShareInteract(db prefixer.Prefixer, sharingID string) (*Permission, e
247248
return getFromSource(db, TypeShareInteract, consts.Sharings, sharingID)
248249
}
249250

251+
// ShareInteractPermissionID returns the canonical permission document ID for a
252+
// share-interact permission set.
253+
func ShareInteractPermissionID(sharingID string) string {
254+
return TypeShareInteract + "-" + sharingID
255+
}
256+
257+
func getShareInteractPermissions(db prefixer.Prefixer, sharingID string) ([]Permission, error) {
258+
var res []Permission
259+
req := couchdb.FindRequest{
260+
UseIndex: "by-source-and-type",
261+
Selector: mango.And(
262+
mango.Equal("type", TypeShareInteract),
263+
mango.Equal("source_id", consts.Sharings+"/"+sharingID),
264+
),
265+
Limit: 1000,
266+
}
267+
err := couchdb.FindDocs(db, consts.Permissions, &req, &res)
268+
if err != nil {
269+
return nil, err
270+
}
271+
return res, nil
272+
}
273+
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)
281+
if err == nil && !retry {
282+
return perm, nil
283+
}
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+
}
294+
}
295+
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
300+
}
301+
if len(perms) == 0 {
302+
return nil, false, &couchdb.Error{
303+
StatusCode: http.StatusNotFound,
304+
Name: "not_found",
305+
Reason: fmt.Sprintf("no permission doc for %v", sharingID),
306+
}
307+
}
308+
309+
canonicalID := ShareInteractPermissionID(sharingID)
310+
merged := &Permission{
311+
PID: canonicalID,
312+
Type: TypeShareInteract,
313+
SourceID: consts.Sharings + "/" + sharingID,
314+
Codes: make(map[string]string),
315+
}
316+
hasUsablePermission := false
317+
hasCanonical := false
318+
duplicates := make([]*Permission, 0, len(perms))
319+
for i := range perms {
320+
p := &perms[i]
321+
if p.Expired() {
322+
continue
323+
}
324+
325+
hasUsablePermission = true
326+
if p.ID() == canonicalID {
327+
hasCanonical = true
328+
merged.SetRev(p.Rev())
329+
} else {
330+
duplicates = append(duplicates, p)
331+
}
332+
333+
if len(merged.Permissions) == 0 && len(p.Permissions) > 0 {
334+
merged.Permissions = p.Clone().(*Permission).Permissions
335+
}
336+
if merged.Metadata == nil && p.Metadata != nil {
337+
merged.Metadata = p.Metadata.Clone()
338+
}
339+
if merged.ExpiresAt == nil && p.ExpiresAt != nil {
340+
merged.ExpiresAt = p.ExpiresAt
341+
}
342+
343+
for key, code := range p.Codes {
344+
if key == "" {
345+
continue
346+
}
347+
if existing, ok := merged.Codes[key]; ok && existing != code {
348+
logger.WithDomain(db.DomainName()).WithNamespace("permissions").
349+
Warnf("conflicting share-interact code for %s in sharing %s", key, sharingID)
350+
continue
351+
}
352+
merged.Codes[key] = code
353+
}
354+
}
355+
if !hasUsablePermission {
356+
return nil, false, ErrExpiredToken
357+
}
358+
359+
if !hasCanonical {
360+
if err := couchdb.CreateNamedDoc(db, merged); err != nil {
361+
if couchdb.IsConflictError(err) || couchdb.IsFileExists(err) {
362+
return nil, true, err
363+
}
364+
return nil, false, err
365+
}
366+
} else if err := couchdb.UpdateDoc(db, merged); err != nil {
367+
if couchdb.IsConflictError(err) {
368+
return nil, true, err
369+
}
370+
return nil, false, err
371+
}
372+
373+
for _, p := range duplicates {
374+
if err := couchdb.DeleteDoc(db, p); err != nil {
375+
if couchdb.IsNotFoundError(err) {
376+
continue
377+
}
378+
if couchdb.IsConflictError(err) {
379+
return nil, true, err
380+
}
381+
return nil, false, err
382+
}
383+
}
384+
385+
return merged, false, nil
386+
}
387+
250388
func getFromSource(db prefixer.Prefixer, permType, docType, slug string) (*Permission, error) {
251389
var res []Permission
252390
req := couchdb.FindRequest{

model/permission/permissions_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,17 @@ import (
66
"strings"
77
"testing"
88

9+
"github.com/cozy/cozy-stack/pkg/config/config"
10+
"github.com/cozy/cozy-stack/pkg/consts"
11+
"github.com/cozy/cozy-stack/pkg/couchdb"
12+
"github.com/cozy/cozy-stack/pkg/prefixer"
913
"github.com/labstack/echo/v4"
1014
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
1116
)
1217

18+
var testPrefix = prefixer.NewPrefixer(0, "test", "permission-tests")
19+
1320
func TestCheckDoctypeName(t *testing.T) {
1421
assert.NoError(t, CheckDoctypeName("io.cozy.files", false))
1522
assert.NoError(t, CheckDoctypeName("io.cozy.account_types", false))
@@ -423,6 +430,58 @@ func TestShareSetPermissions(t *testing.T) {
423430
assert.Error(t, err)
424431
}
425432

433+
func TestRepairShareInteractPermissionsMergesDuplicateDocs(t *testing.T) {
434+
if testing.Short() {
435+
t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
436+
}
437+
438+
config.UseTestFile(t)
439+
require.NoError(t, couchdb.ResetDB(testPrefix, consts.Permissions))
440+
441+
const sharingID = "sharing-duplicate-interact-permissions"
442+
perms := Permission{
443+
Permissions: Set{{
444+
Title: "Shared drive",
445+
Type: consts.Files,
446+
Values: []string{"shared-drive-root"},
447+
Verbs: ALL,
448+
}},
449+
}
450+
451+
_, err := CreateShareInteractSet(testPrefix, sharingID, map[string]string{
452+
"alice@example.test": "alice-token",
453+
}, perms)
454+
require.NoError(t, err)
455+
_, err = CreateShareInteractSet(testPrefix, sharingID, map[string]string{
456+
"bob@example.test": "bob-token",
457+
}, perms)
458+
require.NoError(t, err)
459+
460+
repaired, err := RepairShareInteractPermissions(testPrefix, sharingID)
461+
require.NoError(t, err)
462+
require.Equal(t, ShareInteractPermissionID(sharingID), repaired.ID())
463+
require.Equal(t, map[string]string{
464+
"alice@example.test": "alice-token",
465+
"bob@example.test": "bob-token",
466+
}, repaired.Codes)
467+
468+
all, err := getShareInteractPermissions(testPrefix, sharingID)
469+
require.NoError(t, err)
470+
require.Len(t, all, 1)
471+
require.Equal(t, ShareInteractPermissionID(sharingID), all[0].ID())
472+
473+
repaired, err = RepairShareInteractPermissions(testPrefix, sharingID)
474+
require.NoError(t, err)
475+
require.Equal(t, map[string]string{
476+
"alice@example.test": "alice-token",
477+
"bob@example.test": "bob-token",
478+
}, repaired.Codes)
479+
480+
all, err = getShareInteractPermissions(testPrefix, sharingID)
481+
require.NoError(t, err)
482+
require.Len(t, all, 1)
483+
}
484+
426485
func TestCreateShareSetBlocklist(t *testing.T) {
427486
s := Set{Rule{Type: "io.cozy.notifications"}}
428487
subdoc := Permission{

0 commit comments

Comments
 (0)