Skip to content

Commit 4f240d8

Browse files
authored
[3.3.6 Backport] CBG-5395: User Access History Compaction (#8328)
1 parent 1921b13 commit 4f240d8

16 files changed

Lines changed: 1059 additions & 5 deletions

auth/collection_access.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ type CollectionChannelAPI interface {
6565

6666
// Returns the CollectionAccess map
6767
GetCollectionsAccess() map[string]map[string]*CollectionAccess
68+
69+
// Returns the ColelctionAccessHistory map
70+
GetCollectionAccessHistory() CollectionAccessHistory
6871
}
6972

7073
// UserCollectionChannelAPI defines the interface for managing channel access that is supported by users but not roles.

auth/principal.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type Principal interface {
5858
// Sets the created time for the principal document
5959
SetCreatedAt(t time.Time)
6060

61+
CompactChannelHistory(scope string, col string, channels []string) []string
62+
6163
// Principal includes the PrincipalCollectionAccess interface for operations against
6264
// the _default._default collection (stored directly on the principal for backward
6365
// compatibility)

auth/role.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ func (timedSet TimedSetHistory) PruneHistory(partitionWindow time.Duration) []st
5353
return prunedChannelHistory
5454
}
5555

56+
func (timedSet TimedSetHistory) PruneHistoryByKey(keys []string) []string {
57+
removedKeys := make([]string, 0)
58+
for _, key := range keys {
59+
if _, ok := timedSet[key]; ok {
60+
delete(timedSet, key)
61+
removedKeys = append(removedKeys, key)
62+
}
63+
}
64+
return removedKeys
65+
}
66+
5667
type GrantHistory struct {
5768
UpdatedAt int64 `json:"updated_at"` // Timestamp at which history was last updated, allows for pruning
5869
Entries []GrantHistorySequencePair `json:"entries"` // Entry for a specific grant period
@@ -393,6 +404,15 @@ func (role *roleImpl) authorizeAnyChannel(channels base.Set) error {
393404
return authorizeAnyChannel(role, channels)
394405
}
395406

407+
func (role *roleImpl) CompactChannelHistory(scope, collection string, channels []string) []string {
408+
chanHistory := role.CollectionChannelHistory(scope, collection)
409+
if chanHistory == nil {
410+
return []string{}
411+
}
412+
compactedChannels := chanHistory.PruneHistoryByKey(channels)
413+
return compactedChannels
414+
}
415+
396416
// Returns an HTTP 403 error if the Principal is not allowed to access all the given channels.
397417
// A nil Principal means access control is disabled, so the function will return nil.
398418
func authorizeAllChannels(princ Principal, channels base.Set) error {

auth/role_collection_access.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
package auth
1010

1111
import (
12+
"maps"
13+
"slices"
14+
1215
"fmt"
1316

1417
"github.com/couchbase/sync_gateway/base"
@@ -233,3 +236,28 @@ func (role *roleImpl) initChannels(scopeName, collectionName string, channels ba
233236
func (role *roleImpl) GetCollectionsAccess() map[string]map[string]*CollectionAccess {
234237
return role.CollectionsAccess
235238
}
239+
240+
// CollectionAccessHistory maps scope names to collections, each holding the list of channel names with revocation history.
241+
// Shape: scope → collection → []channel
242+
type CollectionAccessHistory map[string]map[string][]string
243+
244+
func (role *roleImpl) GetCollectionAccessHistory() CollectionAccessHistory {
245+
246+
collectionAccess := role.GetCollectionsAccess()
247+
collectionAccessHistoryMap := make(CollectionAccessHistory)
248+
for scope, cols := range collectionAccess {
249+
collectionAccessHistoryMap[scope] = make(map[string][]string)
250+
for col, colVal := range cols {
251+
collectionAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colVal.ChannelHistory()))
252+
}
253+
}
254+
255+
// Always include the default collection's top-level channel history, without clobbering
256+
// any named collections already recorded under the default scope.
257+
if collectionAccessHistoryMap[base.DefaultScope] == nil {
258+
collectionAccessHistoryMap[base.DefaultScope] = make(map[string][]string)
259+
}
260+
collectionAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(role.ChannelHistory()))
261+
262+
return collectionAccessHistoryMap
263+
}

auth/user_test.go

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,236 @@ func TestUserKeysHash(t *testing.T) {
469469
}
470470
}
471471

472+
func TestCompactCollectionChannelHistory(t *testing.T) {
473+
base.SetUpTestLogging(t, base.LevelDebug, base.KeyAuth)
474+
475+
ctx := base.TestCtx(t)
476+
testBucket := base.GetTestBucket(t)
477+
defer testBucket.Close(ctx)
478+
dataStore := testBucket.GetSingleDataStore()
479+
480+
const (
481+
scope = "scope1"
482+
collection = "collection1"
483+
)
484+
485+
// Helper to create fresh history state for each subtest
486+
newHistory := func() TimedSetHistory {
487+
return TimedSetHistory{
488+
"ch1": GrantHistory{
489+
UpdatedAt: 1000,
490+
Entries: []GrantHistorySequencePair{{StartSeq: 1, EndSeq: 10}},
491+
},
492+
"ch2": GrantHistory{
493+
UpdatedAt: 2000,
494+
Entries: []GrantHistorySequencePair{{StartSeq: 11, EndSeq: 20}},
495+
},
496+
"ch3": GrantHistory{
497+
UpdatedAt: 3000,
498+
Entries: []GrantHistorySequencePair{{StartSeq: 21, EndSeq: 30}},
499+
},
500+
}
501+
}
502+
503+
t.Run("CompactsExistingChannels", func(t *testing.T) {
504+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
505+
user, err := auth.NewUser("user", "password", base.Set{})
506+
require.NoError(t, err)
507+
u := user.(*userImpl)
508+
509+
u.SetCollectionChannelHistory(scope, collection, newHistory())
510+
511+
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch2"})
512+
513+
require.ElementsMatch(t, []string{"ch1", "ch2"}, compacted)
514+
515+
afterHistory := u.CollectionChannelHistory(scope, collection)
516+
_, ch1Present := afterHistory["ch1"]
517+
_, ch2Present := afterHistory["ch2"]
518+
assert.False(t, ch1Present, "ch1 should have been removed from channel history")
519+
assert.False(t, ch2Present, "ch2 should have been removed from channel history")
520+
521+
ch3Entry, ch3Present := afterHistory["ch3"]
522+
require.True(t, ch3Present, "ch3 should remain in channel history")
523+
assert.Equal(t, int64(3000), ch3Entry.UpdatedAt)
524+
require.Len(t, ch3Entry.Entries, 1)
525+
assert.Equal(t, GrantHistorySequencePair{StartSeq: 21, EndSeq: 30}, ch3Entry.Entries[0])
526+
})
527+
528+
t.Run("NonExistentChannelReturnsEmpty", func(t *testing.T) {
529+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
530+
user, err := auth.NewUser("user", "password", base.Set{})
531+
require.NoError(t, err)
532+
u := user.(*userImpl)
533+
534+
u.SetCollectionChannelHistory(scope, collection, newHistory())
535+
536+
compacted := u.CompactChannelHistory(scope, collection, []string{"doesNotExist"})
537+
assert.Empty(t, compacted)
538+
539+
afterHistory := u.CollectionChannelHistory(scope, collection)
540+
require.Len(t, afterHistory, 3, "all three channels should remain in history")
541+
_, ch1Present := afterHistory["ch1"]
542+
_, ch2Present := afterHistory["ch2"]
543+
_, ch3Present := afterHistory["ch3"]
544+
assert.True(t, ch1Present)
545+
assert.True(t, ch2Present)
546+
assert.True(t, ch3Present)
547+
})
548+
549+
t.Run("MultipleCollectionsIsolated", func(t *testing.T) {
550+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
551+
user, err := auth.NewUser("user", "password", base.Set{})
552+
require.NoError(t, err)
553+
u := user.(*userImpl)
554+
555+
const (
556+
scope2 = "scope2"
557+
collection2 = "collection2"
558+
)
559+
560+
// Set up history in both collections
561+
u.SetCollectionChannelHistory(scope, collection, newHistory())
562+
u.SetCollectionChannelHistory(scope2, collection2, newHistory())
563+
564+
// Compact channels in the first collection only
565+
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1"})
566+
require.ElementsMatch(t, []string{"ch1"}, compacted)
567+
568+
// Verify first collection is modified
569+
history1 := u.CollectionChannelHistory(scope, collection)
570+
_, ch1Present := history1["ch1"]
571+
assert.False(t, ch1Present, "ch1 should be removed from first collection")
572+
require.Len(t, history1, 2, "first collection should have 2 channels left")
573+
574+
// Verify second collection is untouched
575+
history2 := u.CollectionChannelHistory(scope2, collection2)
576+
require.Len(t, history2, 3, "second collection should still have all 3 channels")
577+
_, ch1PresentInSecond := history2["ch1"]
578+
assert.True(t, ch1PresentInSecond, "ch1 should still be in second collection")
579+
})
580+
581+
t.Run("NonExistentCollectionReturnsEmpty", func(t *testing.T) {
582+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
583+
user, err := auth.NewUser("user", "password", base.Set{})
584+
require.NoError(t, err)
585+
u := user.(*userImpl)
586+
587+
const (
588+
nonExistentScope = "nonexistent"
589+
nonExistentCollection = "nothere"
590+
)
591+
592+
// Try to compact a scope/collection that was never set up
593+
compacted := u.CompactChannelHistory(nonExistentScope, nonExistentCollection, []string{"ch1", "ch2"})
594+
assert.Empty(t, compacted, "compacting non-existent collection should return empty slice")
595+
596+
// Verify the operation doesn't create an entry
597+
history := u.CollectionChannelHistory(nonExistentScope, nonExistentCollection)
598+
assert.Nil(t, history, "non-existent collection should return nil history")
599+
})
600+
601+
t.Run("NilHistoryForExistingCollection", func(t *testing.T) {
602+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
603+
user, err := auth.NewUser("user", "password", base.Set{})
604+
require.NoError(t, err)
605+
u := user.(*userImpl)
606+
607+
// Grant explicit channels so the CollectionAccess entry exists, but no history is set.
608+
// This mirrors a user who has active channels but has never had any revoked.
609+
user.SetCollectionExplicitChannels(scope, collection, channels.AtSequence(channels.BaseSetOf(t, "ch1"), 1), 0)
610+
611+
// CollectionChannelHistory returns nil via cc.ChannelHistory_ (collection exists, history does not),
612+
// which is distinct from the NonExistentCollectionReturnsEmpty case where getCollectionAccess
613+
// returns false entirely.
614+
require.Nil(t, u.CollectionChannelHistory(scope, collection))
615+
616+
// CompactChannelHistory must handle nil history gracefully and return empty.
617+
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch99"})
618+
assert.Empty(t, compacted)
619+
620+
// Explicit channels must be untouched by the compaction.
621+
cc, ok := u.getCollectionAccess(scope, collection)
622+
require.True(t, ok)
623+
assert.True(t, cc.ExplicitChannels_.Contains("ch1"))
624+
})
625+
626+
t.Run("MixOfExistingAndNonExistingChannels", func(t *testing.T) {
627+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
628+
user, err := auth.NewUser("user", "password", base.Set{})
629+
require.NoError(t, err)
630+
u := user.(*userImpl)
631+
632+
u.SetCollectionChannelHistory(scope, collection, newHistory())
633+
634+
// Compact a mix: ch1 exists, ch2 exists, ch99 doesn't, ch3 exists
635+
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch99", "ch2", "ch3"})
636+
637+
// Only the ones that existed should be returned
638+
require.ElementsMatch(t, []string{"ch1", "ch2", "ch3"}, compacted)
639+
640+
// All three should be removed
641+
afterHistory := u.CollectionChannelHistory(scope, collection)
642+
assert.Len(t, afterHistory, 0, "all channels should have been removed")
643+
})
644+
645+
t.Run("DuplicateChannelNamesInInput", func(t *testing.T) {
646+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
647+
user, err := auth.NewUser("user", "password", base.Set{})
648+
require.NoError(t, err)
649+
u := user.(*userImpl)
650+
651+
u.SetCollectionChannelHistory(scope, collection, newHistory())
652+
653+
// Pass duplicate channel names: ch1 twice, ch2 once
654+
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch1", "ch2"})
655+
656+
// First ch1 succeeds (returns true), second ch1 fails (already deleted, returns false in the map check)
657+
// So we get ch1 and ch2 in the result. The function returns duplicates if passed duplicates.
658+
require.ElementsMatch(t, []string{"ch1", "ch2"}, compacted)
659+
660+
// Verify ch1 and ch2 are removed (only once, idempotently)
661+
afterHistory := u.CollectionChannelHistory(scope, collection)
662+
_, ch1Present := afterHistory["ch1"]
663+
_, ch2Present := afterHistory["ch2"]
664+
assert.False(t, ch1Present)
665+
assert.False(t, ch2Present)
666+
667+
// ch3 should remain
668+
_, ch3Present := afterHistory["ch3"]
669+
require.True(t, ch3Present)
670+
require.Len(t, afterHistory, 1)
671+
})
672+
673+
t.Run("DefaultCollection", func(t *testing.T) {
674+
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
675+
user, err := auth.NewUser("user", "password", base.Set{})
676+
require.NoError(t, err)
677+
u := user.(*userImpl)
678+
679+
// Set up history for the default collection
680+
u.SetCollectionChannelHistory(base.DefaultScope, base.DefaultCollection, newHistory())
681+
682+
// Verify history is accessible via default collection
683+
history := u.CollectionChannelHistory(base.DefaultScope, base.DefaultCollection)
684+
require.Len(t, history, 3)
685+
686+
// Compact channels in default collection
687+
compacted := u.CompactChannelHistory(base.DefaultScope, base.DefaultCollection, []string{"ch1", "ch3"})
688+
require.ElementsMatch(t, []string{"ch1", "ch3"}, compacted)
689+
690+
// Verify removal
691+
afterHistory := u.CollectionChannelHistory(base.DefaultScope, base.DefaultCollection)
692+
_, ch1Present := afterHistory["ch1"]
693+
_, ch2Present := afterHistory["ch2"]
694+
_, ch3Present := afterHistory["ch3"]
695+
assert.False(t, ch1Present)
696+
assert.True(t, ch2Present, "ch2 should remain")
697+
assert.False(t, ch3Present)
698+
require.Len(t, afterHistory, 1)
699+
})
700+
}
701+
472702
func docExists(t *testing.T, dataStore base.DataStore, key string) {
473703
_, _, err := dataStore.GetRaw(key)
474704
require.Nil(t, err, "doc %s should exist in datastore", key)

base/audit_events.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,14 @@ const (
8080
AuditIDDatabaseFlush AuditID = 54045
8181

8282
// User principal events
83-
AuditIDUserCreate AuditID = 54100
84-
AuditIDUserRead AuditID = 54101
85-
AuditIDUserUpdate AuditID = 54102
86-
AuditIDUserDelete AuditID = 54103
87-
AuditIDUsersAll AuditID = 54104
83+
AuditIDUserCreate AuditID = 54100
84+
AuditIDUserRead AuditID = 54101
85+
AuditIDUserUpdate AuditID = 54102
86+
AuditIDUserDelete AuditID = 54103
87+
AuditIDUsersAll AuditID = 54104
88+
AuditIDUserAccessHistoryRead AuditID = 54105
89+
AuditIDUserAccessHistoryCompact AuditID = 54106
90+
8891
// Role principal events
8992
AuditIDRoleCreate AuditID = 54110
9093
AuditIDRoleRead AuditID = 54111
@@ -763,6 +766,29 @@ var AuditEvents = events{
763766
FilteringPermitted: true,
764767
EventType: eventTypeAdmin,
765768
},
769+
AuditIDUserAccessHistoryRead: {
770+
Name: "Read user access history",
771+
Description: "User access history was read",
772+
MandatoryFields: AuditFields{
773+
AuditFieldUserName: "username",
774+
AuditFieldDatabase: "database name",
775+
},
776+
EnabledByDefault: true,
777+
FilteringPermitted: true,
778+
EventType: eventTypeAdmin,
779+
},
780+
AuditIDUserAccessHistoryCompact: {
781+
Name: "Compact user access history",
782+
Description: "User access history was compacted",
783+
MandatoryFields: AuditFields{
784+
AuditFieldUserName: "username",
785+
AuditFieldDatabase: "database name",
786+
AuditFieldChannels: map[string]map[string][]string{"scopeName": {"collectionName": {"list", "of", "channels"}}},
787+
},
788+
EnabledByDefault: true,
789+
FilteringPermitted: true,
790+
EventType: eventTypeAdmin,
791+
},
766792
AuditIDRoleCreate: {
767793
Name: "Create role",
768794
Description: "A new role was created",

docs/api/admin.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ paths:
5151
$ref: './paths/admin/db-_user-name-_session.yaml'
5252
'/{db}/_user/{name}/_session/{sessionid}':
5353
$ref: './paths/admin/db-_user-name-_session-sessionid.yaml'
54+
'/{db}/_user/{name}/_access_history':
55+
$ref: './paths/admin/db-_user-name-_access_history.yaml'
56+
'/{db}/_user/{name}/_access_history/compact':
57+
$ref: './paths/admin/db-_user-name-_access_history-compact.yaml'
5458
'/{db}/_role/':
5559
$ref: './paths/admin/db-_role-.yaml'
5660
'/{db}/_role/{name}':

0 commit comments

Comments
 (0)