Skip to content
Merged
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
3 changes: 3 additions & 0 deletions auth/collection_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ type CollectionChannelAPI interface {

// Returns the CollectionAccess map
GetCollectionsAccess() map[string]map[string]*CollectionAccess

// Returns the ColelctionAccessHistory map
GetCollectionAccessHistory() CollectionAccessHistory
}

// UserCollectionChannelAPI defines the interface for managing channel access that is supported by users but not roles.
Expand Down
2 changes: 2 additions & 0 deletions auth/principal.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type Principal interface {
// Sets the created time for the principal document
SetCreatedAt(t time.Time)

CompactChannelHistory(scope string, col string, channels []string) []string

// Principal includes the PrincipalCollectionAccess interface for operations against
// the _default._default collection (stored directly on the principal for backward
// compatibility)
Expand Down
20 changes: 20 additions & 0 deletions auth/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ func (timedSet TimedSetHistory) PruneHistory(partitionWindow time.Duration) []st
return prunedChannelHistory
}

func (timedSet TimedSetHistory) PruneHistoryByKey(keys []string) []string {
removedKeys := make([]string, 0)
for _, key := range keys {
if _, ok := timedSet[key]; ok {
delete(timedSet, key)
removedKeys = append(removedKeys, key)
}
}
return removedKeys
}

type GrantHistory struct {
UpdatedAt int64 `json:"updated_at"` // Timestamp at which history was last updated, allows for pruning
Entries []GrantHistorySequencePair `json:"entries"` // Entry for a specific grant period
Expand Down Expand Up @@ -394,6 +405,15 @@ func (role *roleImpl) authorizeAnyChannel(channels base.Set) error {
return authorizeAnyChannel(role, channels)
}

func (role *roleImpl) CompactChannelHistory(scope, collection string, channels []string) []string {
chanHistory := role.CollectionChannelHistory(scope, collection)
if chanHistory == nil {
return []string{}
}
compactedChannels := chanHistory.PruneHistoryByKey(channels)
return compactedChannels
}

// Returns an HTTP 403 error if the Principal is not allowed to access all the given channels.
// A nil Principal means access control is disabled, so the function will return nil.
func authorizeAllChannels(princ Principal, channels base.Set) error {
Expand Down
28 changes: 28 additions & 0 deletions auth/role_collection_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
package auth

import (
"maps"
"slices"

"github.com/couchbase/sync_gateway/base"
ch "github.com/couchbase/sync_gateway/channels"
)
Expand Down Expand Up @@ -231,3 +234,28 @@ func (role *roleImpl) initChannels(scopeName, collectionName string, channels ba
func (role *roleImpl) GetCollectionsAccess() map[string]map[string]*CollectionAccess {
return role.CollectionsAccess
}

// CollectionAccessHistory maps scope names to collections, each holding the list of channel names with revocation history.
// Shape: scope → collection → []channel
type CollectionAccessHistory map[string]map[string][]string

func (role *roleImpl) GetCollectionAccessHistory() CollectionAccessHistory {

collectionAccess := role.GetCollectionsAccess()
collectionAccessHistoryMap := make(CollectionAccessHistory)
for scope, cols := range collectionAccess {
collectionAccessHistoryMap[scope] = make(map[string][]string)
for col, colVal := range cols {
collectionAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colVal.ChannelHistory()))
}
}

// Always include the default collection's top-level channel history, without clobbering
// any named collections already recorded under the default scope.
if collectionAccessHistoryMap[base.DefaultScope] == nil {
collectionAccessHistoryMap[base.DefaultScope] = make(map[string][]string)
}
collectionAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(role.ChannelHistory()))

return collectionAccessHistoryMap
}
230 changes: 230 additions & 0 deletions auth/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,236 @@ func TestUserKeysHash(t *testing.T) {
}
}

func TestCompactCollectionChannelHistory(t *testing.T) {
base.SetUpTestLogging(t, base.LevelDebug, base.KeyAuth)

ctx := base.TestCtx(t)
testBucket := base.GetTestBucket(t)
defer testBucket.Close(ctx)
dataStore := testBucket.GetSingleDataStore()

const (
scope = "scope1"
collection = "collection1"
)

// Helper to create fresh history state for each subtest
newHistory := func() TimedSetHistory {
return TimedSetHistory{
"ch1": GrantHistory{
UpdatedAt: 1000,
Entries: []GrantHistorySequencePair{{StartSeq: 1, EndSeq: 10}},
},
"ch2": GrantHistory{
UpdatedAt: 2000,
Entries: []GrantHistorySequencePair{{StartSeq: 11, EndSeq: 20}},
},
"ch3": GrantHistory{
UpdatedAt: 3000,
Entries: []GrantHistorySequencePair{{StartSeq: 21, EndSeq: 30}},
},
}
}

t.Run("CompactsExistingChannels", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

u.SetCollectionChannelHistory(scope, collection, newHistory())

compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch2"})

require.ElementsMatch(t, []string{"ch1", "ch2"}, compacted)

afterHistory := u.CollectionChannelHistory(scope, collection)
_, ch1Present := afterHistory["ch1"]
_, ch2Present := afterHistory["ch2"]
assert.False(t, ch1Present, "ch1 should have been removed from channel history")
assert.False(t, ch2Present, "ch2 should have been removed from channel history")

ch3Entry, ch3Present := afterHistory["ch3"]
require.True(t, ch3Present, "ch3 should remain in channel history")
assert.Equal(t, int64(3000), ch3Entry.UpdatedAt)
require.Len(t, ch3Entry.Entries, 1)
assert.Equal(t, GrantHistorySequencePair{StartSeq: 21, EndSeq: 30}, ch3Entry.Entries[0])
})

t.Run("NonExistentChannelReturnsEmpty", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

u.SetCollectionChannelHistory(scope, collection, newHistory())

compacted := u.CompactChannelHistory(scope, collection, []string{"doesNotExist"})
assert.Empty(t, compacted)

afterHistory := u.CollectionChannelHistory(scope, collection)
require.Len(t, afterHistory, 3, "all three channels should remain in history")
_, ch1Present := afterHistory["ch1"]
_, ch2Present := afterHistory["ch2"]
_, ch3Present := afterHistory["ch3"]
assert.True(t, ch1Present)
assert.True(t, ch2Present)
assert.True(t, ch3Present)
})

t.Run("MultipleCollectionsIsolated", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

const (
scope2 = "scope2"
collection2 = "collection2"
)

// Set up history in both collections
u.SetCollectionChannelHistory(scope, collection, newHistory())
u.SetCollectionChannelHistory(scope2, collection2, newHistory())

// Compact channels in the first collection only
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1"})
require.ElementsMatch(t, []string{"ch1"}, compacted)

// Verify first collection is modified
history1 := u.CollectionChannelHistory(scope, collection)
_, ch1Present := history1["ch1"]
assert.False(t, ch1Present, "ch1 should be removed from first collection")
require.Len(t, history1, 2, "first collection should have 2 channels left")

// Verify second collection is untouched
history2 := u.CollectionChannelHistory(scope2, collection2)
require.Len(t, history2, 3, "second collection should still have all 3 channels")
_, ch1PresentInSecond := history2["ch1"]
assert.True(t, ch1PresentInSecond, "ch1 should still be in second collection")
})

t.Run("NonExistentCollectionReturnsEmpty", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

const (
nonExistentScope = "nonexistent"
nonExistentCollection = "nothere"
)

// Try to compact a scope/collection that was never set up
compacted := u.CompactChannelHistory(nonExistentScope, nonExistentCollection, []string{"ch1", "ch2"})
assert.Empty(t, compacted, "compacting non-existent collection should return empty slice")

// Verify the operation doesn't create an entry
history := u.CollectionChannelHistory(nonExistentScope, nonExistentCollection)
assert.Nil(t, history, "non-existent collection should return nil history")
})

t.Run("NilHistoryForExistingCollection", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

// Grant explicit channels so the CollectionAccess entry exists, but no history is set.
// This mirrors a user who has active channels but has never had any revoked.
user.SetCollectionExplicitChannels(scope, collection, channels.AtSequence(channels.BaseSetOf(t, "ch1"), 1), 0)

// CollectionChannelHistory returns nil via cc.ChannelHistory_ (collection exists, history does not),
// which is distinct from the NonExistentCollectionReturnsEmpty case where getCollectionAccess
// returns false entirely.
require.Nil(t, u.CollectionChannelHistory(scope, collection))

// CompactChannelHistory must handle nil history gracefully and return empty.
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch99"})
assert.Empty(t, compacted)

// Explicit channels must be untouched by the compaction.
cc, ok := u.getCollectionAccess(scope, collection)
require.True(t, ok)
assert.True(t, cc.ExplicitChannels_.Contains("ch1"))
})

t.Run("MixOfExistingAndNonExistingChannels", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

u.SetCollectionChannelHistory(scope, collection, newHistory())

// Compact a mix: ch1 exists, ch2 exists, ch99 doesn't, ch3 exists
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch99", "ch2", "ch3"})

// Only the ones that existed should be returned
require.ElementsMatch(t, []string{"ch1", "ch2", "ch3"}, compacted)

// All three should be removed
afterHistory := u.CollectionChannelHistory(scope, collection)
assert.Len(t, afterHistory, 0, "all channels should have been removed")
})

t.Run("DuplicateChannelNamesInInput", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

u.SetCollectionChannelHistory(scope, collection, newHistory())

// Pass duplicate channel names: ch1 twice, ch2 once
compacted := u.CompactChannelHistory(scope, collection, []string{"ch1", "ch1", "ch2"})

// First ch1 succeeds (returns true), second ch1 fails (already deleted, returns false in the map check)
// So we get ch1 and ch2 in the result. The function returns duplicates if passed duplicates.
require.ElementsMatch(t, []string{"ch1", "ch2"}, compacted)

// Verify ch1 and ch2 are removed (only once, idempotently)
afterHistory := u.CollectionChannelHistory(scope, collection)
_, ch1Present := afterHistory["ch1"]
_, ch2Present := afterHistory["ch2"]
assert.False(t, ch1Present)
assert.False(t, ch2Present)

// ch3 should remain
_, ch3Present := afterHistory["ch3"]
require.True(t, ch3Present)
require.Len(t, afterHistory, 1)
})

t.Run("DefaultCollection", func(t *testing.T) {
auth := NewTestAuthenticator(t, dataStore, nil, DefaultAuthenticatorOptions(ctx))
user, err := auth.NewUser("user", "password", base.Set{})
require.NoError(t, err)
u := user.(*userImpl)

// Set up history for the default collection
u.SetCollectionChannelHistory(base.DefaultScope, base.DefaultCollection, newHistory())

// Verify history is accessible via default collection
history := u.CollectionChannelHistory(base.DefaultScope, base.DefaultCollection)
require.Len(t, history, 3)

// Compact channels in default collection
compacted := u.CompactChannelHistory(base.DefaultScope, base.DefaultCollection, []string{"ch1", "ch3"})
require.ElementsMatch(t, []string{"ch1", "ch3"}, compacted)

// Verify removal
afterHistory := u.CollectionChannelHistory(base.DefaultScope, base.DefaultCollection)
_, ch1Present := afterHistory["ch1"]
_, ch2Present := afterHistory["ch2"]
_, ch3Present := afterHistory["ch3"]
assert.False(t, ch1Present)
assert.True(t, ch2Present, "ch2 should remain")
assert.False(t, ch3Present)
require.Len(t, afterHistory, 1)
})
}

func docExists(t *testing.T, dataStore base.DataStore, key string) {
_, _, err := dataStore.GetRaw(key)
require.Nil(t, err, "doc %s should exist in datastore", key)
Expand Down
36 changes: 31 additions & 5 deletions base/audit_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,14 @@ const (
AuditIDDatabaseFlush AuditID = 54045

// User principal events
AuditIDUserCreate AuditID = 54100
AuditIDUserRead AuditID = 54101
AuditIDUserUpdate AuditID = 54102
AuditIDUserDelete AuditID = 54103
AuditIDUsersAll AuditID = 54104
AuditIDUserCreate AuditID = 54100
AuditIDUserRead AuditID = 54101
AuditIDUserUpdate AuditID = 54102
AuditIDUserDelete AuditID = 54103
AuditIDUsersAll AuditID = 54104
AuditIDUserAccessHistoryRead AuditID = 54105
AuditIDUserAccessHistoryCompact AuditID = 54106

// Role principal events
AuditIDRoleCreate AuditID = 54110
AuditIDRoleRead AuditID = 54111
Expand Down Expand Up @@ -761,6 +764,29 @@ var AuditEvents = events{
FilteringPermitted: true,
EventType: eventTypeAdmin,
},
AuditIDUserAccessHistoryRead: {
Name: "Read user access history",
Description: "User access history was read",
MandatoryFields: AuditFields{
AuditFieldUserName: "username",
AuditFieldDatabase: "database name",
},
EnabledByDefault: true,
FilteringPermitted: true,
EventType: eventTypeAdmin,
},
AuditIDUserAccessHistoryCompact: {
Name: "Compact user access history",
Description: "User access history was compacted",
MandatoryFields: AuditFields{
AuditFieldUserName: "username",
AuditFieldDatabase: "database name",
AuditFieldChannels: map[string]map[string][]string{"scopeName": {"collectionName": {"list", "of", "channels"}}},
},
EnabledByDefault: true,
FilteringPermitted: true,
EventType: eventTypeAdmin,
},
AuditIDRoleCreate: {
Name: "Create role",
Description: "A new role was created",
Expand Down
4 changes: 4 additions & 0 deletions docs/api/admin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ paths:
$ref: './paths/admin/db-_user-name-_session.yaml'
'/{db}/_user/{name}/_session/{sessionid}':
$ref: './paths/admin/db-_user-name-_session-sessionid.yaml'
'/{db}/_user/{name}/_access_history':
$ref: './paths/admin/db-_user-name-_access_history.yaml'
'/{db}/_user/{name}/_access_history/compact':
$ref: './paths/admin/db-_user-name-_access_history-compact.yaml'
'/{db}/_role/':
$ref: './paths/admin/db-_role-.yaml'
'/{db}/_role/{name}':
Expand Down
Loading
Loading