Skip to content

Commit dcb5f79

Browse files
committed
MB-71275 - better permissions handling in the document editor
This issue came out of CBSE-22470, where a customer noted problems with a user who lacked `collections!read` permission on some buckets, but had the permission for other buckets. The bucket/scope/collection menu in the documents page would allow the user to select both permitted and non-permitted buckets, but no scopes/collections were shown for the non-permitted buckets. The user in question had permissions for Query System Catalog, but the UI was trying to use the non-permitted `/pools/default` endpoint to retrieve the list of scopes and collections for the bucket. The UI would default to the `pools/default` endpoint if the user had 'cluster.collection[.:.:.].collections!read' permissions, which means the right to list scopes/collections for at least one bucket. But that doesn't mean that they have the right to list scopes/collections for *every* bucket. The code has been unchanged for the past 4 years, and this is the first time a customer has run into the problem. However, it looks like the use of permissions in the document editor was never really correct after the addition of collections in release 7.0. Problem 1: there are two separate APIs to list scopes/collections, which require *different* permissions. The `/pools/default/buckets` endpoint requires 'cluster.collection[bucket:.:.].collections!read' permission. However, the same information can be retrieved from the query service if the user has the 'Query System Catalog' role. A user might have one of those permissions but not both, thus the code must check and use the appropriate endpoint. Problem 2: there are two separate APIs for retrieving documents. A user with only 'data.docs!read' permission can't use the query endpoint, but a user with 'n1ql.select!execute' can use either the query endpoint or '/pools/default/buckets'. And of course these permissions are independent of those described in Problem 1. Problem 3: to determine whether to permit document editing, the UI was only checking bucket-level permissions, and not collection-level permissions. Thus it might allow the user to try and edit a document in a read-only collection if there was some other collection in the bucket that is writeable. The back-end would prevent the edit, but that doesn't look good. Solution 1: Retrieve the 'collections!read' permission for every bucket when the document editor is initialized. The code was already retrieving whether the user has Query Sytem Catalog, so now the code can determine if the user has permission to fetch scope/collections, and if so which of the two APIs to use. Solution 2: Whenever a collection is selected in the document editor, if needed fetch the permissions for that specific collection w.r.t. 'docs!read', 'docs!upsert', 'docs!delete', and 'n1ql.select!execute'. With this information the code knows which APIs are available for fetching documents, and whether the documents are read-only, read-write, or write-only. (Yes, it is actually possible to create documents in a collection where the user doesn't have read access!) Change-Id: Ie495af092f4330b7388060b265251e4a14f58db2 Reviewed-on: https://review.couchbase.org/c/query-ui/+/242881 Reviewed-by: Piotr Najda <piotr.najda@couchbase.com> Tested-by: Eben Haber <eben@couchbase.com> Well-Formed: Restriction Checker
1 parent 4d5aa41 commit dcb5f79

9 files changed

Lines changed: 121 additions & 30 deletions

File tree

query-ui/angular-components/documents/qw.documents.component.js

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
9393
}
9494

9595
docViewer() {
96-
return this.rbac.init && this.rbac.cluster.collection['.:.:.'].data.docs.read;
96+
return this.rbac.init && (this.rbac.cluster.collection['.:.:.'].data.docs.read || this.rbac.cluster.collection['.:.:.'].data.docs.upsert);
9797
}
9898

9999
constructor(
@@ -165,8 +165,9 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
165165
dec.how_to_query = how_to_query;
166166
dec.can_use_n1ql = can_use_n1ql;
167167
dec.has_indexes = has_indexes;
168+
dec.get_n1ql_placeholder = get_n1ql_placeholder;
168169

169-
// wheneverthe collection menu is changed, remove any 'where' clause and offset
170+
// whenever the collection menu is changed, remove any 'where' clause and offset
170171
dec.collectionMenuCallback = function(event) {
171172
//console.log("collectionMenuCallback: " + JSON.stringify(event));
172173
// MB-51579 - avoid race condition, make sure we have buckets specified
@@ -180,8 +181,12 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
180181
dec.searchForm.get('where_clause').setValue('');
181182
dec.searchForm.get('offset').setValue(0);
182183

183-
if (event.bucket && event.scope && event.collection)
184-
retrieveDocs_inner();
184+
if (event.bucket && event.scope && event.collection) {
185+
// get permissions for new collection
186+
qwMetadataService.checkCollectionPerms(dec.options.selected_bucket, dec.options.selected_scope, dec.options.selected_collection).then(function () {
187+
retrieveDocs_inner();
188+
});
189+
}
185190
}
186191
};
187192

@@ -195,16 +200,23 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
195200
//
196201

197202
dec.upsertAllowed = function() {
198-
return this.rbac.init && this.options.selected_bucket &&
199-
this.rbac.cluster.collection[this.options.selected_bucket + ':.:.'] &&
200-
(this.rbac.cluster.collection[this.options.selected_bucket + ':.:.'].data.docs.upsert ||
201-
this.rbac.cluster.bucket[this.options.selected_bucket].data.docs.upsert);
203+
if (!this.rbac.init || !this.options.selected_bucket || !this.options.selected_scope || !this.options.selected_collection)
204+
return false;
205+
206+
const fullName = `${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`;
207+
208+
return (this.rbac.cluster.collection[fullName]?.data?.docs?.upsert ||
209+
this.rbac.cluster.bucket[this.options.selected_bucket].data.docs.upsert);
202210
};
211+
203212
dec.deleteAllowed = function() {
204-
return this.rbac.init && this.options.selected_bucket &&
205-
this.rbac.cluster.collection[this.options.selected_bucket + ':.:.'] &&
206-
(this.rbac.cluster.collection[this.options.selected_bucket + ':.:.'].data.docs.delete ||
207-
this.rbac.cluster.bucket[this.options.selected_bucket].data.docs.delete);
213+
if (!this.rbac.init || !this.options.selected_bucket || !this.options.selected_scope || !this.options.selected_collection)
214+
return false;
215+
216+
const fullName = `${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`;
217+
218+
return (this.rbac.cluster.collection[fullName]?.data?.docs?.delete ||
219+
this.rbac.cluster.bucket[this.options.selected_bucket].data.docs.delete);
208220
};
209221

210222
//
@@ -285,10 +297,20 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
285297
return (false);
286298
}
287299

300+
// corner case - can't query if we can't read documents
301+
if (dec.rbac.cluster.collection[`${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`]?.data?.docs?.read !== true) {
302+
dec.options.current_result = "No permission to read documents.";
303+
return (false);
304+
}
305+
288306
// always use KV for single doc lookups by ID
289307
if (dec.options.show_id && dec.options.doc_id)
290308
return KV;
291309

310+
// N1QL is not available if the user lacks n1ql.select permissions on the current collection
311+
if (dec.rbac.cluster.collection[`${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`]?.n1ql.select.execute !== true)
312+
return KV;
313+
292314
// key range lookup or limit/offset with no WHERE clause
293315
// - use N1QL if primary index, otherwise KV (though fail if ephemeral)
294316
if ((!dec.options.show_id && (dec.options.doc_id_start || dec.options.doc_id_end)) || dec.options.where_clause.length == 0) {
@@ -325,7 +347,18 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
325347
//
326348

327349
function can_use_n1ql() {
328-
return (has_prim() || has_sec());
350+
// N1QL is not available if the user lacks n1ql.select permissions on the current collection
351+
return (dec.rbac.cluster.collection[`${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`]?.n1ql.select.execute === true
352+
&& (has_prim() || has_sec()));
353+
}
354+
355+
function get_n1ql_placeholder() {
356+
if (dec.rbac.cluster.collection[`${dec.options.selected_bucket}:${dec.options.selected_scope}:${dec.options.selected_collection}`]?.n1ql.select.execute !== true)
357+
return 'no query permissions';
358+
else if (!has_indexes())
359+
return 'no indexes available...';
360+
else
361+
return 'optional...';
329362
}
330363

331364
//
@@ -756,6 +789,7 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
756789
case false: // error status
757790
showErrorDialog("Document Error", dec.options.current_result, true);
758791
dec.options.current_query = dec.options.selected_bucket;
792+
refreshResults();
759793
break;
760794
}
761795
}
@@ -1253,7 +1287,10 @@ class QwDocumentsComponent extends MnLifeCycleHooksToStream {
12531287
metadataUpdate(meta);
12541288
// MB-51579 - when collection unspecified, don't try to retrieve documents
12551289
if (dec.options.selected_bucket && dec.options.selected_scope && dec.options.selected_collection && dec.docViewer())
1256-
retrieveDocs();
1290+
// get permissions for new collection
1291+
qwMetadataService.checkCollectionPerms(dec.options.selected_bucket, dec.options.selected_scope, dec.options.selected_collection).then(function () {
1292+
retrieveDocs_inner();
1293+
});
12571294
});
12581295
});
12591296
}

query-ui/angular-components/documents/qw.documents.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
-->
1010

1111
<div *ngIf="!docViewer()">
12-
Insufficient permissions to view documents. User must have at least Data Reader on one or more
12+
Insufficient permissions to view documents. User must have at least Data Reader or Writer on one or more
1313
collections, and also the ability to view scopes and collections in that bucket.
1414
</div>
1515

@@ -151,7 +151,7 @@ <h5 class="inline">Document ID&nbsp;</h5>
151151
(change)="dec.where_changed()"
152152
[attr.disabled]="(!dec.can_use_n1ql() || (dec.options.show_id && dec.options.doc_id) || (!dec.options.show_id && (dec.options.doc_id_start || dec.options.doc_id_end))) || null"
153153
class="width-12"
154-
placeholder="{{dec.has_indexes() ? 'optional...' : 'no indexes available...'}}">
154+
placeholder="{{dec.get_n1ql_placeholder()}}">
155155
</div>
156156

157157
<div style="display:none;" class="resp-show-sml margin-top-half width-12"></div>

query-ui/angular-components/documents/qw.documents.module.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ let documentsStates = [
5050
name: 'app.admin.docs.editor',
5151
data: {
5252
title: "Documents", // appears in breadcrumbs in title bar
53-
permissions: "cluster.collection['.:.:.'].data.docs.read",
53+
permissions: "cluster.collection['.:.:.'].data.docs.read || cluster.collection['.:.:.'].data.docs.upsert",
5454
},
5555
params: { // can parameters be sent via the URL?
5656
bucket: {

query-ui/angular-directives/qw.collection.menu.component.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,13 @@ class QwCollectionMenu extends MnLifeCycleHooksToStream {
251251
this.scopes[selectedBucket].indexOf(selectedScope) < 0))
252252
this.keyspaceForm.get("scopeName").setValue(this.scopes[selectedBucket][0]);
253253

254-
// make sure we have collections to work with
254+
// if no scopes, blank out scope selection
255+
else if (this.scopes[selectedBucket].length === 0) {
256+
this.keyspaceForm.get("scopeName").setValue('');
257+
this.keyspaceForm.get("collectionName").setValue('');
258+
}
259+
260+
// make sure we have collections to work with
255261
if (!selectedScope || !this.collections[selectedBucket] ||
256262
!this.collections[selectedBucket][selectedScope])
257263
return;

query-ui/angular-directives/qw.collection.menu.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@
3636
*ngIf="errors && compat.atLeast70"
3737
placement="auto"
3838
container="body"
39-
class="fa-warning icon orange-3 cursor-pointer"
39+
class="fa-warning icon orange-3 cursor-pointer margin-right-half"
4040
[ngbTooltip]="errors">
4141
</span>

query-ui/angular-directives/qw.json.table.editor.directive.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ function createHTMLFromJson(json) {
344344

345345
// if json not array, it must be error message string
346346
else {
347-
resultHTML = wrapperStart + json + wrapperEnd;
347+
resultHTML = `${wrapperStart} <div class="error text-small"> ${json} </div> ${wrapperEnd}`;
348348
}
349349

350350
return (resultHTML);

query-ui/angular-services/qw.collections.service.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,27 @@ function getQwCollectionsService(
170170

171171

172172
function refreshScopesAndCollectionsForBucket(bucket, proxy) {
173-
const canUseEndpoints = qms.rbac && qms.rbac.cluster.collection['.:.:.'].collections.read;
174-
return canUseEndpoints ?
175-
refreshScopesAndCollectionsForBucketUsingApi(bucket, proxy) :
176-
refreshScopesAndCollectionsForBucketUsingQuery(bucket, proxy);
173+
// if we are fetching data from a remote cluster, we don't have any permission info, must use API and hope
174+
// for the best
175+
if (proxy || !qms.rbac) {
176+
return refreshScopesAndCollectionsForBucketUsingApi(bucket, proxy);
177+
}
178+
179+
// otherwise, which API do we have permission to use, if any?
180+
const canUseEndpoints = qms.rbac?.cluster.collection[`${bucket}:.:.`].collections.read;
181+
const canUseQuery = qms.rbac?.cluster.n1ql.meta.read;
182+
if (canUseEndpoints)
183+
return refreshScopesAndCollectionsForBucketUsingApi(bucket, proxy);
184+
else if (canUseQuery)
185+
return refreshScopesAndCollectionsForBucketUsingQuery(bucket, proxy);
186+
187+
// no permissions - return an error
188+
const meta = getMeta(proxy);
189+
meta.errors.length = 0;
190+
meta.errors.push(`No permission to read scopes and collections for ${bucket}. User needs either 'cluster.collection[${bucket}:.:.].collections!read' or 'cluster.n1ql.meta!read'`);
191+
meta.scopes[bucket] = [];
192+
meta.collections[bucket] = {};
193+
return Promise.resolve(meta);
177194
}
178195

179196
function refreshScopesAndCollectionsForBucketUsingQuery(bucket, proxy) {

query-ui/angular-services/qw.metadata.service.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ class QwMetadataService {
7272
error => {This.compat = {err: error}});
7373
}
7474

75+
// find out what permissions we have on a selected collection
76+
checkCollectionPerms(bucket, scope, collection) {
77+
const fullName = `${bucket}:${scope}:${collection}`;
78+
const perms = [
79+
`cluster.collection[${fullName}].data.docs!read`,
80+
`cluster.collection[${fullName}].data.docs!upsert`,
81+
`cluster.collection[${fullName}].data.docs!delete`,
82+
`cluster.collection[${fullName}].n1ql.select!execute`,
83+
];
84+
85+
// do we need to get the permissions?
86+
if (this.rbac.cluster.collection[fullName]?.data?.docs?.read === undefined ||
87+
this.rbac.cluster.collection[fullName]?.data?.docs?.upsert === undefined ||
88+
this.rbac.cluster.collection[fullName]?.data?.docs?.delete === undefined ||
89+
this.rbac.cluster.collection[fullName]?.n1ql?.select?.execute === undefined) {
90+
return this.qwHttp.post('/pools/default/checkPermissions',perms.join(','))
91+
.then(result => {
92+
this.decodePermissions(result.data,true)
93+
},
94+
err => {this.rbac = err});
95+
}
96+
else
97+
return Promise.resolve();
98+
}
99+
100+
75101
decodePoolsDefault(poolDefault) {
76102
let This = this;
77103
// figure out compat version
@@ -122,6 +148,9 @@ class QwMetadataService {
122148

123149
...this.bucketList.map(bucketName => 'cluster.collection[' + bucketName + ':.:.].n1ql.udf!manage'),
124150
...this.bucketList.map(bucketName => 'cluster.collection[' + bucketName + ':.:.].n1ql.udf_external!manage'),
151+
152+
// permissions needed for reading scopes & collections
153+
...this.bucketList.map(bucketName => 'cluster.collection[' + bucketName + ':.:.].collections!read'),
125154
];
126155

127156
let promise = this.qwHttp.post('/pools/default/checkPermissions',perms.join(','))
@@ -133,7 +162,7 @@ class QwMetadataService {
133162

134163

135164
// decode permissions from the array of 'name':<bool> to a tree
136-
decodePermissions(permissions) {
165+
decodePermissions(permissions, skipIndexes) {
137166
//console.log("Got permissions: " + JSON.stringify(permissions,null,2));
138167

139168
this.rbac.init = true;
@@ -172,10 +201,12 @@ class QwMetadataService {
172201

173202
// get index info, if possible. If query nodes exist, use a query,
174203
// which works even if /indexStatus API is forbidden
175-
if (this.queryNodes.length > 0)
176-
return this.getIndexesN1QL();
177-
else
178-
return this.getIndexesREST();
204+
if (!skipIndexes) {
205+
if (this.queryNodes.length > 0)
206+
return this.getIndexesN1QL();
207+
else
208+
return this.getIndexesREST();
209+
}
179210
}
180211

181212
// do we have enough permissions to run queries?

query-ui/ui-current/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ angular
4242
state: 'app.admin.docs.editor',
4343
includedByState: 'app.admin.docs',
4444
plugIn: 'workbenchTab',
45-
ngShow: "rbac.cluster.collection['.:.:.'].data.docs.read",
45+
ngShow: "rbac.cluster.collection['.:.:.'].data.docs.read || rbac.cluster.collection['.:.:.'].data.docs.upsert",
4646
index: 0
4747
});
4848

0 commit comments

Comments
 (0)