From 5c595a2cad781730b423218f63ef21a07b1b00a5 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Fri, 15 May 2026 18:21:52 +0530 Subject: [PATCH 01/10] wire up rest api --- auth/principal.go | 2 + rest/admin_api.go | 76 ++++++++++++++++++++ rest/routing.go | 4 ++ rest/user_api_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) diff --git a/auth/principal.go b/auth/principal.go index 223e7f34cb..ec8506a113 100644 --- a/auth/principal.go +++ b/auth/principal.go @@ -137,6 +137,8 @@ type User interface { InitializeRoles() error + CompactChannelHistory(scope string, col string, channels []string) []string + revokedChannels(since uint64, lowSeq uint64, triggeredBy uint64) (RevokedChannels, error) // Obtains the period over which the user had access to the given channel. Either directly or via a role. diff --git a/rest/admin_api.go b/rest/admin_api.go index 4b91dbe96b..06ef6994d4 100644 --- a/rest/admin_api.go +++ b/rest/admin_api.go @@ -16,7 +16,9 @@ import ( "errors" "fmt" "io" + "maps" "net/http" + "slices" "strings" "sync/atomic" "time" @@ -2511,6 +2513,80 @@ func (h *handler) handleGetClusterInfo() error { return nil } +type ColAccessHistoryMap map[string]map[string][]string + +type UserChannelHistory struct { + Channels ColAccessHistoryMap `json:"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 user == nil { + if err == nil { + err = kNotFoundError + } + return err + } + colAccess := user.GetCollectionsAccess() + + colAccessHistoryMap := make(map[string]map[string][]string) + for scope, _ := range colAccess { + colAccessHistoryMap[scope] = make(map[string][]string) + for col, _ := range colAccess[scope] { + colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colAccess[scope][col].ChannelHistory_)) + } + } + + userChannelHistory := UserChannelHistory{ + Channels: colAccessHistoryMap, + } + + h.writeJSON(userChannelHistory) + + return err +} + +func (h *handler) compactUserChannelHistory() error { + h.assertAdminOnly() + var reqUserChannelHistory UserChannelHistory + err := h.readJSONInto(&reqUserChannelHistory) + if err != nil { + return base.HTTPErrorf(http.StatusBadRequest, "Failed to read user channel history: %v", err) + } + + username := internalUserName(mux.Vars(h.rq)["name"]) + authenticator := h.db.Authenticator(h.ctx()) + user, err := authenticator.GetUser(username) + if user == nil { + if err == nil { + err = kNotFoundError + } + return err + } + + colAccessHistoryMap := make(map[string]map[string][]string) + for scope, _ := range reqUserChannelHistory.Channels { + colAccessHistoryMap[scope] = make(map[string][]string) + for col, _ := range reqUserChannelHistory.Channels[scope] { + colAccessHistoryMap[scope][col] = user.CompactChannelHistory(scope, col, reqUserChannelHistory.Channels[scope][col]) + } + } + + userCompactedChannelHistory := UserChannelHistory{ + Channels: 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/routing.go b/rest/routing.go index 9bcf2581ec..26b988f6d0 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}/_channel_history", + makeHandler(sc, adminPrivs, []Permission{PermReadPrincipal}, nil, (*handler).getUserChannelHistory)).Methods("GET") + dbr.Handle("/_user/{name}/_channel_history", + 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/user_api_test.go b/rest/user_api_test.go index c9f5ea98f1..2fae6fef55 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1623,6 +1623,170 @@ func TestDeletedRoleChanHistory(t *testing.T) { } // TestDisabledUser ensures that a disabled (non-guest) user cannot authenticate to make requests. +func TestGetUserChannelHistory(t *testing.T) { + rt := NewRestTester(t, nil) + defer rt.Close() + + t.Run("UserNotFound", func(t *testing.T) { + response := rt.SendAdminRequest(http.MethodGet, "/db/_user/ghost/_channel_history", "") + RequireStatus(t, response, http.StatusNotFound) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]ColAccessHistoryMap + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + + channelHistory := result["channels"] + scope := ds.ScopeName() + collection := ds.CollectionName() + assert.Empty(t, channelHistory[scope][collection]) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]ColAccessHistoryMap + 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]) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]ColAccessHistoryMap + 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]) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + + var result map[string]ColAccessHistoryMap + 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]) + }) +} + +func TestCompactUserChannelHistory(t *testing.T) { + rt := NewRestTester(t, nil) + defer rt.Close() + + 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/_channel_history", body) + RequireStatus(t, response, http.StatusNotFound) + }) + + 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/_channel_history", `not-valid-json`) + RequireStatus(t, response, http.StatusBadRequest) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult UserChannelHistory + 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/_channel_history", body) + RequireStatus(t, response, http.StatusOK) + + var result UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.Channels[scope][collection]) + + // GET confirms history is now empty + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.Empty(t, afterResult.Channels[scope][collection]) + }) +} + func TestDisabledUser(t *testing.T) { rt := NewRestTester(t, nil) defer rt.Close() From d9a1c907523e68485c13a56073c6a0bbe269b132 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Fri, 15 May 2026 18:37:29 +0530 Subject: [PATCH 02/10] add test coverage for revocations --- rest/revocation_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rest/revocation_test.go b/rest/revocation_test.go index 65c8f16145..3af2adf59b 100644 --- a/rest/revocation_test.go +++ b/rest/revocation_test.go @@ -902,6 +902,12 @@ 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/_channel_history", body) + RequireStatus(t, resp, http.StatusOK) + + changes = rt.WaitForChanges(1, fmt.Sprintf("/{{.keyspace}}/_changes?since=%d&revocations=true", 2), "user", false) } func TestRevocationWithAdminRoles(t *testing.T) { From c5e397ec3e4bd92dcdf0c7e76f037775eb9066e0 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Fri, 15 May 2026 21:50:46 +0530 Subject: [PATCH 03/10] add more test cases --- rest/user_api_test.go | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/rest/user_api_test.go b/rest/user_api_test.go index 2fae6fef55..9df6d1e364 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1785,6 +1785,82 @@ func TestCompactUserChannelHistory(t *testing.T) { require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) assert.Empty(t, afterResult.Channels[scope][collection]) }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult UserChannelHistory + 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/_channel_history", body) + RequireStatus(t, response, http.StatusOK) + + var result UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.Empty(t, result.Channels[scope][collection]) + + // GET confirms original history is untouched + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, afterResult.Channels[scope][collection]) + }) + + 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/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var beforeResult UserChannelHistory + 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/_channel_history", body) + RequireStatus(t, response, http.StatusOK) + + var result UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) + assert.ElementsMatch(t, []string{"chan1"}, result.Channels[scope][collection]) + + // GET confirms only chan1 was removed; chan2 and chan3 remain + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_channel_history", "") + RequireStatus(t, response, http.StatusOK) + var afterResult UserChannelHistory + require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) + assert.ElementsMatch(t, []string{"chan2", "chan3"}, afterResult.Channels[scope][collection]) + }) } func TestDisabledUser(t *testing.T) { From 5197815fe7aa75302bd982e58c68ba7633dac7f4 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Fri, 15 May 2026 22:15:04 +0530 Subject: [PATCH 04/10] add documentation - added docstrings - updated api spec --- docs/api/admin.yaml | 2 - .../admin/db-_user-name-_history-compact.yaml | 58 ------------------- .../paths/admin/db-_user-name-_history.yaml | 48 +++++++++++++++ rest/user_api_test.go | 16 ++++- 4 files changed, 63 insertions(+), 61 deletions(-) delete mode 100644 docs/api/paths/admin/db-_user-name-_history-compact.yaml diff --git a/docs/api/admin.yaml b/docs/api/admin.yaml index adcedeea4c..16ab4c4efd 100644 --- a/docs/api/admin.yaml +++ b/docs/api/admin.yaml @@ -53,8 +53,6 @@ paths: $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}/_role/': $ref: './paths/admin/db-_role-.yaml' '/{db}/_role/{name}': diff --git a/docs/api/paths/admin/db-_user-name-_history-compact.yaml b/docs/api/paths/admin/db-_user-name-_history-compact.yaml deleted file mode 100644 index ded0f99677..0000000000 --- a/docs/api/paths/admin/db-_user-name-_history-compact.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# 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. -parameters: - - $ref: ../../components/parameters.yaml#/db - - $ref: ../../components/parameters.yaml#/user-name -post: - summary: Compact user's channel history - description: |- - Remove specified channels from a user's channel history. - - Required Sync Gateway RBAC roles: - - * Sync Gateway Architect - * Sync Gateway Application - requestBody: - description: Channels to be compacted from the user - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/UserHistory - example: - channels: - scope1: - collection1: - - scoped_channel1 - responses: - '200': - description: Channels compacted successfully from user - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/UserHistory - example: - channels: - scope1: - collection1: - - scoped_channel2 - '400': - description: Bad request. Invalid channel names or malformed request body. - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/HTTP-Error - example: - error: "Bad Request" - reason: "Invalid channel format: channels must be non-empty strings" - '403': - $ref: ../../components/responses.yaml#/Unauthorized-database - '404': - $ref: ../../components/responses.yaml#/Not-found - tags: - - Database Security - operationId: post_db-_user-name-_history-compact diff --git a/docs/api/paths/admin/db-_user-name-_history.yaml b/docs/api/paths/admin/db-_user-name-_history.yaml index 780b3f8c3e..447aa7c4c9 100644 --- a/docs/api/paths/admin/db-_user-name-_history.yaml +++ b/docs/api/paths/admin/db-_user-name-_history.yaml @@ -40,3 +40,51 @@ get: tags: - Database Security operationId: get_db-_user-name-_history +post: + summary: Compact user's channel history + description: |- + Remove specified channels from a user's channel history. + + Required Sync Gateway RBAC roles: + + * Sync Gateway Architect + * Sync Gateway Application + requestBody: + description: Channels to be compacted from the user + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/UserHistory + example: + channels: + scope1: + collection1: + - scoped_channel1 + responses: + '200': + description: Channels compacted successfully from user + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/UserHistory + example: + channels: + scope1: + collection1: + - scoped_channel2 + '400': + description: Bad request. Invalid channel names or malformed request body. + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/HTTP-Error + example: + error: "Bad Request" + reason: "Invalid channel format: channels must be non-empty strings" + '403': + $ref: ../../components/responses.yaml#/Unauthorized-database + '404': + $ref: ../../components/responses.yaml#/Not-found + tags: + - Database Security + operationId: post_db-_user-name-_history-compact diff --git a/rest/user_api_test.go b/rest/user_api_test.go index 9df6d1e364..7cf2ca9594 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1622,16 +1622,19 @@ func TestDeletedRoleChanHistory(t *testing.T) { } -// TestDisabledUser ensures that a disabled (non-guest) user cannot authenticate to make requests. +// TestGetUserChannelHistory tests the GET /_user/{name}/_channel_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/_channel_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", @@ -1650,6 +1653,7 @@ func TestGetUserChannelHistory(t *testing.T) { 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", @@ -1673,6 +1677,7 @@ func TestGetUserChannelHistory(t *testing.T) { 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", @@ -1696,6 +1701,7 @@ func TestGetUserChannelHistory(t *testing.T) { assert.ElementsMatch(t, []string{"chan1", "chan2"}, channelHistory[scope][collection]) }) + // 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", @@ -1725,10 +1731,13 @@ func TestGetUserChannelHistory(t *testing.T) { }) } +// TestCompactUserChannelHistory tests the POST /_user/{name}/_channel_history 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() @@ -1738,6 +1747,7 @@ func TestCompactUserChannelHistory(t *testing.T) { 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", @@ -1748,6 +1758,7 @@ func TestCompactUserChannelHistory(t *testing.T) { 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() @@ -1786,6 +1797,7 @@ func TestCompactUserChannelHistory(t *testing.T) { 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() @@ -1824,6 +1836,7 @@ func TestCompactUserChannelHistory(t *testing.T) { 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() @@ -1863,6 +1876,7 @@ func TestCompactUserChannelHistory(t *testing.T) { }) } +// TestDisabledUser ensures that a disabled (non-guest) user cannot authenticate to make requests. func TestDisabledUser(t *testing.T) { rt := NewRestTester(t, nil) defer rt.Close() From 34c03ad29cdc8593e29bfc85101cef26e7f2fcd4 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Thu, 21 May 2026 22:33:08 +0530 Subject: [PATCH 05/10] update rest api and handle default collection - updated the rest api for get and post reqests - fix the case for default collection - update the open api spec --- docs/api/admin.yaml | 6 ++- docs/api/components/schemas.yaml | 39 ++++++++++------ ...b-_user-name-_access_history-compact.yaml} | 36 +-------------- .../admin/db-_user-name-_access_history.yaml | 42 +++++++++++++++++ .../admin/db-_user-name-_channel_history.yaml | 42 +++++++++++++++++ rest/admin_api.go | 21 ++++++--- rest/routing.go | 4 +- rest/user_api_test.go | 46 +++++++++---------- 8 files changed, 156 insertions(+), 80 deletions(-) rename docs/api/paths/admin/{db-_user-name-_history.yaml => db-_user-name-_access_history-compact.yaml} (64%) create mode 100644 docs/api/paths/admin/db-_user-name-_access_history.yaml create mode 100644 docs/api/paths/admin/db-_user-name-_channel_history.yaml diff --git a/docs/api/admin.yaml b/docs/api/admin.yaml index 16ab4c4efd..26a61a4a35 100644 --- a/docs/api/admin.yaml +++ b/docs/api/admin.yaml @@ -51,8 +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}/_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.yaml b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml similarity index 64% rename from docs/api/paths/admin/db-_user-name-_history.yaml rename to docs/api/paths/admin/db-_user-name-_access_history-compact.yaml index 447aa7c4c9..e34843306d 100644 --- a/docs/api/paths/admin/db-_user-name-_history.yaml +++ b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml @@ -8,38 +8,6 @@ parameters: - $ref: ../../components/parameters.yaml#/db - $ref: ../../components/parameters.yaml#/user-name -get: - summary: Get user's channel history information - description: |- - Retrieve the channel history of the specified user. - - Required Sync Gateway RBAC roles: - - * Sync Gateway Architect - * Sync Gateway Application - * Sync Gateway Application Read Only - responses: - '200': - description: User channel history information retrieved successfully - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/UserHistory - example: - channels: - scope1: - collection1: - - channel1 - - channel2 - collection2: - - channel3 - '403': - $ref: ../../components/responses.yaml#/Unauthorized-database - '404': - $ref: ../../components/responses.yaml#/Not-found - tags: - - Database Security - operationId: get_db-_user-name-_history post: summary: Compact user's channel history description: |- @@ -66,9 +34,9 @@ post: content: application/json: schema: - $ref: ../../components/schemas.yaml#/UserHistory + $ref: ../../components/schemas.yaml#/CompactedUserHistory example: - channels: + compacted_channels: scope1: collection1: - scoped_channel2 diff --git a/docs/api/paths/admin/db-_user-name-_access_history.yaml b/docs/api/paths/admin/db-_user-name-_access_history.yaml new file mode 100644 index 0000000000..780b3f8c3e --- /dev/null +++ b/docs/api/paths/admin/db-_user-name-_access_history.yaml @@ -0,0 +1,42 @@ +# 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. +parameters: + - $ref: ../../components/parameters.yaml#/db + - $ref: ../../components/parameters.yaml#/user-name +get: + summary: Get user's channel history information + description: |- + Retrieve the channel history of the specified user. + + Required Sync Gateway RBAC roles: + + * Sync Gateway Architect + * Sync Gateway Application + * Sync Gateway Application Read Only + responses: + '200': + description: User channel history information retrieved successfully + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/UserHistory + example: + channels: + scope1: + collection1: + - channel1 + - channel2 + collection2: + - channel3 + '403': + $ref: ../../components/responses.yaml#/Unauthorized-database + '404': + $ref: ../../components/responses.yaml#/Not-found + tags: + - Database Security + operationId: get_db-_user-name-_history diff --git a/docs/api/paths/admin/db-_user-name-_channel_history.yaml b/docs/api/paths/admin/db-_user-name-_channel_history.yaml new file mode 100644 index 0000000000..780b3f8c3e --- /dev/null +++ b/docs/api/paths/admin/db-_user-name-_channel_history.yaml @@ -0,0 +1,42 @@ +# 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. +parameters: + - $ref: ../../components/parameters.yaml#/db + - $ref: ../../components/parameters.yaml#/user-name +get: + summary: Get user's channel history information + description: |- + Retrieve the channel history of the specified user. + + Required Sync Gateway RBAC roles: + + * Sync Gateway Architect + * Sync Gateway Application + * Sync Gateway Application Read Only + responses: + '200': + description: User channel history information retrieved successfully + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/UserHistory + example: + channels: + scope1: + collection1: + - channel1 + - channel2 + collection2: + - channel3 + '403': + $ref: ../../components/responses.yaml#/Unauthorized-database + '404': + $ref: ../../components/responses.yaml#/Not-found + tags: + - Database Security + operationId: get_db-_user-name-_history diff --git a/rest/admin_api.go b/rest/admin_api.go index 06ef6994d4..3b90b9a794 100644 --- a/rest/admin_api.go +++ b/rest/admin_api.go @@ -2519,6 +2519,10 @@ type UserChannelHistory struct { Channels ColAccessHistoryMap `json:"channels"` } +type UserChannelHistoryResp struct { + CompactedChannels ColAccessHistoryMap `json:"compacted_channels"` +} + func (h *handler) getUserChannelHistory() error { h.assertAdminOnly() username := internalUserName(mux.Vars(h.rq)["name"]) @@ -2532,10 +2536,15 @@ func (h *handler) getUserChannelHistory() error { colAccess := user.GetCollectionsAccess() colAccessHistoryMap := make(map[string]map[string][]string) - for scope, _ := range colAccess { - colAccessHistoryMap[scope] = make(map[string][]string) - for col, _ := range colAccess[scope] { - colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colAccess[scope][col].ChannelHistory_)) + if colAccess == nil { + colAccessHistoryMap[base.DefaultScope] = make(map[string][]string) + colAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(user.ChannelHistory())) + } else { + for scope, _ := range colAccess { + colAccessHistoryMap[scope] = make(map[string][]string) + for col, _ := range colAccess[scope] { + colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colAccess[scope][col].ChannelHistory_)) + } } } @@ -2574,8 +2583,8 @@ func (h *handler) compactUserChannelHistory() error { } } - userCompactedChannelHistory := UserChannelHistory{ - Channels: colAccessHistoryMap, + userCompactedChannelHistory := UserChannelHistoryResp{ + CompactedChannels: colAccessHistoryMap, } err = authenticator.Save(user) diff --git a/rest/routing.go b/rest/routing.go index 26b988f6d0..ae7c67583b 100644 --- a/rest/routing.go +++ b/rest/routing.go @@ -186,9 +186,9 @@ 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}/_channel_history", + dbr.Handle("/_user/{name}/_access_history", makeHandler(sc, adminPrivs, []Permission{PermReadPrincipal}, nil, (*handler).getUserChannelHistory)).Methods("GET") - dbr.Handle("/_user/{name}/_channel_history", + dbr.Handle("/_user/{name}/_access_history/compact", makeHandler(sc, adminPrivs, []Permission{PermWritePrincipal}, nil, (*handler).compactUserChannelHistory)).Methods("POST") dbr.Handle("/_user/{name}/_session", diff --git a/rest/user_api_test.go b/rest/user_api_test.go index 7cf2ca9594..8986a5b564 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1622,7 +1622,7 @@ func TestDeletedRoleChanHistory(t *testing.T) { } -// TestGetUserChannelHistory tests the GET /_user/{name}/_channel_history admin endpoint, +// 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) @@ -1630,7 +1630,7 @@ func TestGetUserChannelHistory(t *testing.T) { // Returns 404 when the requested user does not exist. t.Run("UserNotFound", func(t *testing.T) { - response := rt.SendAdminRequest(http.MethodGet, "/db/_user/ghost/_channel_history", "") + response := rt.SendAdminRequest(http.MethodGet, "/db/_user/ghost/_access_history", "") RequireStatus(t, response, http.StatusNotFound) }) @@ -1641,7 +1641,7 @@ func TestGetUserChannelHistory(t *testing.T) { GetUserPayload(t, "user1", "letmein", "", ds, []string{"chan1", "chan2"}, nil)) RequireStatus(t, response, http.StatusCreated) - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") RequireStatus(t, response, http.StatusOK) var result map[string]ColAccessHistoryMap @@ -1665,7 +1665,7 @@ func TestGetUserChannelHistory(t *testing.T) { GetUserPayload(t, "user2", "letmein", "", ds, []string{}, nil)) RequireStatus(t, response, http.StatusOK) - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") RequireStatus(t, response, http.StatusOK) var result map[string]ColAccessHistoryMap @@ -1689,7 +1689,7 @@ func TestGetUserChannelHistory(t *testing.T) { GetUserPayload(t, "user3", "letmein", "", ds, []string{"chan3"}, nil)) RequireStatus(t, response, http.StatusOK) - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") RequireStatus(t, response, http.StatusOK) var result map[string]ColAccessHistoryMap @@ -1718,7 +1718,7 @@ func TestGetUserChannelHistory(t *testing.T) { GetUserPayload(t, "user4", "letmein", "", ds, []string{"chan1"}, nil)) RequireStatus(t, response, http.StatusOK) - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") RequireStatus(t, response, http.StatusOK) var result map[string]ColAccessHistoryMap @@ -1743,7 +1743,7 @@ func TestCompactUserChannelHistory(t *testing.T) { scope := ds.ScopeName() collection := ds.CollectionName() body := fmt.Sprintf(`{"channels":{%q:{%q:["chan1"]}}}`, scope, collection) - response := rt.SendAdminRequest(http.MethodPost, "/db/_user/ghost/_channel_history", body) + response := rt.SendAdminRequest(http.MethodPost, "/db/_user/ghost/_access_history/compact", body) RequireStatus(t, response, http.StatusNotFound) }) @@ -1754,7 +1754,7 @@ func TestCompactUserChannelHistory(t *testing.T) { GetUserPayload(t, "user1", "letmein", "", ds, []string{"chan1"}, nil)) RequireStatus(t, response, http.StatusCreated) - response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user1/_channel_history", `not-valid-json`) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user1/_access_history/compact", `not-valid-json`) RequireStatus(t, response, http.StatusBadRequest) }) @@ -1774,7 +1774,7 @@ func TestCompactUserChannelHistory(t *testing.T) { RequireStatus(t, response, http.StatusOK) // Verify history is populated before compaction - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") RequireStatus(t, response, http.StatusOK) var beforeResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) @@ -1782,15 +1782,15 @@ func TestCompactUserChannelHistory(t *testing.T) { // Compact chan1 and chan2 body := fmt.Sprintf(`{"channels":{%q:{%q:["chan1","chan2"]}}}`, scope, collection) - response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user2/_channel_history", body) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user2/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistory + var result UserChannelHistoryResp require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) - assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.Channels[scope][collection]) + assert.ElementsMatch(t, []string{"chan1", "chan2"}, result.CompactedChannels[scope][collection]) // GET confirms history is now empty - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") RequireStatus(t, response, http.StatusOK) var afterResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) @@ -1813,7 +1813,7 @@ func TestCompactUserChannelHistory(t *testing.T) { RequireStatus(t, response, http.StatusOK) // Verify history is populated before compaction - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") RequireStatus(t, response, http.StatusOK) var beforeResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) @@ -1821,15 +1821,15 @@ func TestCompactUserChannelHistory(t *testing.T) { // 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/_channel_history", body) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user3/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistory + var result UserChannelHistoryResp require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) - assert.Empty(t, result.Channels[scope][collection]) + assert.Empty(t, result.CompactedChannels[scope][collection]) // GET confirms original history is untouched - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") RequireStatus(t, response, http.StatusOK) var afterResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) @@ -1852,7 +1852,7 @@ func TestCompactUserChannelHistory(t *testing.T) { RequireStatus(t, response, http.StatusOK) // Verify history is populated before compaction - response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") RequireStatus(t, response, http.StatusOK) var beforeResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) @@ -1860,15 +1860,15 @@ func TestCompactUserChannelHistory(t *testing.T) { // 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/_channel_history", body) + response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user4/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistory + var result UserChannelHistoryResp require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) - assert.ElementsMatch(t, []string{"chan1"}, result.Channels[scope][collection]) + 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/_channel_history", "") + response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") RequireStatus(t, response, http.StatusOK) var afterResult UserChannelHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) From 196ea4679ae0ec54f3ab4be31cb63259b4ec029f Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Fri, 22 May 2026 19:38:19 +0530 Subject: [PATCH 06/10] fixes based on PR comments --- ...db-_user-name-_access_history-compact.yaml | 2 +- .../admin/db-_user-name-_channel_history.yaml | 42 ----------------- rest/admin_api.go | 18 +++---- rest/revocation_test.go | 4 +- rest/user_api_test.go | 47 ++++++++++++++++++- 5 files changed, 58 insertions(+), 55 deletions(-) delete mode 100644 docs/api/paths/admin/db-_user-name-_channel_history.yaml diff --git a/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml index e34843306d..1d2e36497c 100644 --- a/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml +++ b/docs/api/paths/admin/db-_user-name-_access_history-compact.yaml @@ -39,7 +39,7 @@ post: 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-_channel_history.yaml b/docs/api/paths/admin/db-_user-name-_channel_history.yaml deleted file mode 100644 index 780b3f8c3e..0000000000 --- a/docs/api/paths/admin/db-_user-name-_channel_history.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# 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. -parameters: - - $ref: ../../components/parameters.yaml#/db - - $ref: ../../components/parameters.yaml#/user-name -get: - summary: Get user's channel history information - description: |- - Retrieve the channel history of the specified user. - - Required Sync Gateway RBAC roles: - - * Sync Gateway Architect - * Sync Gateway Application - * Sync Gateway Application Read Only - responses: - '200': - description: User channel history information retrieved successfully - content: - application/json: - schema: - $ref: ../../components/schemas.yaml#/UserHistory - example: - channels: - scope1: - collection1: - - channel1 - - channel2 - collection2: - - channel3 - '403': - $ref: ../../components/responses.yaml#/Unauthorized-database - '404': - $ref: ../../components/responses.yaml#/Not-found - tags: - - Database Security - operationId: get_db-_user-name-_history diff --git a/rest/admin_api.go b/rest/admin_api.go index 3b90b9a794..dfa9770604 100644 --- a/rest/admin_api.go +++ b/rest/admin_api.go @@ -2540,10 +2540,10 @@ func (h *handler) getUserChannelHistory() error { colAccessHistoryMap[base.DefaultScope] = make(map[string][]string) colAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(user.ChannelHistory())) } else { - for scope, _ := range colAccess { + for scope, cols := range colAccess { colAccessHistoryMap[scope] = make(map[string][]string) - for col, _ := range colAccess[scope] { - colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colAccess[scope][col].ChannelHistory_)) + for col, colVal := range cols { + colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colVal.ChannelHistory_)) } } } @@ -2568,17 +2568,17 @@ func (h *handler) compactUserChannelHistory() error { username := internalUserName(mux.Vars(h.rq)["name"]) authenticator := h.db.Authenticator(h.ctx()) user, err := authenticator.GetUser(username) - if user == nil { - if err == nil { - err = kNotFoundError - } + if err != nil { return err } + if user == nil { + return kNotFoundError + } colAccessHistoryMap := make(map[string]map[string][]string) - for scope, _ := range reqUserChannelHistory.Channels { + for scope, cols := range reqUserChannelHistory.Channels { colAccessHistoryMap[scope] = make(map[string][]string) - for col, _ := range reqUserChannelHistory.Channels[scope] { + for col, _ := range cols { colAccessHistoryMap[scope][col] = user.CompactChannelHistory(scope, col, reqUserChannelHistory.Channels[scope][col]) } } diff --git a/rest/revocation_test.go b/rest/revocation_test.go index 3af2adf59b..5a7baf5ade 100644 --- a/rest/revocation_test.go +++ b/rest/revocation_test.go @@ -903,8 +903,8 @@ 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/_channel_history", body) + 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) diff --git a/rest/user_api_test.go b/rest/user_api_test.go index 8986a5b564..a97d87b03a 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1701,6 +1701,51 @@ func TestGetUserChannelHistory(t *testing.T) { 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]ColAccessHistoryMap + 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() @@ -1731,7 +1776,7 @@ func TestGetUserChannelHistory(t *testing.T) { }) } -// TestCompactUserChannelHistory tests the POST /_user/{name}/_channel_history admin endpoint, +// 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) From a26ae48c084e018044cf76a4652a361c75109bea Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Mon, 25 May 2026 17:29:47 +0530 Subject: [PATCH 07/10] fixes based on pr comments - moved few methods under principal - abstract the channel history implementation - rename few structs --- auth/collection_access.go | 3 +++ auth/principal.go | 4 +-- auth/role.go | 9 +++++++ auth/role_collection_access.go | 23 ++++++++++++++++ auth/user.go | 8 ------ rest/admin_api.go | 48 ++++++++++++---------------------- rest/revocation_test.go | 1 + rest/user_api_test.go | 28 ++++++++++---------- 8 files changed, 68 insertions(+), 56 deletions(-) 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 ec8506a113..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) @@ -137,8 +139,6 @@ type User interface { InitializeRoles() error - CompactChannelHistory(scope string, col string, channels []string) []string - revokedChannels(since uint64, lowSeq uint64, triggeredBy uint64) (RevokedChannels, error) // Obtains the period over which the user had access to the given channel. Either directly or via a role. 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..28d0119705 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,23 @@ 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) + collectionAccessHistoryMap[base.DefaultScope] = make(map[string][]string) + collectionAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(role.ChannelHistory())) + 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())) + } + } + + return collectionAccessHistoryMap +} diff --git a/auth/user.go b/auth/user.go index bd93e5a095..a844989276 100644 --- a/auth/user.go +++ b/auth/user.go @@ -750,14 +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: diff --git a/rest/admin_api.go b/rest/admin_api.go index dfa9770604..c216efa66b 100644 --- a/rest/admin_api.go +++ b/rest/admin_api.go @@ -16,9 +16,7 @@ import ( "errors" "fmt" "io" - "maps" "net/http" - "slices" "strings" "sync/atomic" "time" @@ -2513,43 +2511,29 @@ func (h *handler) handleGetClusterInfo() error { return nil } -type ColAccessHistoryMap map[string]map[string][]string - -type UserChannelHistory struct { - Channels ColAccessHistoryMap `json:"channels"` +type GetUserAccessHistoryResponse struct { + Channels auth.CollectionAccessHistory `json:"channels"` } -type UserChannelHistoryResp struct { - CompactedChannels ColAccessHistoryMap `json:"compacted_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 user == nil { - if err == nil { - err = kNotFoundError - } + if err != nil { return err } - colAccess := user.GetCollectionsAccess() - - colAccessHistoryMap := make(map[string]map[string][]string) - if colAccess == nil { - colAccessHistoryMap[base.DefaultScope] = make(map[string][]string) - colAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(user.ChannelHistory())) - } else { - for scope, cols := range colAccess { - colAccessHistoryMap[scope] = make(map[string][]string) - for col, colVal := range cols { - colAccessHistoryMap[scope][col] = slices.Collect(maps.Keys(colVal.ChannelHistory_)) - } - } + if user == nil { + return kNotFoundError } - userChannelHistory := UserChannelHistory{ - Channels: colAccessHistoryMap, + collectionAccessHistory := user.GetCollectionAccessHistory() + + userChannelHistory := GetUserAccessHistoryResponse{ + Channels: collectionAccessHistory, } h.writeJSON(userChannelHistory) @@ -2559,10 +2543,10 @@ func (h *handler) getUserChannelHistory() error { func (h *handler) compactUserChannelHistory() error { h.assertAdminOnly() - var reqUserChannelHistory UserChannelHistory + var reqUserChannelHistory GetUserAccessHistoryResponse err := h.readJSONInto(&reqUserChannelHistory) if err != nil { - return base.HTTPErrorf(http.StatusBadRequest, "Failed to read user channel history: %v", err) + return base.HTTPErrorf(http.StatusBadRequest, "Invalid request payload: %v", err) } username := internalUserName(mux.Vars(h.rq)["name"]) @@ -2578,12 +2562,12 @@ func (h *handler) compactUserChannelHistory() error { colAccessHistoryMap := make(map[string]map[string][]string) for scope, cols := range reqUserChannelHistory.Channels { colAccessHistoryMap[scope] = make(map[string][]string) - for col, _ := range cols { - colAccessHistoryMap[scope][col] = user.CompactChannelHistory(scope, col, reqUserChannelHistory.Channels[scope][col]) + for col, colVal := range cols { + colAccessHistoryMap[scope][col] = user.CompactChannelHistory(scope, col, colVal) } } - userCompactedChannelHistory := UserChannelHistoryResp{ + userCompactedChannelHistory := CompactUserAccessHistoryResponse{ CompactedChannels: colAccessHistoryMap, } diff --git a/rest/revocation_test.go b/rest/revocation_test.go index 5a7baf5ade..723371ead1 100644 --- a/rest/revocation_test.go +++ b/rest/revocation_test.go @@ -908,6 +908,7 @@ func TestRevocationWithAdminChannels(t *testing.T) { 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/user_api_test.go b/rest/user_api_test.go index a97d87b03a..97586004e0 100644 --- a/rest/user_api_test.go +++ b/rest/user_api_test.go @@ -1644,7 +1644,7 @@ func TestGetUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") RequireStatus(t, response, http.StatusOK) - var result map[string]ColAccessHistoryMap + var result map[string]auth.CollectionAccessHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) channelHistory := result["channels"] @@ -1668,7 +1668,7 @@ func TestGetUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") RequireStatus(t, response, http.StatusOK) - var result map[string]ColAccessHistoryMap + var result map[string]auth.CollectionAccessHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) channelHistory := result["channels"] @@ -1692,7 +1692,7 @@ func TestGetUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") RequireStatus(t, response, http.StatusOK) - var result map[string]ColAccessHistoryMap + var result map[string]auth.CollectionAccessHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) channelHistory := result["channels"] @@ -1738,7 +1738,7 @@ func TestGetUserChannelHistory(t *testing.T) { response = rtMulti.SendAdminRequest(http.MethodGet, "/db/_user/user1/_access_history", "") RequireStatus(t, response, http.StatusOK) - var result map[string]ColAccessHistoryMap + var result map[string]auth.CollectionAccessHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) channelHistory := result["channels"] @@ -1766,7 +1766,7 @@ func TestGetUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") RequireStatus(t, response, http.StatusOK) - var result map[string]ColAccessHistoryMap + var result map[string]auth.CollectionAccessHistory require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &result)) channelHistory := result["channels"] @@ -1821,7 +1821,7 @@ func TestCompactUserChannelHistory(t *testing.T) { // Verify history is populated before compaction response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user2/_access_history", "") RequireStatus(t, response, http.StatusOK) - var beforeResult UserChannelHistory + var beforeResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) assert.ElementsMatch(t, []string{"chan1", "chan2"}, beforeResult.Channels[scope][collection]) @@ -1830,14 +1830,14 @@ func TestCompactUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user2/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistoryResp + 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 UserChannelHistory + var afterResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) assert.Empty(t, afterResult.Channels[scope][collection]) }) @@ -1860,7 +1860,7 @@ func TestCompactUserChannelHistory(t *testing.T) { // Verify history is populated before compaction response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user3/_access_history", "") RequireStatus(t, response, http.StatusOK) - var beforeResult UserChannelHistory + var beforeResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) assert.ElementsMatch(t, []string{"chan1", "chan2"}, beforeResult.Channels[scope][collection]) @@ -1869,14 +1869,14 @@ func TestCompactUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user3/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistoryResp + 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 UserChannelHistory + var afterResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) assert.ElementsMatch(t, []string{"chan1", "chan2"}, afterResult.Channels[scope][collection]) }) @@ -1899,7 +1899,7 @@ func TestCompactUserChannelHistory(t *testing.T) { // Verify history is populated before compaction response = rt.SendAdminRequest(http.MethodGet, "/db/_user/user4/_access_history", "") RequireStatus(t, response, http.StatusOK) - var beforeResult UserChannelHistory + var beforeResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &beforeResult)) assert.ElementsMatch(t, []string{"chan1", "chan2", "chan3"}, beforeResult.Channels[scope][collection]) @@ -1908,14 +1908,14 @@ func TestCompactUserChannelHistory(t *testing.T) { response = rt.SendAdminRequest(http.MethodPost, "/db/_user/user4/_access_history/compact", body) RequireStatus(t, response, http.StatusOK) - var result UserChannelHistoryResp + 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 UserChannelHistory + var afterResult GetUserAccessHistoryResponse require.NoError(t, base.JSONUnmarshal(response.Body.Bytes(), &afterResult)) assert.ElementsMatch(t, []string{"chan2", "chan3"}, afterResult.Channels[scope][collection]) }) From aa19305c4ad8bee7bd31b893f51b64d27922d4b5 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Mon, 25 May 2026 20:39:05 +0530 Subject: [PATCH 08/10] lint fix --- auth/user.go | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/user.go b/auth/user.go index a844989276..7a63645562 100644 --- a/auth/user.go +++ b/auth/user.go @@ -750,7 +750,6 @@ func (user *userImpl) GetAddedChannels(channels ch.TimedSet) (base.Set, error) { return output, nil } - // ////// MARSHALING: // JSON encoding/decoding -- these functions are ugly hacks to work around the current From 232d499774bdc97f502e33ee2ceedbcceb58a054 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Wed, 27 May 2026 18:34:46 +0530 Subject: [PATCH 09/10] Added test for the upgrade scenario --- auth/role_collection_access.go | 9 +- rest/upgradetest/user_access_history_test.go | 150 +++++++++++++++++++ 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 rest/upgradetest/user_access_history_test.go diff --git a/auth/role_collection_access.go b/auth/role_collection_access.go index 28d0119705..a5afa33e66 100644 --- a/auth/role_collection_access.go +++ b/auth/role_collection_access.go @@ -243,8 +243,6 @@ func (role *roleImpl) GetCollectionAccessHistory() CollectionAccessHistory { collectionAccess := role.GetCollectionsAccess() collectionAccessHistoryMap := make(CollectionAccessHistory) - collectionAccessHistoryMap[base.DefaultScope] = make(map[string][]string) - collectionAccessHistoryMap[base.DefaultScope][base.DefaultCollection] = slices.Collect(maps.Keys(role.ChannelHistory())) for scope, cols := range collectionAccess { collectionAccessHistoryMap[scope] = make(map[string][]string) for col, colVal := range cols { @@ -252,5 +250,12 @@ func (role *roleImpl) GetCollectionAccessHistory() CollectionAccessHistory { } } + // 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/rest/upgradetest/user_access_history_test.go b/rest/upgradetest/user_access_history_test.go new file mode 100644 index 0000000000..a95d227022 --- /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]) +} \ No newline at end of file From e49e484171fc7d0151523d5f9729c3a37664cf5a Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Wed, 27 May 2026 18:39:41 +0530 Subject: [PATCH 10/10] lint fix --- rest/upgradetest/user_access_history_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest/upgradetest/user_access_history_test.go b/rest/upgradetest/user_access_history_test.go index a95d227022..47a2c5abe6 100644 --- a/rest/upgradetest/user_access_history_test.go +++ b/rest/upgradetest/user_access_history_test.go @@ -147,4 +147,4 @@ func TestCompactUserChannelHistoryAfterCollectionUpgrade(t *testing.T) { 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]) -} \ No newline at end of file +}