/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ // @ts-check import { computed, get } from '@ember/object'; import { or } from '@ember/object/computed'; import AbstractAbility from './abstract'; import doesMatchPattern from 'nomad-ui/utils/match-glob'; const WILDCARD_GLOB = '*'; const WILDCARD_PATTERN = '/'; const GLOBAL_FLAG = 'g'; const WILDCARD_PATTERN_REGEX = new RegExp(WILDCARD_PATTERN, GLOBAL_FLAG); export default class Variable extends AbstractAbility { // Pass in a namespace to `can` or `cannot` calls to override // https://github.com/minutebase/ember-can#additional-attributes path = '*'; get _path() { if (!this.path) return '*'; return this.path; } @or( 'bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportVariableList' ) canList; @or( 'bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportVariableWriting' ) canWrite; @or( 'bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportVariableDestroy' ) canDestroy; @or( 'bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportVariableRead' ) canRead; @computed('token.selfTokenPolicies') get policiesSupportVariableList() { return this.policyNamespacesIncludeVariablesCapabilities( this.token.selfTokenPolicies, ['list', 'read', 'write', 'destroy'] ); } @computed('path', 'allPaths') get policiesSupportVariableRead() { const matchingPath = this._nearestMatchingPath(this.path); return this.allPaths .find((path) => path.name === matchingPath) ?.capabilities?.includes('read'); } /** * * Map to your policy's namespaces, * and each of their Variables blocks' paths, * and each of their capabilities. * Then, check to see if any of the permissions you're looking for * are contained within at least one of them. * * @param {Object} policies * @param {string[]} capabilities * @returns {boolean} */ policyNamespacesIncludeVariablesCapabilities( policies = [], capabilities = [], path ) { const namespacesWithVariableCapabilities = policies .toArray() .filter((policy) => get(policy, 'rulesJSON.Namespaces')) .map((policy) => get(policy, 'rulesJSON.Namespaces')) .flat() .map((namespace = {}) => { return namespace.Variables?.Paths; }) .flat() .compact() .filter((varsBlock = {}) => { if (!path || path === WILDCARD_GLOB) { return true; } else { return varsBlock.PathSpec === path; } }) .map((varsBlock = {}) => { return varsBlock.Capabilities; }) .flat() .compact(); // Check for requested permissions return namespacesWithVariableCapabilities.some((abilityList) => { return capabilities.includes(abilityList); }); } @computed('allPaths', 'namespace', 'path', 'token.selfTokenPolicies') get policiesSupportVariableWriting() { if (this.namespace === WILDCARD_GLOB && this.path === WILDCARD_GLOB) { // If you're checking if you can write from root, and you don't specify a namespace, // Then if you can write in ANY path in ANY namespace, you can get to /new. return this.policyNamespacesIncludeVariablesCapabilities( this.token.selfTokenPolicies, ['write'], this._nearestMatchingPath(this.path) ); } else { // Checking a specific path in a specific namespace. // TODO: This doesn't cover the case when you're checking for the * namespace at a specific path. // Right now we require you to specify yournamespace to enable the button. const matchingPath = this._nearestMatchingPath(this.path); return this.allPaths .find((path) => path.name === matchingPath) ?.capabilities?.includes('write'); } } @computed('path', 'allPaths') get policiesSupportVariableDestroy() { const matchingPath = this._nearestMatchingPath(this.path); return this.allPaths .find((path) => path.name === matchingPath) ?.capabilities?.includes('destroy'); } @computed('token.selfTokenPolicies.[]', 'namespace') get allPaths() { return (get(this, 'token.selfTokenPolicies') || []) .toArray() .reduce((paths, policy) => { const namespaces = get(policy, 'rulesJSON.Namespaces'); const matchingNamespace = this._nearestMatchingNamespace( namespaces, this.namespace ); const variables = (namespaces || []).find( (namespace) => namespace.Name === matchingNamespace )?.Variables; const pathNames = variables?.Paths?.map((path) => ({ name: path.PathSpec, capabilities: path.Capabilities, })); if (pathNames) { paths = [...paths, ...pathNames]; } return paths; }, []); } _nearestMatchingNamespace(policyNamespaces, namespace) { if (!namespace || !policyNamespaces) return 'default'; return this._findMatchingNamespace(policyNamespaces, namespace); } _formatMatchingPathRegEx(path, wildCardPlacement = 'end') { const replacer = () => '\\/'; if (wildCardPlacement === 'end') { const trimmedPath = path.slice(0, path.length - 1); const pattern = trimmedPath.replace(WILDCARD_PATTERN_REGEX, replacer); return pattern; } else { const trimmedPath = path.slice(1, path.length); const pattern = trimmedPath.replace(WILDCARD_PATTERN_REGEX, replacer); return pattern; } } _computeAllMatchingPaths(pathNames, path) { const matches = []; for (const pathName of pathNames) { if (doesMatchPattern(pathName, path)) matches.push(pathName); } return matches; } _nearestMatchingPath(path) { const pathNames = this.allPaths.map((path) => path.name); if (pathNames.includes(path)) { return path; } const allMatchingPaths = this._computeAllMatchingPaths(pathNames, path); if (!allMatchingPaths.length) return WILDCARD_GLOB; return this._smallestDifference(allMatchingPaths, path); } _computeLengthDiff(pattern, path) { const countGlobsInPattern = pattern ?.split('') .filter((el) => el === WILDCARD_GLOB).length; return path?.length - pattern?.length + countGlobsInPattern; } _smallestDifference(matches, path) { const sortingCallBack = (patternA, patternB) => this._computeLengthDiff(patternA, path) - this._computeLengthDiff(patternB, path); const sortedMatches = matches?.sort(sortingCallBack); const isTie = this._computeLengthDiff(sortedMatches[0], path) === this._computeLengthDiff(sortedMatches[1], path); const doesFirstMatchHaveLeadingGlob = sortedMatches[0][0] === WILDCARD_GLOB; return isTie && doesFirstMatchHaveLeadingGlob ? sortedMatches[1] : sortedMatches[0]; } }