summaryrefslogtreecommitdiffstats
path: root/library/Icingadb/Common/Auth.php
diff options
context:
space:
mode:
Diffstat (limited to 'library/Icingadb/Common/Auth.php')
-rw-r--r--library/Icingadb/Common/Auth.php389
1 files changed, 389 insertions, 0 deletions
diff --git a/library/Icingadb/Common/Auth.php b/library/Icingadb/Common/Auth.php
new file mode 100644
index 0000000..d25526e
--- /dev/null
+++ b/library/Icingadb/Common/Auth.php
@@ -0,0 +1,389 @@
+<?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\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'))));
+
+ return ! array_key_exists($name, $routeDenylist);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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 = in_array('service', $relations, true);
+ $applyHostRestriction = 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($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($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())) {
+ return;
+ }
+ } elseif ($column === $condition->getColumn()) {
+ 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);
+ }
+ }
+}