Skip to content

Commit

Permalink
Redesign squad filters (#671)
Browse files Browse the repository at this point in the history
* Revert "Revert "Rewrite Filterable trait used for Squad filters" (#663)"

This reverts commit 7d10130.

* add vendor to gitignore

* add unit test for seat 5 CVE

* add unit test for group CVE

* add documentation explaining the test cases

* add test for sso scope rules

* extract unit test from reverted commit

* refactor: isEligible -> isUserEligible, deferred migration

* refactor: use character for squad eligibility

* update test cases

* fix migration

* bump dependency

* fix membership recomputation

* styleci
  • Loading branch information
recursivetree authored Mar 14, 2024
1 parent 49e1f56 commit 34f95b5
Show file tree
Hide file tree
Showing 25 changed files with 929 additions and 254 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
.env.php
.env
.idea/

# testing
vendor/
composer.lock
.phpunit.cache/test-results
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"erusev/parsedown": "^1.7",
"eveseat/eseye": "^3.0",
"eveseat/eveapi": "^5.0",
"eveseat/services": "^5.0",
"eveseat/services": "^5.0.3",
"guzzlehttp/guzzle": "^7.0",
"intervention/image": "^2.0",
"laravel/framework": "^10.0",
Expand Down
28 changes: 28 additions & 0 deletions src/Exceptions/InvalidFilterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of SeAT
*
* Copyright (C) 2015 to present Leon Jacobs
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/

namespace Seat\Web\Exceptions;

class InvalidFilterException extends \Exception
{

}
187 changes: 67 additions & 120 deletions src/Models/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Seat\Web\Exceptions\InvalidFilterException;
use stdClass;

/**
Expand All @@ -34,150 +35,96 @@
trait Filterable
{
/**
* The filters to use.
*
* @return \stdClass
*/
abstract public function getFilters(): stdClass;

/**
* @param \Illuminate\Database\Eloquent\Model $member
* @return bool
* Check if an entity is eligible.
*
* @param Model $member The entity to check
* @return bool Whether the entity is eligible
*
* @throws InvalidFilterException If a invalid filter configuration is used
*/
final public function isEligible(Model $member): bool
{
// in case no filters exists, bypass check
// in case no filters exists, everyone should be allowed
// this is the case with manual squads
if (! property_exists($this->getFilters(), 'and') && ! property_exists($this->getFilters(), 'or'))
return true;

// init a new object based on parameter
$class = get_class($member);
$query = new QueryGroupBuilder($member->newQuery(), true);

return (new $class)::where($member->getKeyName(), $member->id)
->where(function ($query) {

// verb will determine what kind of method we have to use (simple andWhere or orWhere)
$verb = property_exists($this->getFilters(), 'and') ? 'whereHas' : 'orWhereHas';

// rules will determine all objects and ruleset in the current object root
$rules = property_exists($this->getFilters(), 'and') ? $this->getFilters()->and : $this->getFilters()->or;

// sort rules by path
$sorted_rules = $this->sortFiltersByRelations($rules);

// TODO: find a way to handle this using recursive loop and determine common patterns
$sorted_rules->each(function ($rules_group, $path) use ($query, $verb) {

if (is_int($path)) {

$parent_verb = $verb == 'whereHas' ? 'where' : 'orWhere';

$query->$parent_verb(function ($q2) use ($rules_group, $parent_verb) {

// all pairs will be group in distinct collection due to previous group by
// as a result, we have to iterate over each members
$rules_group->each(function ($rules) use ($parent_verb, $q2) {

// determine the match kind for the current pair
// sort all rules from this pair in order to ensure relationship consistency
$group_verb = property_exists($rules, 'and') ? 'whereHas' : 'orWhereHas';
$rules_group = $this->sortFiltersByRelations(property_exists($rules, 'and') ?
$rules->and : $rules->or);

$rules_group->each(function ($rules, $path) use ($parent_verb, $group_verb, $q2) {

// prepare query from current pair group
$q2->$parent_verb(function ($q3) use ($rules, $path, $group_verb) {
$q3->$group_verb($path, function ($q4) use ($rules, $group_verb) {

// prevent dummy query by encapsulating rules outside relations
$q4->where(function ($q5) use ($rules, $group_verb) {
$this->applyRules($q5, $group_verb, $rules);
});
});
});
});
});
});

} else {

$rules = $rules_group;

// using group by, we've pair all relationships by their top level relation
// $query->whereHas('characters(.*)', function ($sub_query) { ... }
$query->$verb($path, function ($q2) use ($rules, $verb) {

// override the complete rule group with a global where.
// doing it so will prevent SQL query like
// (users.id = tokens.user_id OR character_id = ? OR character_id = ?)
// when multiple rules are applied on same path.
$q2->where(function ($q3) use ($rules, $verb) {
$this->applyRules($q3, $verb, $rules);
});
});
}
});
})->exists();
}

/**
* @param array $rules
* @return \Illuminate\Support\Collection
*/
private function sortFiltersByRelations(array $rules)
{
return collect($rules)->sortBy('path')->groupBy(function ($rule) {
if (! property_exists($rule, 'path'))
return false;

$relation_members = explode('.', $rule->path);
// make sure we only allow results of the entity we are checking count
$query->where(function (Builder $inner_query) use ($member) {
$inner_query->where($member->getKeyName(), $member->getKey());
});

return $relation_members[0];
// wrap this in an inner query to ensure it is '(correct_entity_check) AND (rule1 AND/OR rule2)'
$query->where(function ($inner_query) {
$this->applyGroup($inner_query, $this->getFilters());
});

return $query->getUnderlyingQuery()->exists();
}

/**
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $group_verb
* @param array|\Illuminate\Support\Collection $rules
* @return \Illuminate\Database\Eloquent\Builder
* Applies a filter group to $query.
*
* @param Builder $query the query to add the filter group to
* @param stdClass $group the filter group configuration
*
* @throws InvalidFilterException
*/
private function applyRules(Builder $query, string $group_verb, $rules)
private function applyGroup(Builder $query, stdClass $group): void
{
$query_operator = $group_verb == 'whereHas' ? 'where' : 'orWhere';

if (is_array($rules))
$rules = collect($rules);
$query_group = new QueryGroupBuilder($query, property_exists($group, 'and'));

$rules->sortBy('path')->groupBy('path')->each(function ($relation_rules, $relation) use ($query, $query_operator) {
if (strpos($relation, '.') !== false) {
$relation = substr($relation, strpos($relation, '.') + 1);
$rules = $query_group->isAndGroup() ? $group->and : $group->or;

$query->whereHas($relation, function ($q2) use ($query_operator, $relation_rules) {
$q2->where(function ($q3) use ($relation_rules, $query_operator) {
foreach ($relation_rules as $index => $rule) {
if ($rule->operator == 'contains') {
$json_operator = $query_operator == 'where' ? 'whereJsonContains' : 'orWhereJsonContains';
$q3->$json_operator($rule->field, $rule->criteria);
} else {
$q3->$query_operator($rule->field, $rule->operator, $rule->criteria);
}
}
});
});
foreach ($rules as $rule){
// check if this is a nested group or not
if(property_exists($rule, 'path')){
$this->applyRule($query_group, $rule);
} else {
$query->where(function ($q2) use ($relation_rules, $query_operator) {
foreach ($relation_rules as $index => $rule) {
if ($rule->operator == 'contains') {
$json_operator = $query_operator == 'where' ? 'whereJsonContains' : 'orWhereJsonContains';
$q2->$json_operator($rule->field, $rule->criteria);
} else {
$q2->$query_operator($rule->field, $rule->operator, $rule->criteria);
}
}
// this is a nested group
$query_group->where(function ($group_query) use ($rule) {
$this->applyGroup($group_query, $rule);
});
}
});
}
}

return $query;
/**
* Applies a rule to a query group.
*
* @param QueryGroupBuilder $query the query to add the rule to
* @param stdClass $rule the rule configuration
*
* @throws InvalidFilterException
*/
private function applyRule(QueryGroupBuilder $query, stdClass $rule): void {
// 'is' operator
if($rule->operator === '=' || $rule->operator === '<' || $rule->operator === '>'){
// normal comparison operations need to relation to exist
$query->whereHas($rule->path, function (Builder $inner_query) use ($rule) {
$inner_query->where($rule->field, $rule->operator, $rule->criteria);
});
} elseif ($rule->operator === '<>' || $rule->operator === '!=') {
// not equal is special cased since a missing relation is the same as not equal
$query->whereDoesntHave($rule->path, function (Builder $inner_query) use ($rule) {
$inner_query->where($rule->field, $rule->criteria);
});
} elseif($rule->operator === 'contains'){
// contains is maybe a misleading name, since it actually checks if json contains a value
$query->whereHas($rule->path, function (Builder $inner_query) use ($rule) {
$inner_query->whereJsonContains($rule->field, $rule->criteria);
});
} else {
throw new InvalidFilterException(sprintf('Unknown rule operator: \'%s\'', $rule->operator));
}
}
}
Loading

0 comments on commit 34f95b5

Please sign in to comment.