diff --git a/auth/collection_access.go b/auth/collection_access.go index 6ebf5d5e21..467d9fb653 100644 --- a/auth/collection_access.go +++ b/auth/collection_access.go @@ -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. diff --git a/auth/principal.go b/auth/principal.go index 223e7f34cb..23216e8308 100644 --- a/auth/principal.go +++ b/auth/principal.go @@ -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) diff --git a/auth/role.go b/auth/role.go index 3f3b0b9d16..a42b451b05 100644 --- a/auth/role.go +++ b/auth/role.go @@ -405,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 { diff --git a/auth/role_collection_access.go b/auth/role_collection_access.go index 0c7704c1b5..a5afa33e66 100644 --- a/auth/role_collection_access.go +++ b/auth/role_collection_access.go @@ -9,6 +9,9 @@ package auth import ( + "maps" + "slices" + "github.com/couchbase/sync_gateway/base" ch "github.com/couchbase/sync_gateway/channels" ) @@ -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 +} diff --git a/auth/user.go b/auth/user.go index bd93e5a095..7a63645562 100644 --- a/auth/user.go +++ b/auth/user.go @@ -750,15 +750,6 @@ func (user *userImpl) GetAddedChannels(channels ch.TimedSet) (base.Set, error) { return output, nil } -func (user *userImpl) CompactChannelHistory(scope, collection string, channels []string) []string { - chanHistory := user.CollectionChannelHistory(scope, collection) - if chanHistory == nil { - return []string{} - } - compactedChannels := chanHistory.PruneHistoryByKey(channels) - return compactedChannels -} - // ////// MARSHALING: // JSON encoding/decoding -- these functions are ugly hacks to work around the current diff --git a/docs/api/admin.yaml b/docs/api/admin.yaml index adcedeea4c..26a61a4a35 100644 --- a/docs/api/admin.yaml +++ b/docs/api/admin.yaml @@ -51,10 +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}/_history': - $ref: './paths/admin/db-_user-name-_history.yaml' - '/{db}/_user/{name}/_history/compact': - $ref: './paths/admin/db-_user-name-_history-compact.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}': diff --git a/docs/api/components/schemas.yaml b/docs/api/components/schemas.yaml index 494652bc10..80a3ccc66d 100644 --- a/docs/api/components/schemas.yaml +++ b/docs/api/components/schemas.yaml @@ -3578,21 +3578,34 @@ RegistryScope: items: type: string +CollectionAccessHistory: + description: Channel history organized by scope name. Each scope contains a map of collection names to arrays of channel names. + type: object + additionalProperties: + x-additionalPropertiesName: scope + type: object + additionalProperties: + x-additionalPropertiesName: collection + type: array + items: + type: string + description: Array of channel names with revocation history in this collection. + UserHistory: - description: User history containing various history types organized by scope and collection. + description: User channel history organized by scope and collection. type: object + title: UserHistory properties: channels: description: Channel history organized by scope and collection. - type: object - additionalProperties: - description: An object keyed by scope name. - type: object - x-additionalPropertiesName: scopename - additionalProperties: - description: An array of channel names for the collection. - type: array - x-additionalPropertiesName: collectionname - items: - type: string - title: UserHistory + allOf: + - $ref: '#/CollectionAccessHistory' +CompactedUserHistory: + description: User channel history organized by scope and collection. + type: object + title: CompactedUserHistory + properties: + compacted_channels: + description: Channel history organized by scope and collection. + allOf: + - $ref: '#/CollectionAccessHistory' diff --git a/docs/api/paths/admin/db-_user-name-_history-compact.yaml b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml similarity index 92% rename from docs/api/paths/admin/db-_user-name-_history-compact.yaml rename to docs/api/paths/admin/db-_user-name-_access_history-compact.yaml index ded0f99677..1d2e36497c 100644 --- a/docs/api/paths/admin/db-_user-name-_history-compact.yaml +++ b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml @@ -34,12 +34,12 @@ post: content: application/json: schema: - $ref: ../../components/schemas.yaml#/UserHistory + $ref: ../../components/schemas.yaml#/CompactedUserHistory example: - channels: + compacted_channels: scope1: collection1: - - scoped_channel2 + - scoped_channel1 '400': description: Bad request. Invalid channel names or malformed request body. content: diff --git a/docs/api/paths/admin/db-_user-name-_history.yaml b/docs/api/paths/admin/db-_user-name-_access_history.yaml similarity index 100% rename from docs/api/paths/admin/db-_user-name-_history.yaml rename to docs/api/paths/admin/db-_user-name-_access_history.yaml diff --git a/rest/admin_api.go b/rest/admin_api.go index 4b91dbe96b..c216efa66b 100644 --- a/rest/admin_api.go +++ b/rest/admin_api.go @@ -2511,6 +2511,75 @@ func (h *handler) handleGetClusterInfo() error { return nil } +type GetUserAccessHistoryResponse struct { + Channels auth.CollectionAccessHistory `json:"channels"` +} + +type CompactUserAccessHistoryResponse struct { + CompactedChannels auth.CollectionAccessHistory `json:"compacted_channels"` +} + +func (h *handler) getUserChannelHistory() error { + h.assertAdminOnly() + username := internalUserName(mux.Vars(h.rq)["name"]) + user, err := h.db.Authenticator(h.ctx()).GetUser(username) + if err != nil { + return err + } + if user == nil { + return kNotFoundError + } + + collectionAccessHistory := user.GetCollectionAccessHistory() + + userChannelHistory := GetUserAccessHistoryResponse{ + Channels: collectionAccessHistory, + } + + h.writeJSON(userChannelHistory) + + return err +} + +func (h *handler) compactUserChannelHistory() error { + h.assertAdminOnly() + var reqUserChannelHistory GetUserAccessHistoryResponse + err := h.readJSONInto(&reqUserChannelHistory) + if err != nil { + return base.HTTPErrorf(http.StatusBadRequest, "Invalid request payload: %v", err) + } + + username := internalUserName(mux.Vars(h.rq)["name"]) + authenticator := h.db.Authenticator(h.ctx()) + user, err := authenticator.GetUser(username) + if err != nil { + return err + } + if user == nil { + return kNotFoundError + } + + colAccessHistoryMap := make(map[string]map[string][]string) + for scope, cols := range reqUserChannelHistory.Channels { + colAccessHistoryMap[scope] = make(map[string][]string) + for col, colVal := range cols { + colAccessHistoryMap[scope][col] = user.CompactChannelHistory(scope, col, colVal) + } + } + + userCompactedChannelHistory := CompactUserAccessHistoryResponse{ + CompactedChannels: colAccessHistoryMap, + } + + err = authenticator.Save(user) + if err != nil { + return err + } + + h.writeJSON(userCompactedChannelHistory) + return nil +} + // databaseLoadErrorAsHTTPError converts an error loading a database into an error with an http status code. Pulled into a function so we can duplicate persistent and non persistent config logic. func databaseLoadErrorAsHTTPError(err error) error { var httpErr *base.HTTPError diff --git a/rest/revocation_test.go b/rest/revocation_test.go index 65c8f16145..723371ead1 100644 --- a/rest/revocation_test.go +++ b/rest/revocation_test.go @@ -902,6 +902,13 @@ func TestRevocationWithAdminChannels(t *testing.T) { assert.Equal(t, "doc", changes.Results[0].ID) assert.True(t, changes.Results[0].Revoked) + + body := fmt.Sprintf(`{"channels": {%q:{%q:["A"]}}}`, dataStore.ScopeName(), dataStore.CollectionName()) + resp = rt.SendAdminRequest("POST", "/db/_user/user/_access_history/compact", body) + RequireStatus(t, resp, http.StatusOK) + + changes = rt.WaitForChanges(1, fmt.Sprintf("/{{.keyspace}}/_changes?since=%d&revocations=true", 2), "user", false) + assert.Equal(t, "_user/user", changes.Results[0].ID) } func TestRevocationWithAdminRoles(t *testing.T) { diff --git a/rest/routing.go b/rest/routing.go index 9bcf2581ec..ae7c67583b 100644 --- a/rest/routing.go +++ b/rest/routing.go @@ -186,6 +186,10 @@ func CreateAdminRouter(sc *ServerContext) *mux.Router { makeHandler(sc, adminPrivs, []Permission{PermWritePrincipal}, nil, (*handler).putUser)).Methods("PUT") dbr.Handle("/_user/{name}", makeHandler(sc, adminPrivs, []Permission{PermWritePrincipal}, nil, (*handler).deleteUser)).Methods("DELETE") + dbr.Handle("/_user/{name}/_access_history", + makeHandler(sc, adminPrivs, []Permission{PermReadPrincipal}, nil, (*handler).getUserChannelHistory)).Methods("GET") + dbr.Handle("/_user/{name}/_access_history/compact", + makeHandler(sc, adminPrivs, []Permission{PermWritePrincipal}, nil, (*handler).compactUserChannelHistory)).Methods("POST") dbr.Handle("/_user/{name}/_session", makeHandler(sc, adminPrivs, []Permission{PermWritePrincipal}, nil, (*handler).deleteUserSessions)).Methods("DELETE") diff --git a/rest/upgradetest/user_access_history_test.go b/rest/upgradetest/user_access_history_test.go new file mode 100644 index 0000000000..47a2c5abe6 --- /dev/null +++ b/rest/upgradetest/user_access_history_test.go @@ -0,0 +1,150 @@ +// Copyright 2026-Present Couchbase, Inc. +// +// Use of this software is governed by the Business Source License included +// in the file licenses/BSL-Couchbase.txt. As of the Change Date specified +// in that file, in accordance with the Business Source License, use of this +// software will be governed by the Apache License, Version 2.0, included in +// the file licenses/APL2.txt. + +package upgradetest + +import ( + "fmt" + "net/http" + "testing" + + "github.com/couchbase/sync_gateway/base" + "github.com/couchbase/sync_gateway/rest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetUserChannelHistoryAfterCollectionUpgrade verifies channel history is reported for both the +// default collection and a named collection when a database originally configured with only +// _default._default is upgraded to add a named collection (in the default scope), and the user +// gains then loses channel access in that new collection. +// +// This test lives in the upgradetest package because it requires named collections to be created in +// the _default scope (UseDefaultScope), so that _default._default and a named collection can coexist +// in a single database - Sync Gateway only supports one named scope per database. +func TestGetUserChannelHistoryAfterCollectionUpgrade(t *testing.T) { + base.TestRequiresCollections(t) + base.RequireNumTestDataStores(t, 1) + + rt := rest.NewRestTesterPersistentConfigNoDB(t) + defer rt.Close() + + const dbName = "db" + + // 1. Create the database with only the default collection. + dbConfig := rt.NewDbConfig() + dbConfig.Scopes = rest.ScopesConfig{ + base.DefaultScope: rest.ScopeConfig{Collections: rest.CollectionsConfig{base.DefaultCollection: {}}}, + } + rest.RequireStatus(t, rt.CreateDatabase(dbName, dbConfig), http.StatusCreated) + + // 2. Create a user with chan1, chan2 in the default collection, then revoke them to populate + // the default collection's channel history. + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + `{"password":"letmein","admin_channels":["chan1","chan2"]}`), http.StatusCreated) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + `{"admin_channels":[]}`), http.StatusOK) + + // 3. Upgrade the database to add a named collection alongside the default collection (both in + // the default scope). + namedStore := rt.TestBucket.GetNonDefaultDatastoreNames()[0] + scope := namedStore.ScopeName() + collection := namedStore.CollectionName() + upgradedConfig := rt.NewDbConfig() + upgradedConfig.Scopes = rest.ScopesConfig{ + base.DefaultScope: rest.ScopeConfig{Collections: rest.CollectionsConfig{ + base.DefaultCollection: {}, + collection: {}, + }}, + } + rest.RequireStatus(t, rt.ReplaceDbConfig(dbName, upgradedConfig), http.StatusCreated) + + // 4. Grant the user channels in the new named collection, then revoke them to populate that + // collection's channel history. + grant := fmt.Sprintf(`{"collection_access":{%q:{%q:{"admin_channels":["chan3","chan4"]}}}}`, scope, collection) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", grant), http.StatusOK) + revoke := fmt.Sprintf(`{"collection_access":{%q:{%q:{"admin_channels":[]}}}}`, scope, collection) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", revoke), http.StatusOK) + + // 5. GET channel history and confirm both collections are reported. + response := rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") + rest.RequireStatus(t, response, http.StatusOK) + + var result rest.GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.Channels[base.DefaultScope][base.DefaultCollection]) + assert.ElementsMatch(t, []string{"chan3", "chan4"}, result.Channels[scope][collection]) +} + +// TestCompactUserChannelHistoryAfterCollectionUpgrade verifies the compact endpoint can remove +// channel history from both the default collection and a named collection when a database originally +// configured with only _default._default is upgraded to add a named collection (in the default +// scope), and the user has accrued channel history in both. +func TestCompactUserChannelHistoryAfterCollectionUpgrade(t *testing.T) { + base.TestRequiresCollections(t) + base.RequireNumTestDataStores(t, 1) + + rt := rest.NewRestTesterPersistentConfigNoDB(t) + defer rt.Close() + + const dbName = "db" + + // 1. Create the database with only the default collection. + dbConfig := rt.NewDbConfig() + dbConfig.Scopes = rest.ScopesConfig{ + base.DefaultScope: rest.ScopeConfig{Collections: rest.CollectionsConfig{base.DefaultCollection: {}}}, + } + rest.RequireStatus(t, rt.CreateDatabase(dbName, dbConfig), http.StatusCreated) + + // 2. Create a user with chan1, chan2 in the default collection, then revoke them to populate + // the default collection's channel history. + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + `{"password":"letmein","admin_channels":["chan1","chan2"]}`), http.StatusCreated) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + `{"admin_channels":[]}`), http.StatusOK) + + // 3. Upgrade the database to add a named collection alongside the default collection (both in + // the default scope). + namedStore := rt.TestBucket.GetNonDefaultDatastoreNames()[0] + scope := namedStore.ScopeName() + collection := namedStore.CollectionName() + upgradedConfig := rt.NewDbConfig() + upgradedConfig.Scopes = rest.ScopesConfig{ + base.DefaultScope: rest.ScopeConfig{Collections: rest.CollectionsConfig{ + base.DefaultCollection: {}, + collection: {}, + }}, + } + rest.RequireStatus(t, rt.ReplaceDbConfig(dbName, upgradedConfig), http.StatusCreated) + + // 4. Grant the user channels in the new named collection, then revoke them to populate that + // collection's channel history. + grant := fmt.Sprintf(`{"collection_access":{%q:{%q:{"admin_channels":["chan3","chan4"]}}}}`, scope, collection) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", grant), http.StatusOK) + revoke := fmt.Sprintf(`{"collection_access":{%q:{%q:{"admin_channels":[]}}}}`, scope, collection) + rest.RequireStatus(t, rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", revoke), http.StatusOK) + + // 5. Compact channel history in both the default and named collections. + compactBody := fmt.Sprintf(`{"channels":{%q:{%q:["chan1","chan2"],%q:["chan3","chan4"]}}}`, + base.DefaultScope, base.DefaultCollection, collection) + response := rt.SendAdminRequest(http.MethodPost, "/db/_user/user1/_access_history/compact", compactBody) + rest.RequireStatus(t, response, http.StatusOK) + + var result rest.CompactUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.CompactedChannels[base.DefaultScope][base.DefaultCollection]) + assert.ElementsMatch(t, []string{"chan3", "chan4"}, result.CompactedChannels[scope][collection]) + + // 6. GET channel history and confirm both collections are now empty. + getResp := rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") + rest.RequireStatus(t, getResp, http.StatusOK) + var afterHistory rest.GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(getResp.Body.Bytes(), &afterHistory)) + assert.Empty(t, afterHistory.Channels[base.DefaultScope][base.DefaultCollection]) + assert.Empty(t, afterHistory.Channels[scope][collection]) +} diff --git a/rest/user_api_test.go b/rest/user_api_test.go index c9f5ea98f1..97586004e0 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1622,6 +1622,305 @@ func TestDeletedRoleChanHistory(t *testing.T) { } +// TestGetUserChannelHistory tests the GET /_user/{name}/_access_history admin endpoint, +// which returns the revoked channel history for a user across all scopes and collections. +func TestGetUserChannelHistory(t *testing.T) { + rt := NewRestTester(t, nil) + defer rt.Close() + + // Returns 404 when the requested user does not exist. + t.Run("UserNotFound", func(t *testing.T) { + response := rt.SendAdminRequest(http.MethodGet, "/db/_user/ghost/_access_history", "") + RequireStatus(t, response, http.StatusNotFound) + }) + + // Returns empty history when the user has active channels but none have ever been revoked. + t.Run("UserWithNoChannelHistory", func(t *testing.T) { + ds := rt.GetSingleDataStore() + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + GetUserPayload(t, "user1", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]auth.CollectionAccessHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + scope := ds.ScopeName() + collection := ds.CollectionName() + assert.Empty(t, channelHistory[scope][collection]) + }) + + // Returns all channels that have been revoked from the user. + t.Run("UserWithChannelHistory", func(t *testing.T) { + ds := rt.GetSingleDataStore() + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user2", + GetUserPayload(t, "user2", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke both channels by updating user with empty channel list + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user2", + GetUserPayload(t, "user2", "letmein", "", ds, []string{}, nil)) + RequireStatus(t, response, http.StatusOK) + + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]auth.CollectionAccessHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + scope := ds.ScopeName() + collection := ds.CollectionName() + assert.ElementsMatch(t, []string{"chan1", "chan2"}, channelHistory[scope][collection]) + }) + + // Returns only revoked channels; active channels do not appear in the history. + t.Run("UserWithPartialChannelHistory", func(t *testing.T) { + ds := rt.GetSingleDataStore() + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user3", + GetUserPayload(t, "user3", "letmein", "", ds, []string{"chan1", "chan2", "chan3"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke chan1 and chan2, keep chan3 + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user3", + GetUserPayload(t, "user3", "letmein", "", ds, []string{"chan3"}, nil)) + RequireStatus(t, response, http.StatusOK) + + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]auth.CollectionAccessHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + scope := ds.ScopeName() + collection := ds.CollectionName() + assert.ElementsMatch(t, []string{"chan1", "chan2"}, channelHistory[scope][collection]) + }) + + // User has channels revoked in two named collections (same scope). Both collections and their respective revoked channels should appear in _channel_history. + t.Run("MultipleNamedCollections", func(t *testing.T) { + base.RequireNumTestDataStores(t, 2) + rtMulti := NewRestTesterMultipleCollections(t, nil, 2) + defer rtMulti.Close() + + scope := rtMulti.GetDbCollections()[0].ScopeName + col1 := rtMulti.GetDbCollections()[0].Name + col2 := rtMulti.GetDbCollections()[1].Name + + userPayload := fmt.Sprintf(`{ + "password": "letmein", + "collection_access": { + %q: { + %q: {"admin_channels": ["chan1", "chan2"]}, + %q: {"admin_channels": ["chan3", "chan4"]} + } + } + }`, scope, col1, col2) + response := rtMulti.SendAdminRequest(http.MethodPut, "/db/_user/user1", userPayload) + RequireStatus(t, response, http.StatusCreated) + + // Revoke all channels in both collections + revokePayload := fmt.Sprintf(`{ + "collection_access": { + %q: { + %q: {"admin_channels": []}, + %q: {"admin_channels": []} + } + } + }`, scope, col1, col2) + response = rtMulti.SendAdminRequest(http.MethodPut, "/db/_user/user1", revokePayload) + RequireStatus(t, response, http.StatusOK) + + response = rtMulti.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]auth.CollectionAccessHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + assert.ElementsMatch(t, []string{"chan1", "chan2"}, channelHistory[scope][col1]) + assert.ElementsMatch(t, []string{"chan3", "chan4"}, channelHistory[scope][col2]) + }) + + // Revocation history is preserved even after a previously revoked channel is re-granted. + t.Run("ChannelHistoryAfterReGrant", func(t *testing.T) { + ds := rt.GetSingleDataStore() + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user4", + GetUserPayload(t, "user4", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke both channels + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user4", + GetUserPayload(t, "user4", "letmein", "", ds, []string{}, nil)) + RequireStatus(t, response, http.StatusOK) + + // Re-grant chan1 + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user4", + GetUserPayload(t, "user4", "letmein", "", ds, []string{"chan1"}, nil)) + RequireStatus(t, response, http.StatusOK) + + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]auth.CollectionAccessHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + scope := ds.ScopeName() + collection := ds.CollectionName() + assert.ElementsMatch(t, []string{"chan1", "chan2"}, channelHistory[scope][collection]) + }) +} + +// TestCompactUserChannelHistory tests the POST /_user/{name}/_access_history/compact admin endpoint, +// which removes specified channels from a user's revocation history and returns those that were found and removed. +func TestCompactUserChannelHistory(t *testing.T) { + rt := NewRestTester(t, nil) + defer rt.Close() + + // Returns 404 when the requested user does not exist. + t.Run("UserNotFound", func(t *testing.T) { + ds := rt.GetSingleDataStore() + scope := ds.ScopeName() + collection := ds.CollectionName() + body := fmt.Sprintf(`{"channels":{%q:{%q:["chan1"]}}}`, scope, collection) + response := rt.SendAdminRequest(http.MethodPost, "/db/_user/ghost/_access_history/compact", body) + RequireStatus(t, response, http.StatusNotFound) + }) + + // Returns 400 when the request body is not valid JSON. + t.Run("InvalidBody", func(t *testing.T) { + ds := rt.GetSingleDataStore() + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user1", + GetUserPayload(t, "user1", "letmein", "", ds, []string{"chan1"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user1/_access_history/compact", `not-valid-json`) + RequireStatus(t, response, http.StatusBadRequest) + }) + + // Removes specified channels from history and returns them; subsequent GET shows empty history. + t.Run("CompactsExistingChannels", func(t *testing.T) { + ds := rt.GetSingleDataStore() + scope := ds.ScopeName() + collection := ds.CollectionName() + + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user2", + GetUserPayload(t, "user2", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke both channels to populate history + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user2", + GetUserPayload(t, "user2", "letmein", "", ds, []string{}, nil)) + RequireStatus(t, response, http.StatusOK) + + // Verify history is populated before compaction + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, beforeResult.Channels[scope][collection]) + + // Compact chan1 and chan2 + body := fmt.Sprintf(`{"channels":{%q:{%q:["chan1","chan2"]}}}`, scope, collection) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user2/_access_history/compact", body) + RequireStatus(t, response, http.StatusOK) + + var result CompactUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.CompactedChannels[scope][collection]) + + // GET confirms history is now empty + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.Empty(t, afterResult.Channels[scope][collection]) + }) + + // Returns empty for a collection when none of the requested channels exist in history; existing history is untouched. + t.Run("NonExistentChannelReturnsEmpty", func(t *testing.T) { + ds := rt.GetSingleDataStore() + scope := ds.ScopeName() + collection := ds.CollectionName() + + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user3", + GetUserPayload(t, "user3", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke both channels to populate history + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user3", + GetUserPayload(t, "user3", "letmein", "", ds, []string{}, nil)) + RequireStatus(t, response, http.StatusOK) + + // Verify history is populated before compaction + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, beforeResult.Channels[scope][collection]) + + // Compact a channel that doesn't exist in history + body := fmt.Sprintf(`{"channels":{%q:{%q:["doesNotExist"]}}}`, scope, collection) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user3/_access_history/compact", body) + RequireStatus(t, response, http.StatusOK) + + var result CompactUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.Empty(t, result.CompactedChannels[scope][collection]) + + // GET confirms original history is untouched + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, afterResult.Channels[scope][collection]) + }) + + // Only channels present in history are removed and returned; unknown channels are silently ignored. + t.Run("MixOfExistingAndNonExistingChannels", func(t *testing.T) { + ds := rt.GetSingleDataStore() + scope := ds.ScopeName() + collection := ds.CollectionName() + + response := rt.SendAdminRequest(http.MethodPut, "/db/_user/user4", + GetUserPayload(t, "user4", "letmein", "", ds, []string{"chan1", "chan2", "chan3"}, nil)) + RequireStatus(t, response, http.StatusCreated) + + // Revoke all channels to populate history + response = rt.SendAdminRequest(http.MethodPut, "/db/_user/user4", + GetUserPayload(t, "user4", "letmein", "", ds, []string{}, nil)) + RequireStatus(t, response, http.StatusOK) + + // Verify history is populated before compaction + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) + assert.ElementsMatch(t, []string{"chan1", "chan2", "chan3"}, beforeResult.Channels[scope][collection]) + + // Compact chan1 and doesNotExist — only chan1 should be returned + body := fmt.Sprintf(`{"channels":{%q:{%q:["chan1","doesNotExist"]}}}`, scope, collection) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user4/_access_history/compact", body) + RequireStatus(t, response, http.StatusOK) + + var result CompactUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1"}, result.CompactedChannels[scope][collection]) + + // GET confirms only chan1 was removed; chan2 and chan3 remain + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult GetUserAccessHistoryResponse + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.ElementsMatch(t, []string{"chan2", "chan3"}, afterResult.Channels[scope][collection]) + }) +} + // TestDisabledUser ensures that a disabled (non-guest) user cannot authenticate to make requests. func TestDisabledUser(t *testing.T) { rt := NewRestTester(t, nil)