Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ui] Modify variable access permissions for UI users with write in only certain namespaces #24073

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/24073.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:bug
ui: Fixes an issue where variables paths would not let namespaced users write variables unless they also had wildcard namespace variable write permissions
```
167 changes: 116 additions & 51 deletions ui/app/abilities/variable.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,68 @@ export default class Variable extends AbstractAbility {
);
}

@computed('path', 'allPaths')
/**
* Check if the user has read access to a specific path in a specific namespace.
* @returns {boolean}
*/
@computed(
'allVariablePathRules',
'namespace',
'path',
'token.selfTokenPolicies'
)
get policiesSupportVariableRead() {
const matchingPath = this._nearestMatchingPath(this.path);
return this.allPaths
.find((path) => path.name === matchingPath)
?.capabilities?.includes('read');
if (this.namespace === WILDCARD_GLOB) {
return this.policyNamespacesIncludeVariablesCapabilities(
this.token.selfTokenPolicies,
['read'],
matchingPath
);
} else {
return this.allVariablePathRules.some((rule) => {
const ruleMatchingPath = this._nearestMatchingPath(rule.name);
return (
(rule.namespace === WILDCARD_GLOB ||
rule.namespace === this.namespace) &&
(ruleMatchingPath === WILDCARD_GLOB ||
ruleMatchingPath === matchingPath) &&
rule.capabilities.includes('read')
);
});
}
}

/**
* Check if the user has destroy access to a specific path in a specific namespace.
* @returns {boolean}
*/
@computed(
'allVariablePathRules',
'namespace',
'path',
'token.selfTokenPolicies'
)
get policiesSupportVariableDestroy() {
const matchingPath = this._nearestMatchingPath(this.path);
if (this.namespace === WILDCARD_GLOB) {
return this.policyNamespacesIncludeVariablesCapabilities(
this.token.selfTokenPolicies,
['destroy'],
matchingPath
);
} else {
return this.allVariablePathRules.some((rule) => {
const ruleMatchingPath = this._nearestMatchingPath(rule.name);
return (
(rule.namespace === WILDCARD_GLOB ||
rule.namespace === this.namespace) &&
(ruleMatchingPath === WILDCARD_GLOB ||
ruleMatchingPath === matchingPath) &&
rule.capabilities.includes('destroy')
);
});
}
}

/**
Expand All @@ -85,7 +141,7 @@ export default class Variable extends AbstractAbility {
capabilities = [],
path
) {
const namespacesWithVariableCapabilities = policies
const variableCapabilitiesAmongNamespaces = policies
.toArray()
.filter((policy) => get(policy, 'rulesJSON.Namespaces'))
.map((policy) => get(policy, 'rulesJSON.Namespaces'))
Expand All @@ -109,66 +165,75 @@ export default class Variable extends AbstractAbility {
.compact();

// Check for requested permissions
return namespacesWithVariableCapabilities.some((abilityList) => {
return capabilities.includes(abilityList);
return variableCapabilitiesAmongNamespaces.some((abilityList) => {
['write', 'read', 'destroy'];
return capabilities.includes(abilityList); // at least one of the capabilities is included in the list
});
}

@computed('allPaths', 'namespace', 'path', 'token.selfTokenPolicies')
/**
* Check if the user has write access to a specific path in a specific namespace.
* @returns {boolean}
*/
@computed(
'allVariablePathRules',
'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.
const matchingPath = this._nearestMatchingPath(this.path);
if (this.namespace === WILDCARD_GLOB) {
// Check policyNamespacesIncludeVariablesCapabilities, which is namespace-agnostic.
return this.policyNamespacesIncludeVariablesCapabilities(
this.token.selfTokenPolicies,
['write'],
this._nearestMatchingPath(this.path)
matchingPath
);
} 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');
// If the namespace is not wildcarded, then we dig into rules by namespace.
return this.allVariablePathRules.some((rule) => {
const ruleMatchingPath = this._nearestMatchingPath(rule.name);
return (
(rule.namespace === WILDCARD_GLOB ||
rule.namespace === this.namespace) &&
(ruleMatchingPath === WILDCARD_GLOB ||
ruleMatchingPath === matchingPath) &&
rule.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');
}

/**
* Generate a list of all the path rules for all the policies
* that the user has access to.
* {
* namespace: string,
* name: string,
* capabilities: string[],
* }
* @returns {Array}
*/
@computed('token.selfTokenPolicies.[]', 'namespace')
get allPaths() {
get allVariablePathRules() {
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;
}, []);
.flatMap((policy) => {
const namespaces = get(policy, 'rulesJSON.Namespaces') || [];

return namespaces.flatMap((namespace) => {
const variables = namespace.Variables;
const pathNames =
variables?.Paths?.map((path) => ({
namespace: namespace.Name,
name: path.PathSpec,
capabilities: path.Capabilities,
})) || [];

return pathNames;
});
});
}

_nearestMatchingNamespace(policyNamespaces, namespace) {
Expand Down Expand Up @@ -201,7 +266,7 @@ export default class Variable extends AbstractAbility {
}

_nearestMatchingPath(path) {
const pathNames = this.allPaths.map((path) => path.name);
const pathNames = this.allVariablePathRules.map((path) => path.name);
if (pathNames.includes(path)) {
return path;
}
Expand Down
2 changes: 1 addition & 1 deletion ui/app/templates/variables/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
</Hds::Dropdown>
{{/if}}

{{#if (can "write variable" path="*" namespace=this.namespaceSelection)}}
{{#if (can "write variable" path="*" namespace="*")}}
<div
{{keyboard-shortcut
pattern=(array "n" "v")
Expand Down
38 changes: 33 additions & 5 deletions ui/tests/unit/abilities/variable-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -807,21 +807,27 @@ module('Unit | Ability | variable', function (hooks) {
this.owner.register('service:token', mockToken);
this.ability.namespace = 'bar';

const allPaths = this.ability.allPaths;
const allPaths = this.ability.allVariablePathRules;

assert.deepEqual(
allPaths,
[
{
capabilities: ['write'],
name: 'foo',
namespace: 'default',
},
{
capabilities: ['read', 'write'],
name: 'foo',
namespace: 'bar',
},
],
'It should return the exact path match.'
);
});

test('it matches on default if no namespace is selected', function (assert) {
test('it matches if no namespace is selected', function (assert) {
const mockToken = Service.extend({
aclEnabled: true,
selfToken: { type: 'client' },
Expand Down Expand Up @@ -854,17 +860,23 @@ module('Unit | Ability | variable', function (hooks) {
this.owner.register('service:token', mockToken);
this.ability.namespace = undefined;

const allPaths = this.ability.allPaths;
const allPaths = this.ability.allVariablePathRules;

assert.deepEqual(
allPaths,
[
{
capabilities: ['write'],
name: 'foo',
namespace: 'default',
},
{
capabilities: ['read', 'write'],
name: 'foo',
namespace: 'bar',
},
],
'It should return the exact path match.'
'It should return both matches separated by namespace.'
);
});

Expand Down Expand Up @@ -925,14 +937,30 @@ module('Unit | Ability | variable', function (hooks) {
this.owner.register('service:token', mockToken);
this.ability.namespace = 'pablo';

const allPaths = this.ability.allPaths;
const allPaths = this.ability.allVariablePathRules;

assert.deepEqual(
allPaths,
[
{
capabilities: ['list'],
name: '*',
namespace: '*',
},
{
capabilities: ['list', 'read', 'destroy', 'create'],
name: '*',
namespace: 'namespace-1',
},
{
capabilities: ['list', 'read', 'destroy', 'create'],
name: 'blue/*',
namespace: 'namespace-2',
},
{
capabilities: ['list', 'read', 'create'],
name: 'nomad/jobs/*',
namespace: 'namespace-2',
},
],
'It should return the glob matching namespace match.'
Expand Down
Loading