diff --git a/db/database.go b/db/database.go index 8508e4bbf3..253af5e581 100644 --- a/db/database.go +++ b/db/database.go @@ -2438,3 +2438,19 @@ func (dbc *DatabaseContext) InstallPrincipals(ctx context.Context, spec map[stri } return nil } + +// DataStoreNames returns the names of all datastores connected to this database +func (db *Database) DataStoreNames() base.ScopeAndCollectionNames { + if db.Scopes == nil { + return base.ScopeAndCollectionNames{ + base.DefaultScopeAndCollectionName(), + } + } + var names base.ScopeAndCollectionNames + for scopeName, scope := range db.Scopes { + for collectionName := range scope.Collections { + names = append(names, base.ScopeAndCollectionName{Scope: scopeName, Collection: collectionName}) + } + } + return names +} diff --git a/docs/api/components/responses.yaml b/docs/api/components/responses.yaml index 350c1638f9..a0162ffc75 100644 --- a/docs/api/components/responses.yaml +++ b/docs/api/components/responses.yaml @@ -180,3 +180,9 @@ DB-config-precondition-failed: example: error: Precondition Failed reason: Provided If-Match header does not match current config version +All_user_channels_response: + description: Map of all keyspaces to all channels that the user has access to. + content: + application/json: + schema: + $ref: ./schemas.yaml#/all_user_channels diff --git a/docs/api/components/schemas.yaml b/docs/api/components/schemas.yaml index b25006c28f..4a179d6da4 100644 --- a/docs/api/components/schemas.yaml +++ b/docs/api/components/schemas.yaml @@ -2512,3 +2512,28 @@ CollectionNames: - Starting - Stopping - Resyncing +all_user_channels: + description: |- + All user channels split by how they were assigned to the user and by keyspace. + type: object + properties: + all_channels: + description: |- + All channels that the user has access to. + type: object + properties: + keyspace: + type: object + properties: + channel: + $ref: '#/channelEntry' +channelEntry: + description: Channel name + type: object + properties: + entries: + type: array + description: Start sequence to end sequence. If the channel is currently granted, the end sequence will be zero. + updated_at: + type: integer + description: Unix timestamp of last update diff --git a/docs/api/diagnostic.yaml b/docs/api/diagnostic.yaml index 24050a14ca..90b131189a 100644 --- a/docs/api/diagnostic.yaml +++ b/docs/api/diagnostic.yaml @@ -36,6 +36,8 @@ paths: $ref: './paths/diagnostic/keyspace-sync.yaml' '/{keyspace}/import_filter': $ref: './paths/diagnostic/keyspace-import_filter.yaml' + '/{db}/_user/{name}/all_channels': + $ref: './paths/diagnostic/db-_user-name-_all_channels.yaml' externalDocs: description: Sync Gateway Quickstart | Couchbase Docs url: 'https://docs.couchbase.com/sync-gateway/current/index.html' diff --git a/docs/api/paths/diagnostic/db-_user-name-_all_channels.yaml b/docs/api/paths/diagnostic/db-_user-name-_all_channels.yaml new file mode 100644 index 0000000000..c130a869e6 --- /dev/null +++ b/docs/api/paths/diagnostic/db-_user-name-_all_channels.yaml @@ -0,0 +1,28 @@ +# Copyright 2022-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 all channels for a user + description: |- + Retrieve all channels that a user has access to. + + Required Sync Gateway RBAC roles: + + * Sync Gateway Architect + * Sync Gateway Application + * Sync Gateway Application Read Only + responses: + '200': + $ref: ../../components/responses.yaml#/All_user_channels_response + '404': + $ref: ../../components/responses.yaml#/Not-found + tags: + - Database Security + operationId: get_db-_user-name_-all_channels diff --git a/rest/diagnostic_api.go b/rest/diagnostic_api.go new file mode 100644 index 0000000000..5065657b1a --- /dev/null +++ b/rest/diagnostic_api.go @@ -0,0 +1,76 @@ +// Copyright 2013-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 rest + +import ( + "fmt" + + "github.com/couchbase/sync_gateway/auth" + "github.com/couchbase/sync_gateway/base" + channels "github.com/couchbase/sync_gateway/channels" + "github.com/gorilla/mux" +) + +type allChannels struct { + Channels map[string]map[string]channelHistory `json:"all_channels,omitempty"` +} +type channelHistory struct { + Entries []auth.GrantHistorySequencePair `json:"entries"` // Entry for a specific grant period +} + +func (h *handler) handleGetAllChannels() error { + h.assertAdminOnly() + user, err := h.db.Authenticator(h.ctx()).GetUser(internalUserName(mux.Vars(h.rq)["name"])) + if err != nil { + return fmt.Errorf("could not get user %s: %w", user.Name(), err) + } + if user == nil { + return kNotFoundError + } + + resp := make(map[string]map[string]channelHistory) + + // handles deleted collections, default/ single named collection + for _, dsName := range h.db.DataStoreNames() { + keyspace := dsName.ScopeName() + "." + dsName.CollectionName() + + currentChannels := user.InheritedCollectionChannels(dsName.ScopeName(), dsName.CollectionName()) + chanHistory := user.CollectionChannelHistory(dsName.ScopeName(), dsName.CollectionName()) + // If no channels aside from public and no channels in history, don't make a key for this keyspace + if len(currentChannels) == 1 && len(chanHistory) == 0 { + continue + } + resp[keyspace] = make(map[string]channelHistory) + for chanName, chanEntry := range currentChannels { + if chanName == channels.DocumentStarChannel { + continue + } + resp[keyspace][chanName] = channelHistory{Entries: []auth.GrantHistorySequencePair{{StartSeq: chanEntry.Sequence, EndSeq: 0}}} + } + for chanName, chanEntry := range chanHistory { + chanHistoryEntry := channelHistory{Entries: chanEntry.Entries} + // if channel is also in history, append current entry to history entries + if _, chanCurrentlyAssigned := resp[keyspace][chanName]; chanCurrentlyAssigned { + var newEntries []auth.GrantHistorySequencePair + newEntries = append(chanEntry.Entries, resp[keyspace][chanName].Entries...) + chanHistoryEntry.Entries = newEntries + } + resp[keyspace][chanName] = chanHistoryEntry + } + + } + allChannels := allChannels{Channels: resp} + + bytes, err := base.JSONMarshal(allChannels) + if err != nil { + return err + } + h.writeRawJSON(bytes) + return err +} diff --git a/rest/diagnostic_api_test.go b/rest/diagnostic_api_test.go new file mode 100644 index 0000000000..ec8637d3b1 --- /dev/null +++ b/rest/diagnostic_api_test.go @@ -0,0 +1,866 @@ +// Copyright 2013-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 rest + +import ( + "fmt" + "net/http" + "testing" + + "github.com/couchbase/sync_gateway/auth" + "github.com/couchbase/sync_gateway/base" + "github.com/stretchr/testify/require" +) + +type grant interface { + request(rt *RestTester) +} + +func compareAllChannelsOutput(rt *RestTester, username string, expectedOutput string) { + response := rt.SendDiagnosticRequest(http.MethodGet, + "/{{.db}}/_user/"+username+"/_all_channels", ``) + RequireStatus(rt.TB, response, http.StatusOK) + rt.TB.Logf("All channels response: %s", response.BodyString()) + require.JSONEq(rt.TB, rt.mustTemplateResource(expectedOutput), response.BodyString()) + +} + +type userGrant struct { + user string + adminChannels map[string][]string + roles []string + output string +} + +func (g *userGrant) getUserPayload(rt *RestTester) string { + config := auth.PrincipalConfig{ + Name: base.StringPtr(g.user), + Password: base.StringPtr(RestTesterDefaultUserPassword), + } + if len(g.roles) > 0 { + config.ExplicitRoleNames = base.SetOf(g.roles...) + } + + for keyspace, chans := range g.adminChannels { + _, scope, collection, err := ParseKeyspace(rt.mustTemplateResource(keyspace)) + require.NoError(rt.TB, err) + if scope == nil && collection == nil { + config.ExplicitChannels = base.SetFromArray(chans) + continue + } + require.NotNil(rt.TB, scope, "Could not find scope from keyspace %s", keyspace) + require.NotNil(rt.TB, collection, "Could not find collection from keyspace %s", keyspace) + if base.IsDefaultCollection(*scope, *collection) { + config.ExplicitChannels = base.SetFromArray(chans) + } else { + config.SetExplicitChannels(*scope, *collection, chans...) + } + } + + return string(base.MustJSONMarshal(rt.TB, config)) +} + +func (g userGrant) request(rt *RestTester) { + payload := g.getUserPayload(rt) + rt.TB.Logf("Issuing admin grant: %+v", payload) + response := rt.SendAdminRequest(http.MethodPut, "/{{.db}}/_user/"+g.user, payload) + if response.Code != http.StatusCreated && response.Code != http.StatusOK { + rt.TB.Fatalf("Expected 200 or 201 exit code") + } + if g.output != "" { + compareAllChannelsOutput(rt, g.user, g.output) + } +} + +type roleGrant struct { + role string + adminChannels map[string][]string +} + +func (g roleGrant) getPayload(rt *RestTester) string { + config := auth.PrincipalConfig{ + Password: base.StringPtr(RestTesterDefaultUserPassword), + } + for keyspace, chans := range g.adminChannels { + _, scope, collection, err := ParseKeyspace(rt.mustTemplateResource(keyspace)) + require.NoError(rt.TB, err) + if scope == nil && collection == nil { + config.ExplicitChannels = base.SetFromArray(chans) + continue + } + require.NotNil(rt.TB, scope, "Could not find scope from keyspace %s", keyspace) + require.NotNil(rt.TB, collection, "Could not find collection from keyspace %s", keyspace) + if base.IsDefaultCollection(*scope, *collection) { + config.ExplicitChannels = base.SetFromArray(chans) + } else { + config.SetExplicitChannels(*scope, *collection, chans...) + } + } + return string(base.MustJSONMarshal(rt.TB, config)) +} + +func (g roleGrant) request(rt *RestTester) { + payload := g.getPayload(rt) + rt.TB.Logf("Issuing admin grant: %+v", payload) + response := rt.SendAdminRequest(http.MethodPut, "/{{.db}}/_role/"+g.role, payload) + if response.Code != http.StatusCreated && response.Code != http.StatusOK { + rt.TB.Fatalf("Expected 200 or 201 exit code") + } +} + +type docGrant struct { + userName string + dynamicRole string + dynamicChannel string + output string +} + +func (g docGrant) getPayload() string { + role := fmt.Sprintf(`"role":"role:%s",`, g.dynamicRole) + if g.dynamicRole == "" { + role = "" + } + user := fmt.Sprintf(`"user":["%s"],`, g.userName) + if g.userName == "" { + user = "" + } + payload := fmt.Sprintf(`{%s %s "channel":"%s"}`, user, role, g.dynamicChannel) + + return payload +} + +func (g docGrant) request(rt *RestTester) { + payload := g.getPayload() + rt.TB.Logf("Issuing dynamic grant: %+v", payload) + response := rt.SendAdminRequest(http.MethodPut, "/{{.keyspace}}/doc", payload) + if response.Code != http.StatusCreated && response.Code != http.StatusOK { + rt.TB.Fatalf("Expected 200 or 201 exit code, got %d, output: %s", response.Code, response.Body.String()) + } + if g.output != "" { + compareAllChannelsOutput(rt, g.userName, g.output) + } +} + +func TestGetAllChannelsByUser(t *testing.T) { + tests := []struct { + name string + adminChannels []string + grants []grant + }{ + { + name: "admin channels once", + grants: []grant{ + // grant 1 + userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace}}": {"A", "B", "C"}, + }, + output: ` +{"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-0"]}, + "B": { "entries" : ["1-0"]}, + "C": { "entries" : ["1-0"]} + }}}`, + }}}, + { + name: "multiple history entries", + grants: []grant{ + // grant 1 + userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace}}": {"A"}, + }, + output: ` +{"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-0"]} + }}}`, + }, + // grant 2 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + output: ` +{"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-2"]} + }}}`, + }, + // grant 2 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` +{"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-2", "3-0"]} + }}}`, + }, + }, + }, + { + name: "limit history entries to 10", + grants: []grant{ + // grant 1 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 2 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 3 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 4 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 5 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 6 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 7 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 8 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 9 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 10 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 11 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 12 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 13 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 14 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 15 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 16 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 17 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 18 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 19 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 20 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 19 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // grant 20 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"!"}}, + }, + // grant 23 + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-4", "5-6", "7-8", "9-10", "11-12","13-14","15-16","17-18","19-20","21-22","23-0"]} + }}}`, + }, + }, + }, + { + name: "admin role grant channels", + grants: []grant{ + // grant 1 + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A", "B"}}, + }, + userGrant{ + user: "alice", + roles: []string{"role1"}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["2-0"]}, + "B": { "entries" : ["2-0"]} + }}}`, + }, + }, + }, + { + name: "dynamic grant channels", + grants: []grant{ + userGrant{ + user: "alice", + }, + docGrant{ + userName: "alice", + dynamicChannel: "A", + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["2-0"]} + }}}`, + }, + }, + }, + { + name: "dynamic role grant channels", + grants: []grant{ + // create user + userGrant{ + user: "alice", + }, + // create role with channels + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A", "B"}}, + }, + // assign role through the sync fn and check output + docGrant{ + userName: "alice", + dynamicRole: "role1", + dynamicChannel: "chan1", + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["3-0"]}, + "B": { "entries" : ["3-0"]}, + "chan1": { "entries" : ["3-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both dynamic and admin grants, assert earlier sequence (admin) is used", + grants: []grant{ + // create user and assign channels through admin_channels + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign channels through sync fn and assert on sequences + docGrant{ + userName: "alice", + dynamicChannel: "A", + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both dynamic and admin grants, assert earlier sequence (dynamic) is used", + grants: []grant{ + // create user with no channels + userGrant{ + user: "alice", + }, + // create doc and assign dynamic chan through sync fn + docGrant{ + userName: "alice", + dynamicChannel: "A", + }, + // assign same channels through admin_channels and assert on sequences + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["2-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both admin and admin role grants, assert earlier sequence (admin) is used", + grants: []grant{ + // create user and assign channel through admin_channels + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // create role with same channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign role through admin_roles and assert on sequences + userGrant{ + user: "alice", + roles: []string{"role1"}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both admin and admin role grants, assert earlier sequence (admin role) is used", + grants: []grant{ + // create user with no channels + userGrant{ + user: "alice", + }, + // create role with channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign role through admin_roles + userGrant{ + user: "alice", + roles: []string{"role1"}, + }, + // assign role channel through admin_channels and assert on sequences + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["3-0"]} + }}}`, // A start sequence would be 4 if its broken + }, + }, + }, + { + name: "channel assigned through both dynamic role and admin grants, assert earlier sequence (dynamic role) is used", + grants: []grant{ + // create user with no channels + userGrant{ + user: "alice", + }, + // create role with channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // create doc and assign role through sync fn + docGrant{ + userName: "alice", + dynamicRole: "role1", + dynamicChannel: "docChan", + }, + // assign role cahnnel to user through admin_channels and assert on sequences + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["3-0"]}, + "docChan": { "entries" : ["3-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both dynamic role and admin grants, assert earlier sequence (admin) is used", + grants: []grant{ + // create user with channel + userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // create role with same channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign role to user through sync fn and assert channel sequence is from admin_channels + docGrant{ + userName: "alice", + dynamicRole: "role1", + dynamicChannel: "docChan", + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["1-0"]}, + "docChan": { "entries" : ["3-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both dynamic role and admin role grants, assert earlier sequence (dynamic role) is used", + grants: []grant{ + // create user with no channels + userGrant{ + user: "alice", + }, + // create role with channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // create another role with same channel + roleGrant{ + role: "role2", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign first role through sync fn + docGrant{ + userName: "alice", + dynamicRole: "role1", + dynamicChannel: "docChan", + }, + // assign second role through admin_roles and assert sequence is from dynamic (first) role + userGrant{ + user: "alice", + roles: []string{"role2"}, + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["4-0"]}, + "docChan": { "entries" : ["4-0"]} + }}}`, + }, + }, + }, + { + name: "channel assigned through both dynamic role and admin role grants, assert earlier sequence (admin role) is used", + grants: []grant{ + // create user with no channels + userGrant{ + user: "alice", + }, + // create role with channel + roleGrant{ + role: "role1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // create another role with same channel + roleGrant{ + role: "role2", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + }, + // assign role through admin_roles + userGrant{ + user: "alice", + roles: []string{"role1"}, + }, + // assign other role through sync fn and assert earlier sequences are returned + docGrant{ + userName: "alice", + dynamicRole: "role2", + dynamicChannel: "docChan", + output: ` + {"all_channels":{"{{.scopeAndCollection}}": { + "A": { "entries" : ["4-0"]}, + "docChan": { "entries" : ["5-0"]} + }}}`, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rt := NewRestTester(t, &RestTesterConfig{ + PersistentConfig: true, + SyncFn: `function(doc) {channel(doc.channel); access(doc.user, doc.channel); role(doc.user, doc.role);}`, + }) + defer rt.Close() + + RequireStatus(t, rt.CreateDatabase("db", rt.NewDbConfig()), http.StatusCreated) + + // iterate and execute grants in each test case + for i, grant := range test.grants { + t.Logf("Processing grant %d", i+1) + grant.request(rt) + } + + }) + } +} + +func TestGetAllChannelsByUserWithSingleNamedCollection(t *testing.T) { + base.TestRequiresCollections(t) + + bucket := base.GetTestBucket(t) + rt := NewRestTesterMultipleCollections(t, &RestTesterConfig{PersistentConfig: true, CustomTestBucket: bucket}, 1) + defer rt.Close() + + // add single named collection + newCollection := base.ScopeAndCollectionName{Scope: base.DefaultScope, Collection: t.Name()} + require.NoError(t, bucket.CreateDataStore(base.TestCtx(t), newCollection)) + defer func() { + require.NoError(t, rt.TestBucket.DropDataStore(newCollection)) + }() + + dbConfig := rt.NewDbConfig() + dbConfig.Scopes = ScopesConfig{ + base.DefaultScope: {Collections: CollectionsConfig{ + base.DefaultCollection: {}, + newCollection.CollectionName(): {}, + }}, + } + RequireStatus(t, rt.CreateDatabase("db", dbConfig), http.StatusCreated) + + // Test that the keyspace with no channels assigned does not have a key in the response + grant := userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace1}}": {}, + "{{.keyspace2}}": {"D"}}, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection2}}":{ + "D":{ + "entries":["1-0"] + } + } + }}`, + } + grant.request(rt) + + // add channel to single named collection and assert its handled + grant = userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace1}}": {"A"}, + }, + output: ` +{ + "all_channels":{ + "{{.scopeAndCollection1}}":{ + "A":{ + "entries":["2-0"] + } + }, + "{{.scopeAndCollection2}}":{ + "D":{ + "entries":[ + "1-0" + ] + } + } + } +}`, + } + grant.request(rt) + +} + +func TestGetAllChannelsByUserWithMultiCollections(t *testing.T) { + base.TestRequiresCollections(t) + + rt := NewRestTesterMultipleCollections(t, &RestTesterConfig{PersistentConfig: true}, 2) + defer rt.Close() + + RequireStatus(t, rt.CreateDatabase("db", rt.NewDbConfig()), http.StatusCreated) + + // Test that the keyspace with no channels assigned does not have a key in the response + grant := userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace1}}": {"D"}}, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection1}}":{ + "D":{ + "entries":["1-0"] + } + } + }}`, + } + grant.request(rt) + + // add channel to collection with no channels and assert multi collection is handled + grant = userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace2}}": {"A"}, + }, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection2}}":{ + "A":{ + "entries":["2-0"] + } + }, + "{{.scopeAndCollection1}}":{ + "D":{ + "entries":[ + "1-0" + ] + } + } + } + }`, + } + grant.request(rt) + + // check removed channel in keyspace2 is in history before deleting collection 2 + grant = userGrant{ + user: "alice", + adminChannels: map[string][]string{ + "{{.keyspace1}}": {"D"}, + "{{.keyspace2}}": {"!"}, + }, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection2}}":{ + "A":{ + "entries":["2-3"] + } + }, + "{{.scopeAndCollection1}}":{ + "D":{ + "entries":["1-0"] + } + } + } + }`, + } + grant.request(rt) + + // delete collection 2 + dbConfig := rt.NewDbConfig() + dbConfig.Scopes = GetCollectionsConfig(t, rt.TestBucket, 1) + RequireStatus(t, rt.UpsertDbConfig("db", dbConfig), http.StatusCreated) + + // check deleted collection is not there + grant = userGrant{ + user: "alice", + adminChannels: map[string][]string{"{{.keyspace}}": {"D"}}, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection}}":{ + "D":{ + "entries":[ + "1-0" + ] + } + } + } + }`, + } + grant.request(rt) +} + +func TestGetAllChannelsByUserDeletedRole(t *testing.T) { + + rt := NewRestTesterPersistentConfig(t) + defer rt.Close() + + // Create role with 1 channel and assign it to user + roleGrant := roleGrant{role: "role1", adminChannels: map[string][]string{"{{.keyspace}}": {"role1Chan"}}} + roleGrant.request(rt) + userGrant := userGrant{ + user: "alice", + roles: []string{"role1"}, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection}}":{ + "role1Chan":{ + "entries":["2-0"] + } + } + }}`, + } + userGrant.request(rt) + + // Delete role and assert its channels no longer appear in response + resp := rt.SendAdminRequest("DELETE", "/db/_role/role1", ``) + RequireStatus(t, resp, http.StatusOK) + + userGrant.output = `{}` + userGrant.roles = []string{} + userGrant.request(rt) + +} + +func TestGetAllChannelsByUserNonexistentAndDeletedUser(t *testing.T) { + + rt := NewRestTesterPersistentConfig(t) + defer rt.Close() + + // assert the endpoint returns 404 when user is not found + resp := rt.SendDiagnosticRequest("GET", "/db/_user/user1/_all_channels", ``) + RequireStatus(t, resp, http.StatusNotFound) + + // Create user and assert on response + userGrant := userGrant{ + user: "user1", + adminChannels: map[string][]string{"{{.keyspace}}": {"A"}}, + output: ` + { + "all_channels":{ + "{{.scopeAndCollection}}":{ + "A":{ + "entries":["1-0"] + } + } + }}`, + } + userGrant.request(rt) + + // delete user + resp = rt.SendAdminRequest("DELETE", "/db/_user/user1", ``) + RequireStatus(t, resp, http.StatusOK) + + // Get deleted user all channels, expect 404 + resp = rt.SendDiagnosticRequest("GET", "/db/_user/user1/_all_channels", ``) + RequireStatus(t, resp, http.StatusNotFound) +} diff --git a/rest/routing.go b/rest/routing.go index eab5a66314..6487d5cd05 100644 --- a/rest/routing.go +++ b/rest/routing.go @@ -375,6 +375,8 @@ func createDiagnosticRouter(sc *ServerContext) *mux.Router { keyspace.Handle("/{docid:"+docRegex+"}/_all_channels", makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleGetDocChannels)).Methods("GET") keyspace.Handle("/_sync", makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleSyncFnDryRun)).Methods("GET") keyspace.Handle("/_import_filter", makeHandler(sc, adminPrivs, []Permission{PermReadAppData}, nil, (*handler).handleImportFilterDryRun)).Methods("GET") + dbr.Handle("/_user/{name}/_all_channels", + makeHandler(sc, adminPrivs, []Permission{PermReadPrincipal}, nil, (*handler).handleGetAllChannels)).Methods("GET") return r } diff --git a/rest/utilities_testing.go b/rest/utilities_testing.go index 0cec7f0c3d..3ef60a4820 100644 --- a/rest/utilities_testing.go +++ b/rest/utilities_testing.go @@ -656,13 +656,16 @@ func (rt *RestTester) templateResource(resource string) (string, error) { if multipleDatabases { data[fmt.Sprintf("db%dkeyspace", i+1)] = getKeyspaces(rt.TB, database)[0] } else { - data["keyspace"] = rt.GetSingleKeyspace() + keyspace := rt.GetSingleKeyspace() + data["keyspace"] = keyspace + data["scopeAndCollection"] = getScopeAndCollectionFromKeyspace(rt.TB, keyspace) } continue } for j, keyspace := range getKeyspaces(rt.TB, database) { if !multipleDatabases { data[fmt.Sprintf("keyspace%d", j+1)] = keyspace + data[fmt.Sprintf("scopeAndCollection%d", j+1)] = getScopeAndCollectionFromKeyspace(rt.TB, keyspace) } else { data[fmt.Sprintf("db%dkeyspace%d", i+1, j+1)] = keyspace } @@ -2456,6 +2459,18 @@ func getRESTKeyspace(_ testing.TB, dbName string, collection *db.DatabaseCollect return strings.Join([]string{dbName, collection.ScopeName, collection.Name}, base.ScopeCollectionSeparator) } +// getScopeAndCollectionFromKeyspace returns the scope and collection from a keyspace, e.g. /db/ -> _default._default , or /db.scope1.collection1 -> scope1.collection1 +func getScopeAndCollectionFromKeyspace(t testing.TB, keyspace string) string { + _, scope, collection, err := ParseKeyspace(keyspace) + require.NoError(t, err) + if scope == nil && collection == nil { + return strings.Join([]string{base.DefaultScope, base.DefaultCollection}, base.ScopeCollectionSeparator) + } + require.NotNil(t, scope, "Expected scope to be non-nil for %s", keyspace) + require.NotNil(t, collection, "Expected collection to be non-nil for %s", keyspace) + return strings.Join([]string{*scope, *collection}, base.ScopeCollectionSeparator) +} + // getKeyspaces returns the names of all the keyspaces on the rest tester. Currently assumes a single database. func getKeyspaces(t testing.TB, database *db.DatabaseContext) []string { var keyspaces []string