diff --git a/ui/src/authorizations/utils/permissions.test.ts b/ui/src/authorizations/utils/permissions.test.ts new file mode 100644 index 0000000000..f352c904a9 --- /dev/null +++ b/ui/src/authorizations/utils/permissions.test.ts @@ -0,0 +1,122 @@ +import {Authorization} from '@influxdata/influx' + +import {filterIrrelevantAuths} from './permissions' + +describe('filterIrrelevantAuths', () => { + test('can find relevant tokens for a bucket', () => { + const allAccessToken = { + id: '03f4f29fd0011002', + token: 'a', + status: 'active', + description: 'all access token', + orgID: '03f4f29fd0011000', + org: "Chris' Org", + userID: '03f4f29fbd811000', + user: 'chris', + permissions: [ + { + action: 'read', + resource: { + type: 'buckets', + }, + }, + { + action: 'write', + resource: { + type: 'buckets', + }, + }, + { + action: 'read', + resource: { + type: 'dashboards', + }, + }, + ], + } as Authorization + + const readAAndBBucketsToken = { + id: '043f77872cad3000', + token: 'b', + status: 'active', + description: 'scoped read token (to A and B)', + orgID: '03f4f29fd0011000', + org: "Chris' Org", + userID: '03f4f29fbd811000', + user: 'chris', + permissions: [ + { + action: 'read', + resource: { + type: 'buckets', + id: '03f8d8d34d7e5000', + orgID: '03f4f29fd0011000', + name: 'A', + org: "Chris' Org", + }, + }, + { + action: 'read', + resource: { + type: 'buckets', + id: '043e71cf1922d000', + orgID: '03f4f29fd0011000', + name: 'B', + org: "Chris' Org", + }, + }, + ], + } as Authorization + + const writeCBucketToken = { + id: '043f7799e2ad3000', + token: 'c', + status: 'active', + description: 'scoped write token (to C)', + orgID: '03f4f29fd0011000', + org: "Chris' Org", + userID: '03f4f29fbd811000', + user: 'chris', + permissions: [ + { + action: 'write', + resource: { + type: 'buckets', + id: '03f4f29fd0011001', + orgID: '03f4f29fd0011000', + name: 'C', + org: "Chris' Org", + }, + }, + ], + } as Authorization + + const tokens = [allAccessToken, readAAndBBucketsToken, writeCBucketToken] + + expect(filterIrrelevantAuths(tokens, 'read', ['A', 'B', 'C'])).toEqual([ + allAccessToken, + ]) + + expect(filterIrrelevantAuths(tokens, 'read', ['B', 'C'])).toEqual([ + allAccessToken, + ]) + + expect(filterIrrelevantAuths(tokens, 'read', ['A', 'B'])).toEqual([ + allAccessToken, + readAAndBBucketsToken, + ]) + + expect(filterIrrelevantAuths(tokens, 'read', ['C'])).toEqual([ + allAccessToken, + ]) + + expect(filterIrrelevantAuths(tokens, 'write', ['C'])).toEqual([ + allAccessToken, + writeCBucketToken, + ]) + + expect(filterIrrelevantAuths(tokens, 'write', ['A'])).toEqual([ + allAccessToken, + ]) + }) +}) diff --git a/ui/src/authorizations/utils/permissions.ts b/ui/src/authorizations/utils/permissions.ts index cc9c94a71e..ad2d88b4b2 100644 --- a/ui/src/authorizations/utils/permissions.ts +++ b/ui/src/authorizations/utils/permissions.ts @@ -160,18 +160,21 @@ export enum BucketTab { /* Given a list of authorizations, return only those that allow performing the - supplied `action` to the supplied `bucketName`. + supplied `action` to all of the supplied `bucketNames`. */ export const filterIrrelevantAuths = ( auths: Authorization[], action: 'read' | 'write', - bucketName: string -): Authorization[] => - auths.filter(auth => - auth.permissions.some( - permission => - permission.action === action && - permission.resource.type === 'buckets' && - (!permission.resource.name || permission.resource.name === bucketName) + bucketNames: string[] +): Authorization[] => { + return auths.filter(auth => + bucketNames.every(bucketName => + auth.permissions.some( + permission => + permission.action === action && + permission.resource.type === 'buckets' && + (!permission.resource.name || permission.resource.name === bucketName) + ) ) ) +} diff --git a/ui/src/dataExplorer/components/SaveAsTaskForm.tsx b/ui/src/dataExplorer/components/SaveAsTaskForm.tsx index d2015ea267..c68219da8c 100644 --- a/ui/src/dataExplorer/components/SaveAsTaskForm.tsx +++ b/ui/src/dataExplorer/components/SaveAsTaskForm.tsx @@ -19,6 +19,7 @@ import {getAuthorizations} from 'src/authorizations/actions' // Utils import {filterIrrelevantAuths} from 'src/authorizations/utils/permissions' +import {getReadBuckets} from 'src/shared/utils/getReadBuckets' import {getActiveTimeMachine, getActiveQuery} from 'src/timeMachine/selectors' import {getTimeRangeVars} from 'src/variables/utils/getTimeRangeVars' import {getWindowVars} from 'src/variables/utils/getWindowVars' @@ -121,26 +122,26 @@ class SaveAsTaskForm extends PureComponent { taskOptions: {toBucketName}, } = this.props - const readAuths = filterIrrelevantAuths(tokens, 'read', this.readBucketName) - const writeAuths = filterIrrelevantAuths(tokens, 'write', toBucketName) - const relevantAuthorizations = intersectionBy(readAuths, writeAuths, 'id') + const readAuths = filterIrrelevantAuths( + tokens, + 'read', + this.readBucketNames + ) - return relevantAuthorizations + const writeAuths = filterIrrelevantAuths(tokens, 'write', [toBucketName]) + const relevantAuths = intersectionBy(readAuths, writeAuths, 'id') + + return relevantAuths } - private get readBucketName() { + private get readBucketNames(): string[] { const {activeQuery} = this.props if (activeQuery.editMode === 'builder') { - return activeQuery.builderConfig.buckets[0] || '' + return activeQuery.builderConfig.buckets } - const text = activeQuery.text - const splitBucket = text.split('bucket:') - const splitQuotes = splitBucket[1].split('"') - const readBucketName = splitQuotes[1] - - return readBucketName + return getReadBuckets(this.activeScript) } private get isFormValid(): boolean { diff --git a/ui/src/shared/utils/ast.ts b/ui/src/shared/utils/ast.ts new file mode 100644 index 0000000000..5aac0261bc --- /dev/null +++ b/ui/src/shared/utils/ast.ts @@ -0,0 +1,32 @@ +// Libraries +import {isObject, isArray} from 'lodash' + +// Types +import {Node} from 'src/types' + +/* + Find all nodes in a tree matching the `predicate` function. Each node in the + tree is an object, which may contain objects or arrays of objects as children + under any key. +*/ +export const findNodes = ( + node: any, + predicate: (node: Node) => boolean, + acc: any[] = [] +) => { + if (predicate(node)) { + acc.push(node) + } + + for (const value of Object.values(node)) { + if (isObject(value)) { + findNodes(value, predicate, acc) + } else if (isArray(value)) { + for (const innerValue of value) { + findNodes(innerValue, predicate, acc) + } + } + } + + return acc +} diff --git a/ui/src/shared/utils/getMinDurationFromAST.ts b/ui/src/shared/utils/getMinDurationFromAST.ts index 9e12870e02..973fb88928 100644 --- a/ui/src/shared/utils/getMinDurationFromAST.ts +++ b/ui/src/shared/utils/getMinDurationFromAST.ts @@ -1,5 +1,8 @@ // Libraries -import {get, isObject, isArray} from 'lodash' +import {get} from 'lodash' + +// Utils +import {findNodes} from 'src/shared/utils/ast' // Types import { @@ -12,7 +15,7 @@ import { ObjectExpression, DateTimeLiteral, DurationLiteral, -} from 'src/types/ast' +} from 'src/types' export function getMinDurationFromAST(ast: Package): number { // We can't take the minimum of durations of each range individually, since @@ -41,7 +44,7 @@ export function getMinDurationFromAST(ast: Package): number { } function allRangeTimes(ast: any): Array<[number, number]> { - return findNodes(isRangeNode, ast).map(node => rangeTimes(ast, node)) + return findNodes(ast, isRangeNode).map(node => rangeTimes(ast, node)) } /* @@ -138,7 +141,7 @@ function lookupVariable(ast: any, name: string): Expression { ) } - const declarator = findNodes(isDeclarator, ast) + const declarator = findNodes(ast, isDeclarator) if (!declarator.length) { throw new Error(`unable to lookup variable "${name}"`) @@ -160,30 +163,3 @@ function isRangeNode(node: Node) { get(node, 'callee.name') === 'range' ) } - -/* - Find all nodes in a tree matching the `predicate` function. Each node in the - tree is an object, which may contain objects or arrays of objects as children - under any key. -*/ -function findNodes( - predicate: (node: Node) => boolean, - node: any, - acc: any[] = [] -) { - if (predicate(node)) { - acc.push(node) - } - - for (const value of Object.values(node)) { - if (isObject(value)) { - findNodes(predicate, value, acc) - } else if (isArray(value)) { - for (const innerValue of value) { - findNodes(predicate, innerValue, acc) - } - } - } - - return acc -} diff --git a/ui/src/shared/utils/getReadBuckets.test.ts b/ui/src/shared/utils/getReadBuckets.test.ts new file mode 100644 index 0000000000..f7472d223c --- /dev/null +++ b/ui/src/shared/utils/getReadBuckets.test.ts @@ -0,0 +1,29 @@ +import {getReadBuckets} from './getReadBuckets' + +// These tests are skipped until we can use WASM modules in Jest +describe.skip('getReadBuckets', () => { + test('handles an empty script', () => { + expect(getReadBuckets('')).toEqual([]) + }) + + test('can find buckets read from in a Flux query', () => { + const script = ` +from(bucket:"foo") |> limit(limit:100, offset:10) + + |> filter(fn: (r) => r.foo and r.bar or r.buz) from + + (bucket: + + "bar" +) + +|> foo() +|> fromSnozzles(bucket: "moo") +|> moo() + +|> to( bucket: "baz" ) +`.trim() + + expect(getReadBuckets(script)).toEqual(['foo', 'bar']) + }) +}) diff --git a/ui/src/shared/utils/getReadBuckets.ts b/ui/src/shared/utils/getReadBuckets.ts new file mode 100644 index 0000000000..1c4b118b9e --- /dev/null +++ b/ui/src/shared/utils/getReadBuckets.ts @@ -0,0 +1,54 @@ +// Libraries +import {parse} from '@influxdata/flux-parser' +import {get, flatMap} from 'lodash' + +// Utils +import {findNodes} from 'src/shared/utils/ast' + +// Types +import { + File, + CallExpression, + Property, + StringLiteral, + Identifier, +} from 'src/types' + +/* + Given a Flux script, return a list of names of buckets that are read from in + the script. + + For now, this means detecting each time something like + + from(bucket: "foo") + + appears in the script. +*/ +export const getReadBuckets = (text: string): string[] => { + try { + const ast: File = parse(text) + + // Find every `from(bucket: "foo")` call in the script + const fromCalls: CallExpression[] = findNodes( + ast, + n => n.type === 'CallExpression' && get(n, 'callee.name') === 'from' + ) + + // Extract the `bucket: "foo"` part from each call + const bucketProperties: Property[] = flatMap(fromCalls, call => + findNodes( + call, + n => n.type === 'Property' && (n.key as Identifier).name === 'bucket' + ) + ) + + // Extract the `foo` from each object property + const bucketNames = bucketProperties.map( + prop => (prop.value as StringLiteral).value + ) + + return bucketNames + } catch (e) { + console.error('Failed to find buckets read in flux script', e) + } +}