Skip to content

Commit

Permalink
ui: create variable permission logic (#13447)
Browse files Browse the repository at this point in the history
* ui:  inject router service into Variable ability to compute path

* ui:  test create secure variable ability

* refact:  update templates to properly check create ability

* chore:  update token factory to enable 1 path to have create ability

* refact:  remove router service injection for path variable

* refact:  update mirage factory for edit and delete perms on  path for testing

* ui:  handle path matching (#13474)

* test:  write specifications for nearestPath computation

* ui:  write logic for getting all paths

* ui:  nearestPathMatching algorithm

* test:  nearestPathMatching algorithm test

* ui:  handle namespace filtering for capabilities check (#13475)

* ui: add namespace handling

* refact:  add logical OR operator to handle unstructured  object.

* ui:  acceptance test for create flow in secure variables (#13500)

* test:  write happy path test for creating variable

* refact:  add missing data-test attributes

* test:  sad path for disabled button

* fix:  move comment in  file

* test:  acceptance test for editing a variable (#13529)

* refact:  add data-test variable

* test:  happy path and sad path for edit flow

* refact:  update test language to say disabled

* ui:  glob matching algorithm (#13533)

* ui: compute length difference (#13542)

* ui: compute length difference

* refact:  use glob matching and sorting algos in `nearestMatchingPath` (#13544)

* refact:  use const in compute

* ui:  smallest difference logic

* refact:  use glob matching and sorting algo in _nearestPathPath helper

* ui:  add can edit to variable capabilities (#13545)

* ui:  create edit capabilities getter

* ui:  add ember-can check for edit button

* refact:  update test to mock edit capabilities in policy

* fix:  remove unused var

* Edit capabilities for variables depend on Create

Co-authored-by: Phil Renaud <phil@riotindustries.com>

Co-authored-by: Phil Renaud <phil@riotindustries.com>

Co-authored-by: Phil Renaud <phil@riotindustries.com>

* refact:  update token factory (#13596)

* refact:  update rulesJSON in token factory to reflect schema update

* refact:  update capability names (#13597)

* refact:  update rules to match rulesJSON

* refact:  update create to write

* ui:  add `canDestroy` permissions (#13598)

* refact:  update rulesJSON in token factory to reflect schema update

* refact:  update rules to match rulesJSON

* refact:  update create to write

* ui:  add canDestroy capability

* test:  unit test for canDestroy

* ui:  add permission check to template

* test:  acceptance test for delete flow

* refact:  update test to use correct capability name

* refact:  update tests to reflect rulesJSON schema change

* ui:  update path matching logic to account for schema change (#13605)

* refact:  update path matching logic

* refact:  update tests to reflect rulesJSON change

Co-authored-by: Phil Renaud <phil@riotindustries.com>

Co-authored-by: Phil Renaud <phil@riotindustries.com>
  • Loading branch information
ChaiWithJai and philrenaud authored Jul 6, 2022
1 parent 50b75b6 commit cd13aad
Show file tree
Hide file tree
Showing 9 changed files with 1,241 additions and 93 deletions.
174 changes: 163 additions & 11 deletions ui/app/abilities/variable.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import AbstractAbility from './abstract';
import { computed, get } from '@ember/object';
import { or } from '@ember/object/computed';
import AbstractAbility from './abstract';

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;
}

export default class extends AbstractAbility {
@or(
'bypassAuthorization',
'selfTokenIsManagement',
Expand All @@ -13,9 +27,16 @@ export default class extends AbstractAbility {
@or(
'bypassAuthorization',
'selfTokenIsManagement',
'policiesSupportVariableCreation'
'policiesSupportVariableWriting'
)
canCreate;
canWrite;

@or(
'bypassAuthorization',
'selfTokenIsManagement',
'policiesSupportVariableDestroy'
)
canDestroy;

@computed('rulesForNamespace.@each.capabilities')
get policiesSupportVariableView() {
Expand All @@ -24,12 +45,143 @@ export default class extends AbstractAbility {
});
}

@computed('rulesForNamespace.@each.capabilities') // TODO: edit computed property to be SecureVariables.Path "DYNAMIC PATH"
get policiesSupportVariableCreation() {
return this.rulesForNamespace.some((rules) => {
const keyName = `SecureVariables.Path "*".Capabilities`; // TODO: add ability to edit path, however computed properties can't take parameters
const capabilities = get(rules, keyName) || [];
return capabilities.includes('create');
});
@computed('path', 'allPaths')
get policiesSupportVariableWriting() {
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 matchingNamespace = this._findMatchingNamespace(
get(policy, 'rulesJSON.Namespaces') || [],
this._namespace
);

const variables = (get(policy, 'rulesJSON.Namespaces') || []).find(
(namespace) => namespace.Name === matchingNamespace
)?.SecureVariables;

const pathNames = variables?.Paths?.map((path) => ({
name: path.PathSpec,
capabilities: path.Capabilities,
}));

if (pathNames) {
paths = [...paths, ...pathNames];
}

return paths;
}, []);
}

_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 (this._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);
}

_doesMatchPattern(pattern, path) {
const parts = pattern?.split(WILDCARD_GLOB);
const hasLeadingGlob = pattern?.startsWith(WILDCARD_GLOB);
const hasTrailingGlob = pattern?.endsWith(WILDCARD_GLOB);
const lastPartOfPattern = parts[parts.length - 1];
const isPatternWithoutGlob = parts.length === 1 && !hasLeadingGlob;

if (!pattern || !path || isPatternWithoutGlob) {
return pattern === path;
}

if (pattern === WILDCARD_GLOB) {
return true;
}

let subPathToMatchOn = path;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const idx = subPathToMatchOn?.indexOf(part);
const doesPathIncludeSubPattern = idx > -1;
const doesMatchOnFirstSubpattern = idx === 0;

if (i === 0 && !hasLeadingGlob && !doesMatchOnFirstSubpattern) {
return false;
}

if (!doesPathIncludeSubPattern) {
return false;
}

subPathToMatchOn = subPathToMatchOn.slice(0, idx + path.length);
}

return hasTrailingGlob || path.endsWith(lastPartOfPattern);
}

_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];
}
}
3 changes: 3 additions & 0 deletions ui/app/components/secure-variable-form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
disabled={{not @model.isNew}}
{{on "input" this.validatePath}}
{{autofocus}}
data-test-path-input
/>
</label>
{{#if this.duplicatePathWarning}}
Expand Down Expand Up @@ -57,6 +58,7 @@
Key
</span>
<Input
data-test-var-key
@type="text"
@value={{entry.key}}
class="input"
Expand Down Expand Up @@ -96,6 +98,7 @@
disabled={{this.shouldDisableSave}}
class="button is-primary"
type="submit"
data-test-submit-var
>
Save
{{pluralize "Variable" @this.keyValues.length}}
Expand Down
3 changes: 2 additions & 1 deletion ui/app/components/secure-variable-form/input-group.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
@type={{this.inputType}}
@value={{@entry.value}}
class="input value-input"
autocomplete="new-password"
{{! prevent auto-fill }}
autocomplete="new-password"
data-test-var-value
/>
<button
class="show-hide-values button is-light"
Expand Down
5 changes: 3 additions & 2 deletions ui/app/templates/variables/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
</div>
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
{{#if (can "create variable" namespace=this.qpNamespace)}}
{{#if (can "write variable" path="*")}}
<LinkTo
@route="variables.new"
@query={{hash namespace=this.qpNamespace}}
class="button is-primary"
data-test-create-var
>
Create Secure Variable
</LinkTo>
Expand All @@ -26,6 +26,7 @@
aria-label="You don’t have sufficient permissions"
disabled
type="button"
data-test-disabled-create-var
>
Create Secure Variable
</button>
Expand Down
5 changes: 2 additions & 3 deletions ui/app/templates/variables/path.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
<div class="toolbar">
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
{{!-- TODO: make sure qpNamespace persists to here --}}
{{#if (can "create variable" namespace=this.qpNamespace)}}
{{#if (can "write variable" path=this.model.absolutePath)}}
<LinkTo
@route="variables.new"
@query={{hash namespace=this.qpNamespace path=(concat this.model.absolutePath "/")}}
@query={{hash path=(concat this.model.absolutePath "/")}}
class="button is-primary"
>
Create Secure Variable
Expand Down
33 changes: 19 additions & 14 deletions ui/app/templates/variables/variable/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
</div>
<div>
{{#unless this.isDeleting}}
{{#if (can "write variable" path=this.model.absolutePath)}}
<div class="two-step-button">
<LinkTo
data-test-edit-button
class="button is-info is-inverted is-small"
@model={{this.model}}
@route="variables.variable.edit"
Expand All @@ -46,19 +48,22 @@
Edit
</LinkTo>
</div>
{{/if}}
{{/unless}}
<TwoStepButton
data-test-delete-button
@alignRight={{true}}
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure you want to delete this variable and all its items?"
@awaitingConfirmation={{this.deleteVariableFile.isRunning}}
@onConfirm={{perform this.deleteVariableFile}}
@onPrompt={{this.onDeletePrompt}}
@onCancel={{this.onDeleteCancel}}
/>
{{#if (can "destroy variable" path=this.model.absolutePath)}}
<TwoStepButton
data-test-delete-button
@alignRight={{true}}
@idleText="Delete"
@cancelText="Cancel"
@confirmText="Yes, delete"
@confirmationMessage="Are you sure you want to delete this variable and all its items?"
@awaitingConfirmation={{this.deleteVariableFile.isRunning}}
@onConfirm={{perform this.deleteVariableFile}}
@onPrompt={{this.onDeletePrompt}}
@onCancel={{this.onDeleteCancel}}
/>
{{/if}}
</div>
</h1>
{{#if (eq this.view "json")}}
Expand All @@ -79,7 +84,7 @@
{{else}}
<ListTable data-test-eval-table @source={{this.model.keyValues}} as |t|>
<t.body as |row|>
<tr>
<tr data-test-var={{row.model.key}}>
<td>
{{row.model.key}}
</td>
Expand All @@ -89,4 +94,4 @@
</tr>
</t.body>
</ListTable>
{{/if}}
{{/if}}
30 changes: 26 additions & 4 deletions ui/mirage/factories/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,19 @@ namespace "default" {
policy = "read"
capabilities = ["list-jobs", "alloc-exec", "read-logs"]
secure_variables {
# full access to secrets in all project paths
path "blue/*" {
capabilities = ["write", "read", "destroy", "list"]
}
# full access to secrets in all project paths
path "*" {
capabilities = ["list"]
capabilities = ["write", "read", "destroy", "list"]
}
# read/list access within a "system" path belonging to administrators
path "system/*" {
capabilities = ["read", "list"]
}
}
}
Expand All @@ -55,9 +66,20 @@ node {
Name: 'default',
Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
SecureVariables: {
'Path "*"': {
Capabilities: ['list', 'create'],
},
Paths: [
{
Capabilities: ['write', 'read', 'destroy', 'list'],
PathSpec: 'blue/*',
},
{
Capabilities: ['write', 'read', 'destroy', 'list'],
PathSpec: '*',
},
{
Capabilities: ['read', 'list'],
PathSpec: 'system/*',
},
],
},
},
],
Expand Down
Loading

0 comments on commit cd13aad

Please sign in to comment.