diff --git a/addons/api/addon/services/resource-filter-store.js b/addons/api/addon/services/resource-filter-store.js index 35582302b..c9ad01bcb 100644 --- a/addons/api/addon/services/resource-filter-store.js +++ b/addons/api/addon/services/resource-filter-store.js @@ -8,10 +8,11 @@ import { inject as service } from '@ember/service'; * @example * const filter = new ResourceFilter({ * type: 'oidc', - * status: ['active', 'pending'] + * status: ['active', 'pending'], + * authorized_actions: [{ contains: 'read' }] * }); * log(filter.queryExpression); - * // ("/item/type" == "oidc") and ("/item/status" == "active" or "/item/status" == "pending") + * // ("/item/type" == "oidc") and ("/item/status" == "active" or "/item/status" == "pending") and ("read" in "/item/authorized_actions") */ export class ResourceFilter { // =attributes @@ -30,7 +31,14 @@ export class ResourceFilter { const value = this.#filterObject[key]; const valuesArray = Array.isArray(value) ? value : [value]; return this.parenthetical( - this.or(valuesArray.map((value) => this.equals(key, value))) + this.or( + valuesArray.map((value) => { + if (value && value.contains) { + return this.in(value.contains, key); + } + return this.equals(key, value); + }) + ) ); }) .flat() @@ -56,6 +64,15 @@ export class ResourceFilter { return value !== null ? `"/item/${key}" == "${value}"` : null; } + /** + * @param {string} key + * @param value + * @return {string} + */ + in(value, key) { + return value !== null ? `"${value}" in "/item/${key}"` : null; + } + /** * @param {string[]} clauses * @return {string} diff --git a/addons/api/mirage/config.js b/addons/api/mirage/config.js index 3ec44c437..b866dcae2 100644 --- a/addons/api/mirage/config.js +++ b/addons/api/mirage/config.js @@ -349,19 +349,22 @@ export default function () { this.get( '/targets', - function ({ targets }, { queryParams: { scope_id, recursive } }) { + function ({ targets }, { queryParams: { scope_id, recursive, filter } }) { + let resultSet; if (recursive && scope_id === 'global') { - return targets.all(); + resultSet = targets.all(); } else if (recursive) { - return targets.where((target) => { + resultSet = targets.where((target) => { const targetModel = targets.find(target.id); return ( target.scopeId === scope_id || targetModel?.scope?.scope?.id === scope_id ); }); + } else { + resultSet = targets.where((target) => target.scopeId === scope_id); } - return targets.where((target) => target.scopeId === scope_id); + return resultSet.filter(makeBooleanFilter(filter)); } ); this.post('/targets', function ({ targets }) { diff --git a/addons/api/tests/unit/services/resource-filter-store-test.js b/addons/api/tests/unit/services/resource-filter-store-test.js index 4902d8c5b..c748f6751 100644 --- a/addons/api/tests/unit/services/resource-filter-store-test.js +++ b/addons/api/tests/unit/services/resource-filter-store-test.js @@ -10,9 +10,10 @@ module('Unit | Service | resource-filter-store', function (hooks) { const filter = new ResourceFilter({ type: 'oidc', status: ['active', 'pending'], + authorized_actions: [{ contains: 'read' }], }); const expected = - '("/item/type" == "oidc") and ("/item/status" == "active" or "/item/status" == "pending")'; + '("/item/type" == "oidc") and ("/item/status" == "active" or "/item/status" == "pending") and ("read" in "/item/authorized_actions")'; assert.equal(filter.queryExpression, expected); }); }); diff --git a/ui/desktop/app/routes/scopes/scope/projects/sessions.js b/ui/desktop/app/routes/scopes/scope/projects/sessions.js index 506af23c0..b353fb8a7 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/sessions.js +++ b/ui/desktop/app/routes/scopes/scope/projects/sessions.js @@ -105,22 +105,22 @@ export default class ScopesScopeProjectsSessionsRoute extends Route { await this.ipc.invoke('stop', { session_id: session.id }); } - /** - * Sets the specified resource filter field to the specified value. - * @param {string} field - * @param value - */ - @action - filterBy(field, value) { - this[field] = value; - } + /** + * Sets the specified resource filter field to the specified value. + * @param {string} field + * @param value + */ + @action + filterBy(field, value) { + this[field] = value; + } - /** - * Clears and filter selections. - */ - @action - clearAllFilters() { - this.status = []; - this.project = []; - } + /** + * Clears and filter selections. + */ + @action + clearAllFilters() { + this.status = []; + this.project = []; + } } diff --git a/ui/desktop/app/routes/scopes/scope/projects/targets.js b/ui/desktop/app/routes/scopes/scope/projects/targets.js index 682ee0c25..2bed7e272 100644 --- a/ui/desktop/app/routes/scopes/scope/projects/targets.js +++ b/ui/desktop/app/routes/scopes/scope/projects/targets.js @@ -3,6 +3,7 @@ import { inject as service } from '@ember/service'; import { action } from '@ember/object'; import { task, timeout } from 'ember-concurrency'; import config from '../../../../config/environment'; +import { resourceFilter } from 'core/decorators/resource-filter'; const POLL_TIMEOUT_SECONDS = config.sessionPollingTimeoutSeconds; @@ -15,9 +16,16 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { @service origin; @service notify; @service confirm; + @service resourceFilterStore; // =attributes + @resourceFilter({ + allowed: (route) => route.modelFor('scopes.scope.projects'), + serialize: ({ id }) => id, + findBySerialized: ({ id }, value) => id === value + }) project; + /** * A simple Ember Concurrency-based polling task that refreshes the route * every POLL_TIMEOUT_SECONDS seconds. This is necessary to display changes @@ -52,13 +60,17 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { async model() { const { id: scope_id } = this.modelFor('scopes.scope'); const { user_id } = this.session.data.authenticated; - await this.store.query('session', { - filter: `"/item/user_id" == "${user_id}"`, + const projects = this.project || []; + await this.resourceFilterStore.queryBy('session', { user_id }, { recursive: true, scope_id }); - return this.store.query('target', { - filter: '"authorize-session" in "/item/authorized_actions"', + return this.resourceFilterStore.queryBy('target', { + scope_id: projects.map(({ id }) => id), + authorized_actions: [ + { contains: 'authorize-session' }, + ] + }, { recursive: true, scope_id }); @@ -129,4 +141,22 @@ export default class ScopesScopeProjectsTargetsRoute extends Route { .catch(() => null /* no op */); } } + + /** + * Sets the specified resource filter field to the specified value. + * @param {string} field + * @param value + */ + @action + filterBy(field, value) { + this[field] = value; + } + + /** + * Clears and filter selections. + */ + @action + clearAllFilters() { + this.project = []; + } } diff --git a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs index a7c726fee..d32b85516 100644 --- a/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs +++ b/ui/desktop/app/templates/scopes/scope/projects/targets/index.hbs @@ -9,6 +9,85 @@ + {{#if (feature-flag 'filter')}} + {{#if + (or + @model + (has-resource-filter-selections + 'scopes.scope.projects.targets' 'project' + ) + ) + }} + + + {{#with + (resource-filter 'scopes.scope.projects.targets' 'project') + as |filter| + }} + +
    + {{#with + (group-by filter.allowedValues 'scopeModel') + as |groups| + }} + {{#each groups as |groupedItems|}} +
  • + + + {{groupedItems.key.displayName}} + +
      + +
    • + + + +
    • +
      +
    +
  • + {{/each}} + {{/with}} +
+ + {{! }} +
+ {{/with}} + + {{#if + (has-resource-filter-selections + 'scopes.scope.projects.targets' 'project' + ) + }} + {{t 'actions.clear-all-filters'}} + + {{/if}} +
+
+ {{/if}} + {{/if}} + {{#if @model}} diff --git a/ui/desktop/tests/acceptance/projects/targets-test.js b/ui/desktop/tests/acceptance/projects/targets-test.js index fc40f6611..4971f209f 100644 --- a/ui/desktop/tests/acceptance/projects/targets-test.js +++ b/ui/desktop/tests/acceptance/projects/targets-test.js @@ -255,7 +255,11 @@ module('Acceptance | projects | targets', function (hooks) { await visit(urls.targets); }); - test('cannot connect to a target without proper authorization', async function (assert) { + // Skipping because this test doesn't make sense. Users will never even see + // targets for which they do not have authorize-session. The only reason this + // test ever passed was because filtering in target mocks wasn't previously + // implemented. + test.skip('cannot connect to a target without proper authorization', async function (assert) { assert.expect(3); instances.target.update({ authorized_actions: instances.target.authorized_actions.filter(