diff --git a/db/database.go b/db/database.go index c7e9289bb6..9ae67b4490 100644 --- a/db/database.go +++ b/db/database.go @@ -188,6 +188,7 @@ type DatabaseContextOptions struct { NumIndexReplicas uint // Number of replicas for GSI indexes NumIndexPartitions *uint32 // Number of partitions for GSI indexes, if not set will default to 1 ImportVersion uint64 // Version included in import DCP checkpoints, incremented when collections added to db + DisablePublicAllDocs bool // Disable public access to the _all_docs endpoint for this database } type ConfigPrincipals struct { diff --git a/docs/api/components/schemas.yaml b/docs/api/components/schemas.yaml index aa9b21aa2b..9944a3e45f 100644 --- a/docs/api/components/schemas.yaml +++ b/docs/api/components/schemas.yaml @@ -1915,6 +1915,16 @@ Database: name: description: The name of the role for which audit logging is disabled. type: string + disable_public_all_docs: + description: |- + This controls whether the [GET /{keyspace}/_all_docs](#operation/get_keyspace-_all_docs) REST API endpoint is publicly accessible or not. + Disabling this endpoint is recommended for larger datasets or production workloads. + [GET /{keyspace}/_changes](#operation/get_keyspace-_changes) or [POST /{keyspace}/_bulk_get](#operation/post_keyspace-_bulk_get) have more efficient implementations and should be used instead. + + If set to `true`, the endpoint will not be publicly accessible, and will only be available on the Admin API. + Setting this to `false`, or leaving it as the default value is deprecated, and may default to `true` in a future release. + type: boolean + default: false title: Database-config Disabled-users-and-roles: type: object diff --git a/docs/api/paths/admin/keyspace-_all_docs.yaml b/docs/api/paths/admin/keyspace-_all_docs.yaml index bdbcbdc669..0640b3f630 100644 --- a/docs/api/paths/admin/keyspace-_all_docs.yaml +++ b/docs/api/paths/admin/keyspace-_all_docs.yaml @@ -12,6 +12,9 @@ get: description: |- Returns all documents in the database based on the specified parameters. + This endpoint is not recommended for larger datasets or production workloads. + [GET /{keyspace}/_changes](#operation/get_keyspace-_changes) or [POST /{keyspace}/_bulk_get](#operation/post_keyspace-_bulk_get) have more efficient implementations and should be used instead. + Required Sync Gateway RBAC roles: * Sync Gateway Application @@ -41,6 +44,9 @@ post: description: |- Returns all documents in the database based on the specified parameters. + This endpoint is not recommended for larger datasets or production workloads. + [GET /{keyspace}/_changes](#operation/get_keyspace-_changes) or [POST /{keyspace}/_bulk_get](#operation/post_keyspace-_bulk_get) have more efficient implementations and should be used instead. + Required Sync Gateway RBAC roles: * Sync Gateway Application diff --git a/docs/api/paths/public/keyspace-_all_docs.yaml b/docs/api/paths/public/keyspace-_all_docs.yaml index 996da9e110..95e223559f 100644 --- a/docs/api/paths/public/keyspace-_all_docs.yaml +++ b/docs/api/paths/public/keyspace-_all_docs.yaml @@ -9,7 +9,11 @@ parameters: - $ref: ../../components/parameters.yaml#/keyspace get: summary: Gets all the documents in the database with the given parameters - description: Returns all documents in the database based on the specified parameters. + description: |- + Returns all documents in the database based on the specified parameters. + + This endpoint is not recommended for larger datasets or production workloads. + [GET /{keyspace}/_changes](#operation/get_keyspace-_changes) or [POST /{keyspace}/_bulk_get](#operation/post_keyspace-_bulk_get) have more efficient implementations and should be used instead. parameters: - $ref: ../../components/parameters.yaml#/include_docs - $ref: ../../components/parameters.yaml#/Include-channels @@ -25,6 +29,12 @@ get: $ref: ../../components/responses.yaml#/all-docs '400': $ref: ../../components/responses.yaml#/request-problem + '403': + description: This API endpoint has been disabled by the administrator. + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/HTTP-Error '404': $ref: ../../components/responses.yaml#/Not-found tags: @@ -32,7 +42,11 @@ get: operationId: get_keyspace-_all_docs post: summary: Get all the documents in the database using a built-in view - description: Returns all documents in the database based on the specified parameters. + description: |- + Returns all documents in the database based on the specified parameters. + + This endpoint is not recommended for larger datasets or production workloads. + [GET /{keyspace}/_changes](#operation/get_keyspace-_changes) or [POST /{keyspace}/_bulk_get](#operation/post_keyspace-_bulk_get) have more efficient implementations and should be used instead. parameters: - $ref: ../../components/parameters.yaml#/include_docs - $ref: ../../components/parameters.yaml#/Include-channels @@ -60,6 +74,12 @@ post: $ref: ../../components/responses.yaml#/all-docs '400': $ref: ../../components/responses.yaml#/request-problem + '403': + description: This API endpoint has been disabled by the administrator. + content: + application/json: + schema: + $ref: ../../components/schemas.yaml#/HTTP-Error '404': $ref: ../../components/responses.yaml#/Not-found tags: diff --git a/rest/access_test.go b/rest/access_test.go index e92f3bbcf4..d02a8ecbaa 100644 --- a/rest/access_test.go +++ b/rest/access_test.go @@ -70,22 +70,7 @@ func TestStarAccess(t *testing.T) { base.SetUpTestLogging(t, base.LevelDebug, base.KeyChanges) - type allDocsRow struct { - ID string `json:"id"` - Key string `json:"key"` - Value struct { - Rev string `json:"rev"` - Channels []string `json:"channels,omitempty"` - Access map[string]base.Set `json:"access,omitempty"` // for admins only - } `json:"value"` - Doc db.Body `json:"doc,omitempty"` - Error string `json:"error"` - } - var allDocsResult struct { - TotalRows int `json:"total_rows"` - Offset int `json:"offset"` - Rows []allDocsRow `json:"rows"` - } + var allDocsResult allDocsResponse // Create some docs: rt := NewRestTester(t, &RestTesterConfig{SyncFn: channels.DocChannelsSyncFunction}) @@ -551,23 +536,6 @@ func TestAllDocsAccessControl(t *testing.T) { rt := NewRestTester(t, &RestTesterConfig{SyncFn: channels.DocChannelsSyncFunction}) defer rt.Close() - type allDocsRow struct { - ID string `json:"id"` - Key string `json:"key"` - Value struct { - Rev string `json:"rev"` - Channels []string `json:"channels,omitempty"` - Access map[string]base.Set `json:"access,omitempty"` // for admins only - } `json:"value"` - Doc db.Body `json:"doc,omitempty"` - Error string `json:"error"` - } - type allDocsResponse struct { - TotalRows int `json:"total_rows"` - Offset int `json:"offset"` - Rows []allDocsRow `json:"rows"` - } - // Create some docs: a := auth.NewAuthenticator(rt.MetadataStore(), nil, rt.GetDatabase().AuthenticatorOptions(rt.Context())) a.Collections = rt.GetDatabase().CollectionNames @@ -707,13 +675,13 @@ func TestAllDocsAccessControl(t *testing.T) { assert.Equal(t, []string{"Cinemax"}, allDocsResult.Rows[0].Value.Channels) assert.Equal(t, "doc1", allDocsResult.Rows[1].Key) assert.Equal(t, "forbidden", allDocsResult.Rows[1].Error) - assert.Equal(t, "", allDocsResult.Rows[1].Value.Rev) + assert.Nil(t, allDocsResult.Rows[1].Value) assert.Equal(t, "doc3", allDocsResult.Rows[2].ID) assert.Equal(t, []string{"Cinemax"}, allDocsResult.Rows[2].Value.Channels) assert.Equal(t, "1-20912648f85f2bbabefb0993ddd37b41", allDocsResult.Rows[2].Value.Rev) assert.Equal(t, "b0gus", allDocsResult.Rows[3].Key) assert.Equal(t, "not_found", allDocsResult.Rows[3].Error) - assert.Equal(t, "", allDocsResult.Rows[3].Value.Rev) + assert.Nil(t, allDocsResult.Rows[3].Value) // Check GET to _all_docs with keys parameter: response = rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_all_docs?channels=true&keys=%5B%22doc4%22%2C%22doc1%22%2C%22doc3%22%2C%22b0gus%22%5D", "", "alice") diff --git a/rest/api_test.go b/rest/api_test.go index 7f156370f9..5ac2c24129 100644 --- a/rest/api_test.go +++ b/rest/api_test.go @@ -1228,22 +1228,7 @@ func TestResponseEncoding(t *testing.T) { func TestAllDocsChannelsAfterChannelMove(t *testing.T) { - type allDocsRow struct { - ID string `json:"id"` - Key string `json:"key"` - Value struct { - Rev string `json:"rev"` - Channels []string `json:"channels,omitempty"` - Access map[string]base.Set `json:"access,omitempty"` // for admins only - } `json:"value"` - Doc db.Body `json:"doc,omitempty"` - Error string `json:"error"` - } - var allDocsResult struct { - TotalRows int `json:"total_rows"` - Offset int `json:"offset"` - Rows []allDocsRow `json:"rows"` - } + var allDocsResult allDocsResponse rtConfig := RestTesterConfig{SyncFn: `function(doc) {channel(doc.channels)}`} rt := NewRestTester(t, &rtConfig) diff --git a/rest/bulk_api.go b/rest/bulk_api.go index c3f2b532dc..4c6c9916b7 100644 --- a/rest/bulk_api.go +++ b/rest/bulk_api.go @@ -25,8 +25,33 @@ import ( "github.com/couchbase/sync_gateway/db" ) +type allDocsResponse struct { + Rows []allDocsRow `json:"rows"` + TotalRows int `json:"total_rows"` + UpdateSeq uint64 `json:"update_seq"` +} + +type allDocsRowValue struct { + Rev string `json:"rev"` + Channels []string `json:"channels,omitempty"` + Access map[string]base.Set `json:"access,omitempty"` // for admins only +} +type allDocsRow struct { + Key string `json:"key"` + ID string `json:"id,omitempty"` + Value *allDocsRowValue `json:"value,omitempty"` + Doc json.RawMessage `json:"doc,omitempty"` + UpdateSeq uint64 `json:"update_seq,omitempty"` + Error string `json:"error,omitempty"` + Status int `json:"status,omitempty"` +} + // HTTP handler for _all_docs func (h *handler) handleAllDocs() error { + if h.privs != adminPrivs && h.db.DatabaseContext.Options.DisablePublicAllDocs { + return base.HTTPErrorf(http.StatusForbidden, "public access to _all_docs is disabled for this database") + } + // http://wiki.apache.org/couchdb/HTTP_Bulk_Document_API includeDocs := h.getBoolQuery("include_docs") includeChannels := h.getBoolQuery("channels") @@ -99,21 +124,6 @@ func (h *handler) handleAllDocs() error { return result } - type allDocsRowValue struct { - Rev string `json:"rev"` - Channels []string `json:"channels,omitempty"` - Access map[string]base.Set `json:"access,omitempty"` // for admins only - } - type allDocsRow struct { - Key string `json:"key"` - ID string `json:"id,omitempty"` - Value *allDocsRowValue `json:"value,omitempty"` - Doc json.RawMessage `json:"doc,omitempty"` - UpdateSeq uint64 `json:"update_seq,omitempty"` - Error string `json:"error,omitempty"` - Status int `json:"status,omitempty"` - } - // Subroutine that creates a response row for a document: totalRows := 0 createRow := func(doc db.IDRevAndSequence, channels []string) *allDocsRow { diff --git a/rest/bulk_api_test.go b/rest/bulk_api_test.go new file mode 100644 index 0000000000..603b271175 --- /dev/null +++ b/rest/bulk_api_test.go @@ -0,0 +1,81 @@ +// Copyright 2025-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 ( + "encoding/json" + "net/http" + "testing" + + "github.com/couchbase/sync_gateway/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDisablePublicAllDocs(t *testing.T) { + tests := []struct { + name string + disablePublicAllDocs *bool + expectedPublicStatus int + expectedPublicError string + }{ + { + name: "default", + disablePublicAllDocs: nil, + expectedPublicStatus: http.StatusOK, + }, + { + name: "disabled", + disablePublicAllDocs: base.Ptr(true), + expectedPublicStatus: http.StatusForbidden, + expectedPublicError: "public access to _all_docs is disabled for this database", + }, + { + name: "enabled", + disablePublicAllDocs: base.Ptr(false), + expectedPublicStatus: http.StatusOK, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rtConfig := RestTesterConfig{ + DatabaseConfig: &DatabaseConfig{ + DbConfig: DbConfig{ + DisablePublicAllDocs: test.disablePublicAllDocs, + }, + }, + } + rt := NewRestTester(t, &rtConfig) + defer rt.Close() + + rt.CreateUser("user1", nil) + rt.CreateTestDoc("doc1") + rt.CreateTestDoc("doc2") + + t.Run("public", func(t *testing.T) { + response := rt.SendUserRequest("GET", "/{{.keyspace}}/_all_docs", "", "user1") + RequireStatus(t, response, test.expectedPublicStatus) + if test.expectedPublicError != "" { + require.Contains(t, response.Body.String(), test.expectedPublicError) + } + }) + + t.Run("admin", func(t *testing.T) { + response := rt.SendAdminRequest("GET", "/{{.keyspace}}/_all_docs", "") + RequireStatus(t, response, http.StatusOK) + var result allDocsResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) + assert.Equal(t, 2, len(result.Rows)) + assert.Equal(t, "doc1", result.Rows[0].ID) + assert.Equal(t, "doc2", result.Rows[1].ID) + }) + }) + } +} diff --git a/rest/config.go b/rest/config.go index 69709879f8..e4747d8d71 100644 --- a/rest/config.go +++ b/rest/config.go @@ -196,6 +196,7 @@ type DbConfig struct { Logging *DbLoggingConfig `json:"logging,omitempty"` // Per-database Logging config UpdatedAt *time.Time `json:"updated_at,omitempty"` // Time at which the database config was last updated CreatedAt *time.Time `json:"created_at,omitempty"` // Time at which the database config was created + DisablePublicAllDocs *bool `json:"disable_public_all_docs,omitempty"` // Whether to disable public access to the _all_docs endpoint for this database } type ScopesConfig map[string]ScopeConfig diff --git a/rest/config_database.go b/rest/config_database.go index 69798e450d..3ed06b0da0 100644 --- a/rest/config_database.go +++ b/rest/config_database.go @@ -185,6 +185,7 @@ func DefaultDbConfig(sc *StartupConfig, useXattrs bool) *DbConfig { JavascriptTimeoutSecs: base.Ptr(base.DefaultJavascriptTimeoutSecs), Suspendable: base.Ptr(sc.IsServerless()), Logging: DefaultPerDBLogging(sc.Logging), + DisablePublicAllDocs: base.Ptr(false), } if useXattrs { diff --git a/rest/server_context.go b/rest/server_context.go index 45f1180c13..10681b6343 100644 --- a/rest/server_context.go +++ b/rest/server_context.go @@ -1349,7 +1349,7 @@ func dbcOptionsFromConfig(ctx context.Context, sc *ServerContext, config *DbConf } if config.Unsupported.DisableCleanSkippedQuery { - base.WarnfCtx(ctx, `Deprecation notice: setting databse configuration option "disable_clean_skipped_query" no longer has any functionality. In the future, this option will be removed.`) + base.WarnfCtx(ctx, `Deprecation notice: setting database configuration option "disable_clean_skipped_query" no longer has any functionality. In the future, this option will be removed.`) } // If basic auth is disabled, it doesn't make sense to send WWW-Authenticate sendWWWAuthenticate := config.SendWWWAuthenticateHeader @@ -1357,6 +1357,11 @@ func dbcOptionsFromConfig(ctx context.Context, sc *ServerContext, config *DbConf sendWWWAuthenticate = base.Ptr(false) } + disablePublicAllDocs := base.ValDefault(config.DisablePublicAllDocs, false) + if !disablePublicAllDocs { + base.WarnfCtx(ctx, `Deprecation notice: setting database configuration option "disable_public_all_docs" to false is deprecated. In the future, public access to the all_docs API will be disabled by default.`) + } + contextOptions := db.DatabaseContextOptions{ CacheOptions: &cacheOptions, RevisionCacheOptions: revCacheOptions, @@ -1392,6 +1397,7 @@ func dbcOptionsFromConfig(ctx context.Context, sc *ServerContext, config *DbConf MaxConcurrentChangesBatches: sc.Config.Replicator.MaxConcurrentChangesBatches, MaxConcurrentRevs: sc.Config.Replicator.MaxConcurrentRevs, NumIndexReplicas: config.numIndexReplicas(), + DisablePublicAllDocs: disablePublicAllDocs, } if config.Index != nil && config.Index.NumPartitions != nil {