mirror of
https://github.com/Icinga/icingadb-web.git
synced 2026-02-03 20:40:15 -05:00
473 lines
18 KiB
PHP
473 lines
18 KiB
PHP
<?php
|
|
|
|
/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
|
|
|
|
namespace Icinga\Module\Icingadb\Common;
|
|
|
|
use Icinga\Authentication\Auth as IcingaAuth;
|
|
use Icinga\Exception\ConfigurationError;
|
|
use Icinga\Module\Icingadb\Authentication\ObjectAuthorization;
|
|
use Icinga\Security\SecurityException;
|
|
use Icinga\Util\StringHelper;
|
|
use ipl\Orm\Compat\FilterProcessor;
|
|
use ipl\Orm\Model;
|
|
use ipl\Orm\Query;
|
|
use ipl\Orm\UnionQuery;
|
|
use ipl\Sql\Expression;
|
|
use ipl\Stdlib\Filter;
|
|
use ipl\Web\Filter\QueryString;
|
|
|
|
trait Auth
|
|
{
|
|
public function getAuth(): IcingaAuth
|
|
{
|
|
return IcingaAuth::getInstance();
|
|
}
|
|
|
|
/**
|
|
* Check whether access to the given route is permitted
|
|
*
|
|
* @param string $name
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isPermittedRoute(string $name): bool
|
|
{
|
|
if ($this->getAuth()->getUser()->isUnrestricted()) {
|
|
return true;
|
|
}
|
|
|
|
// The empty array is for PHP pre 7.4, older versions require at least a single param for array_merge
|
|
$routeDenylist = array_flip(array_merge([], ...array_map(function ($restriction) {
|
|
return StringHelper::trimSplit($restriction);
|
|
}, $this->getAuth()->getRestrictions('icingadb/denylist/routes'))));
|
|
|
|
if (! array_key_exists($name, $routeDenylist)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check whether the permission is granted on the object
|
|
*
|
|
* @param string $permission
|
|
* @param Model $object
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isGrantedOn(string $permission, Model $object): bool
|
|
{
|
|
if ($this->getAuth()->getUser()->isUnrestricted()) {
|
|
return $this->getAuth()->hasPermission($permission);
|
|
}
|
|
|
|
return ObjectAuthorization::grantsOn($permission, $object);
|
|
}
|
|
|
|
/**
|
|
* Check whether the permission is granted on objects matching the type and filter
|
|
*
|
|
* The check will be performed on every object matching the filter. Though the result
|
|
* only allows to determine whether the permission is granted on **any** or *none*
|
|
* of the objects in question. Any subsequent call to {@see Auth::isGrantedOn} will
|
|
* make use of the underlying results the check has determined in order to avoid
|
|
* unnecessary queries.
|
|
*
|
|
* @param string $permission
|
|
* @param string $type
|
|
* @param Filter\Rule $filter
|
|
* @param bool $cache Pass `false` to not perform the check on every object
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isGrantedOnType(string $permission, string $type, Filter\Rule $filter, bool $cache = true): bool
|
|
{
|
|
if ($this->getAuth()->getUser()->isUnrestricted()) {
|
|
return $this->getAuth()->hasPermission($permission);
|
|
}
|
|
|
|
return ObjectAuthorization::grantsOnType($permission, $type, $filter, $cache);
|
|
}
|
|
|
|
/**
|
|
* Check whether the filter matches the given object
|
|
*
|
|
* @param string $queryString
|
|
* @param Model $object
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function isMatchedOn(string $queryString, Model $object): bool
|
|
{
|
|
return ObjectAuthorization::matchesOn($queryString, $object);
|
|
}
|
|
|
|
/**
|
|
* Assert that the given filter does not contain any column restrictions
|
|
*
|
|
* @param Filter\Rule $filter The filter to check
|
|
*
|
|
* @return void
|
|
*
|
|
* @throws SecurityException
|
|
*/
|
|
public function assertColumnRestrictions(Filter\Rule $filter): void
|
|
{
|
|
if ($this->getAuth()->getUser() === null || $this->getAuth()->getUser()->isUnrestricted()) {
|
|
return;
|
|
}
|
|
|
|
$forbiddenVars = Filter::all();
|
|
$protectedVars = Filter::any();
|
|
$hiddenVars = Filter::any();
|
|
foreach ($this->getAuth()->getUser()->getRoles() as $role) {
|
|
if (($restriction = $role->getRestrictions('icingadb/denylist/variables'))) {
|
|
$denied = Filter::none();
|
|
$hiddenVars->add($denied);
|
|
foreach (explode(',', $restriction) as $value) {
|
|
$denied->add(Filter::like('name', trim($value))->ignoreCase());
|
|
}
|
|
}
|
|
|
|
if (($restriction = $role->getRestrictions('icingadb/protect/variables'))) {
|
|
$protected = Filter::none();
|
|
$protectedVars->add($protected);
|
|
foreach (explode(',', $restriction) as $value) {
|
|
$protected->add(Filter::like('name', trim($value))->ignoreCase());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! $hiddenVars->isEmpty()) {
|
|
$forbiddenVars->add($hiddenVars);
|
|
}
|
|
|
|
if (! $protectedVars->isEmpty()) {
|
|
$forbiddenVars->add($protectedVars);
|
|
}
|
|
|
|
if ($forbiddenVars->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$checkVars = static function (Filter\Rule $filter) use ($forbiddenVars, &$checkVars) {
|
|
if ($filter instanceof Filter\Chain) {
|
|
foreach ($filter as $rule) {
|
|
$checkVars($rule);
|
|
}
|
|
} elseif (
|
|
! $filter->metaData()->get('_isRestriction', false)
|
|
&& preg_match('/^(?:host|service)\.vars\.(.*)/i', $filter->getColumn(), $matches)
|
|
) {
|
|
if (! Filter::match($forbiddenVars, ['name' => $matches[1]])) {
|
|
throw new SecurityException(
|
|
'The variable "%s" is not allowed to be queried.',
|
|
$matches[1]
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
$checkVars($filter);
|
|
}
|
|
|
|
/**
|
|
* Apply Icinga DB Web's restrictions depending on what is queried
|
|
*
|
|
* This will apply `icingadb/filter/objects` in any case. `icingadb/filter/services` is only
|
|
* applied to queries fetching services and `icingadb/filter/hosts` is applied to queries
|
|
* fetching either hosts or services. It also applies custom variable restrictions and
|
|
* obfuscations. (`icingadb/denylist/variables` and `icingadb/protect/variables`)
|
|
*
|
|
* @param Query $query
|
|
*
|
|
* @return void
|
|
*/
|
|
public function applyRestrictions(Query $query)
|
|
{
|
|
if ($this->getAuth()->getUser()->isUnrestricted()) {
|
|
return;
|
|
}
|
|
|
|
if ($query instanceof UnionQuery) {
|
|
$queries = $query->getUnions();
|
|
} else {
|
|
$queries = [$query];
|
|
}
|
|
|
|
$orgQuery = $query;
|
|
foreach ($queries as $query) {
|
|
$relations = [$query->getModel()->getTableName()];
|
|
foreach ($query->getWith() as $relationPath => $relation) {
|
|
$relations[$relationPath] = $relation->getTarget()->getTableName();
|
|
}
|
|
|
|
$customVarRelationName = array_search('customvar_flat', $relations, true);
|
|
$applyServiceRestriction = $relations[0] === 'dependency_node' || in_array('service', $relations, true);
|
|
$applyHostRestriction = $relations[0] === 'dependency_node' || in_array('host', $relations, true)
|
|
// Hosts and services have a special relation as a service can't exist without its host.
|
|
// Hence why the hosts restriction is also applied if only services are queried.
|
|
|| $applyServiceRestriction;
|
|
|
|
$hostStateRelation = array_search('host_state', $relations, true);
|
|
$serviceStateRelation = array_search('service_state', $relations, true);
|
|
|
|
$resolver = $query->getResolver();
|
|
|
|
$queryFilter = Filter::any();
|
|
$obfuscationRules = Filter::any();
|
|
foreach ($this->getAuth()->getUser()->getRoles() as $role) {
|
|
$roleFilter = Filter::all();
|
|
|
|
if ($customVarRelationName !== false) {
|
|
if (($restriction = $role->getRestrictions('icingadb/denylist/variables'))) {
|
|
$roleFilter->add($this->parseDenylist(
|
|
$restriction,
|
|
$customVarRelationName
|
|
? $resolver->qualifyColumn('flatname', $customVarRelationName)
|
|
: 'flatname'
|
|
));
|
|
}
|
|
|
|
if (($restriction = $role->getRestrictions('icingadb/protect/variables'))) {
|
|
$obfuscationRules->add($this->parseDenylist(
|
|
$restriction,
|
|
$customVarRelationName
|
|
? $resolver->qualifyColumn('flatname', $customVarRelationName)
|
|
: 'flatname'
|
|
));
|
|
}
|
|
}
|
|
|
|
if ($customVarRelationName === false || count($relations) > 1) {
|
|
if ($restriction = $role->getRestrictions('icingadb/filter/objects')) {
|
|
$roleFilter->add(Filter::any(
|
|
Filter::all(
|
|
Filter::unlike('host.id', '*'),
|
|
Filter::unlike('service.id', '*')
|
|
),
|
|
$this->parseRestriction($restriction, 'icingadb/filter/objects')
|
|
));
|
|
}
|
|
|
|
if ($applyHostRestriction && ($restriction = $role->getRestrictions('icingadb/filter/hosts'))) {
|
|
$hostFilter = $this->parseRestriction($restriction, 'icingadb/filter/hosts');
|
|
if ($orgQuery instanceof UnionQuery) {
|
|
$this->forceQueryOptimization($hostFilter, 'hostgroup.name');
|
|
}
|
|
|
|
$roleFilter->add(Filter::any(Filter::unlike('host.id', '*'), $hostFilter));
|
|
}
|
|
|
|
if (
|
|
$applyServiceRestriction
|
|
&& ($restriction = $role->getRestrictions('icingadb/filter/services'))
|
|
) {
|
|
$serviceFilter = $this->parseRestriction($restriction, 'icingadb/filter/services');
|
|
if ($orgQuery instanceof UnionQuery) {
|
|
$this->forceQueryOptimization($serviceFilter, 'servicegroup.name');
|
|
}
|
|
|
|
$roleFilter->add(Filter::any(Filter::unlike('service.id', '*'), $serviceFilter));
|
|
}
|
|
}
|
|
|
|
if (! $roleFilter->isEmpty()) {
|
|
$queryFilter->add($roleFilter);
|
|
}
|
|
}
|
|
|
|
if (! $this->getAuth()->hasPermission('icingadb/object/show-source')) {
|
|
// In case the user does not have permission to see the object's `Source` tab, then the user must be
|
|
// restricted from accessing the executed command for the object.
|
|
$columns = $query->getColumns();
|
|
$commandColumns = [];
|
|
if ($hostStateRelation !== false) {
|
|
$commandColumns[] = $resolver->qualifyColumn('check_commandline', $hostStateRelation);
|
|
}
|
|
|
|
if ($serviceStateRelation !== false) {
|
|
$commandColumns[] = $resolver->qualifyColumn('check_commandline', $serviceStateRelation);
|
|
}
|
|
|
|
if (! empty($columns)) {
|
|
foreach ($commandColumns as $commandColumn) {
|
|
$commandColumnPath = array_search($commandColumn, $columns, true);
|
|
if ($commandColumnPath !== false) {
|
|
$columns[$commandColumn] = new Expression("'***'");
|
|
unset($columns[$commandColumnPath]);
|
|
}
|
|
}
|
|
|
|
$query->columns($columns);
|
|
} else {
|
|
$query->withoutColumns($commandColumns);
|
|
}
|
|
}
|
|
|
|
if (! $obfuscationRules->isEmpty()) {
|
|
$flatvaluePath = $customVarRelationName
|
|
? $resolver->qualifyColumn('flatvalue', $customVarRelationName)
|
|
: 'flatvalue';
|
|
|
|
$columns = $query->getColumns();
|
|
if (empty($columns)) {
|
|
$columns = [
|
|
$customVarRelationName
|
|
? $resolver->qualifyColumn('flatname', $customVarRelationName)
|
|
: 'flatname',
|
|
$flatvaluePath
|
|
];
|
|
}
|
|
|
|
$flatvalue = null;
|
|
if (isset($columns[$flatvaluePath])) {
|
|
$flatvalue = $columns[$flatvaluePath];
|
|
} else {
|
|
$flatvaluePathAt = array_search($flatvaluePath, $columns, true);
|
|
if ($flatvaluePathAt !== false) {
|
|
$flatvalue = $columns[$flatvaluePathAt];
|
|
if (is_int($flatvaluePathAt)) {
|
|
unset($columns[$flatvaluePathAt]);
|
|
} else {
|
|
$flatvaluePath = $flatvaluePathAt;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($flatvalue !== null) {
|
|
// TODO: The four lines below are needed because there is still no way to postpone filter column
|
|
// qualification. (i.e. Just like the expression, filter rules need to be handled the same
|
|
// so that their columns are qualified lazily when assembling the query)
|
|
$queryClone = clone $query;
|
|
$queryClone->getSelectBase()->resetWhere();
|
|
FilterProcessor::apply($obfuscationRules, $queryClone);
|
|
$where = $queryClone->getSelectBase()->getWhere();
|
|
|
|
$values = [];
|
|
$rendered = $query->getDb()->getQueryBuilder()->buildCondition($where, $values);
|
|
$columns[$flatvaluePath] = new Expression(
|
|
"CASE WHEN (" . $rendered . ") THEN (%s) ELSE '***' END",
|
|
[$flatvalue],
|
|
...$values
|
|
);
|
|
|
|
$query->columns($columns);
|
|
}
|
|
}
|
|
|
|
$query->filter($queryFilter);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the given restriction
|
|
*
|
|
* @param string $queryString
|
|
* @param string $restriction The name of the restriction
|
|
*
|
|
* @return Filter\Rule
|
|
*/
|
|
protected function parseRestriction(string $queryString, string $restriction): Filter\Rule
|
|
{
|
|
$allowedColumns = [
|
|
'host.name',
|
|
'hostgroup.name',
|
|
'host.user.name',
|
|
'host.usergroup.name',
|
|
'service.name',
|
|
'servicegroup.name',
|
|
'service.user.name',
|
|
'service.usergroup.name',
|
|
'(host|service).vars.<customvar-name>' => function ($c) {
|
|
return preg_match('/^(?:host|service)\.vars\./i', $c);
|
|
}
|
|
];
|
|
|
|
return QueryString::fromString($queryString)
|
|
->on(
|
|
QueryString::ON_CONDITION,
|
|
function (Filter\Condition $condition) use (
|
|
$restriction,
|
|
$queryString,
|
|
$allowedColumns
|
|
) {
|
|
foreach ($allowedColumns as $column) {
|
|
if (is_callable($column)) {
|
|
if ($column($condition->getColumn())) {
|
|
$condition->metaData()->set('_isRestriction', true);
|
|
|
|
return;
|
|
}
|
|
} elseif ($column === $condition->getColumn()) {
|
|
$condition->metaData()->set('_isRestriction', true);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new ConfigurationError(
|
|
t(
|
|
'Cannot apply restriction %s using the filter %s.'
|
|
. ' You can only use the following columns: %s'
|
|
),
|
|
$restriction,
|
|
$queryString,
|
|
join(
|
|
', ',
|
|
array_map(
|
|
function ($k, $v) {
|
|
return is_string($k) ? $k : $v;
|
|
},
|
|
array_keys($allowedColumns),
|
|
$allowedColumns
|
|
)
|
|
)
|
|
);
|
|
}
|
|
)->parse();
|
|
}
|
|
|
|
/**
|
|
* Parse the given denylist
|
|
*
|
|
* @param string $denylist Comma separated list of column names
|
|
* @param string $column The column which should not equal any of the denylisted names
|
|
*
|
|
* @return Filter\None
|
|
*/
|
|
protected function parseDenylist(string $denylist, string $column): Filter\None
|
|
{
|
|
$filter = Filter::none();
|
|
foreach (explode(',', $denylist) as $value) {
|
|
$filter->add(Filter::like($column, trim($value)));
|
|
}
|
|
|
|
return $filter;
|
|
}
|
|
|
|
/**
|
|
* Force query optimization on the given service/host filter rule
|
|
*
|
|
* Applies forceOptimization, when the given filter rule contains the given filter column
|
|
*
|
|
* @param Filter\Rule $filterRule
|
|
* @param string $filterColumn
|
|
*
|
|
* @return void
|
|
*/
|
|
protected function forceQueryOptimization(Filter\Rule $filterRule, string $filterColumn)
|
|
{
|
|
// TODO: This is really a very poor solution is therefore only a quick fix.
|
|
// We need to somehow manage to make this more enjoyable and creative!
|
|
if ($filterRule instanceof Filter\Chain) {
|
|
foreach ($filterRule as $rule) {
|
|
$this->forceQueryOptimization($rule, $filterColumn);
|
|
}
|
|
} elseif ($filterRule->getColumn() === $filterColumn) {
|
|
$filterRule->metaData()->set('forceOptimization', true);
|
|
}
|
|
}
|
|
}
|