diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 7bcd1f9ed..dfa002c2f 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -51,7 +51,8 @@ class StyleSheet 'css/icinga/compat.less', 'css/icinga/print.less', 'css/icinga/responsive.less', - 'css/icinga/modal.less' + 'css/icinga/modal.less', + 'css/icinga/audit.less' ]; /** diff --git a/library/Icinga/Web/View/PrivilegeAudit.php b/library/Icinga/Web/View/PrivilegeAudit.php new file mode 100644 index 000000000..0e63fc9d1 --- /dev/null +++ b/library/Icinga/Web/View/PrivilegeAudit.php @@ -0,0 +1,540 @@ + 'privilege-audit']; + + /** @var Role[] */ + protected $roles; + + public function __construct(array $roles) + { + $this->roles = $roles; + $this->setBaseTarget('_next'); + } + + protected function auditPermission($permission) + { + $grantedBy = []; + $refusedBy = []; + foreach ($this->roles as $role) { + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $grantedBy[] = $role->getName(); + } + } elseif ($role->denies($permission)) { + $refusedBy[] = $role->getName(); + } elseif ($role->grants($permission, false, false)) { + $grantedBy[] = $role->getName(); + } + } + + $header = new HtmlElement('div'); + if (! empty($refusedBy)) { + $header->add([ + new Icon('times-circle', ['class' => 'refused']), + count($refusedBy) > 2 + ? sprintf( + tp( + 'Refused by %s and %s as well as one other', + 'Refused by %s and %s as well as %d others', + count($refusedBy) - 2 + ), + $refusedBy[0], + $refusedBy[1], + count($refusedBy) - 2 + ) + : sprintf( + tp('Refused by %s', 'Refused by %s and %s', count($refusedBy)), + ...$refusedBy + ) + ]); + } elseif (! empty($grantedBy)) { + $header->add([ + new Icon('check-circle', ['class' => 'granted']), + count($grantedBy) > 2 + ? sprintf( + tp( + 'Granted by %s and %s as well as one other', + 'Granted by %s and %s as well as %d others', + count($grantedBy) - 2 + ), + $grantedBy[0], + $grantedBy[1], + count($grantedBy) - 2 + ) + : sprintf( + tp('Granted by %s', 'Granted by %s and %s', count($grantedBy)), + ...$grantedBy + ) + ]); + } else { + $header->add([new Icon('minus-circle'), t('Not granted or refused by any role')]); + } + + $vClass = null; + $rolePaths = []; + foreach (array_reverse($this->roles) as $role) { + if (! in_array($role->getName(), $refusedBy, true) && ! in_array($role->getName(), $grantedBy, true)) { + continue; + } + + /** @var Role[] $rolesReversed */ + $rolesReversed = []; + + do { + array_unshift($rolesReversed, $role); + } while (($role = $role->getParent()) !== null); + + $path = new HtmlElement('ol'); + + $class = null; + $initiator = null; + $setInitiator = false; + foreach ($rolesReversed as $role) { + $granted = false; + $refused = false; + $icon = new Icon('minus-circle'); + if ($permission === self::UNRESTRICTED_PERMISSION) { + if ($role->isUnrestricted()) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + } elseif ($role->denies($permission, true)) { + $refused = true; + $icon = new Icon('times-circle', ['class' => 'refused']); + } elseif ($role->grants($permission, true, false)) { + $granted = true; + $icon = new Icon('check-circle', ['class' => 'granted']); + } + + $connector = null; + if ($role->getParent() !== null) { + $connector = new HtmlElement('li', ['class' => ['connector', $class]]); + if ($setInitiator) { + $setInitiator = false; + $initiator = $connector; + } + + $path->prepend($connector); + } + + $path->prepend(new HtmlElement('li', [ + 'class' => ['role', $class], + 'title' => $role->getName() + ], new Link([$icon, $role->getName()], Url::fromPath('role/edit', ['role' => $role->getName()])))); + + if ($refused) { + $setInitiator = $class !== 'refused'; + $class = 'refused'; + } elseif ($granted) { + $setInitiator = $class === null; + $class = $class ?: 'granted'; + } + } + + if ($initiator !== null) { + $initiator->getAttributes()->add('class', 'initiator'); + } + + if ($vClass === null || $vClass === 'granted') { + $vClass = $class; + } + + array_unshift($rolePaths, $path->prepend([ + empty($rolePaths) ? null : new HtmlElement('li', ['class' => ['vertical-line', $vClass]]), + new HtmlElement('li', ['class' => [ + 'connector', + $class, + $initiator === null ? 'initiator' : null + ]]) + ])); + } + + return [ + empty($refusedBy) ? (empty($grantedBy) ? null : true) : false, + new HtmlElement('div', [ + 'class' => [empty($rolePaths) ? null : 'collapsible', 'inheritance-paths'], + 'data-toggle-element' => '.collapsible-control', + 'data-no-persistence' => true, + 'data-visible-height' => 0 + ], [ + empty($rolePaths) ? $header : $header->addAttributes(['class' => 'collapsible-control']), + $rolePaths + ]) + ]; + } + + protected function auditRestriction($restriction) + { + $restrictedBy = []; + $restrictions = []; + foreach ($this->roles as $role) { + if ($role->isUnrestricted()) { + $restrictedBy = []; + $restrictions = []; + break; + } + + if (($roleRestriction = $role->getRestrictions($restriction)) !== null) { + $restrictedBy[] = $role->getName(); + $restrictions[] = $roleRestriction; + } + } + + $header = new HtmlElement('div'); + if (! empty($restrictedBy)) { + $header->add([ + new Icon('filter', ['class' => 'restricted']), + count($restrictedBy) > 2 + ? sprintf( + tp( + 'Restricted by %s and %s as well as one other', + 'Restricted by %s and %s as well as %d others', + count($restrictedBy) - 2 + ), + $restrictedBy[0], + $restrictedBy[1], + count($restrictedBy) - 2 + ) + : sprintf( + tp('Restricted by %s', 'Restricted by %s and %s', count($restrictedBy)), + ...$restrictedBy + ) + ]); + } else { + $header->add([new Icon('filter'), t('Not restricted by any role')]); + } + + $roles = []; + if (! empty($restrictions) && count($restrictions) > 1) { + list($combinedRestrictions, $combinedLinks) = $this->createRestrictionLinks($restriction, $restrictions); + $roles[] = new HtmlElement('li', null, [ + new HtmlElement('div', ['class' => 'flex-overflow'], [ + new HtmlElement('span', [ + 'class' => 'role', + 'title' => t('All roles combined') + ], join(' | ', $restrictedBy)), + new HtmlElement('code', ['class' => 'restriction'], $combinedRestrictions) + ]), + $combinedLinks ? new HtmlElement('div', ['class' => 'previews'], [ + new HtmlElement('em', null, t('Previews:')), + $combinedLinks + ]) : null + ]); + } + + foreach ($this->roles as $role) { + if (! in_array($role->getName(), $restrictedBy, true)) { + continue; + } + + list($roleRestriction, $restrictionLinks) = $this->createRestrictionLinks( + $restriction, + [$role->getRestrictions($restriction)] + ); + + $roles[] = new HtmlElement('li', null, [ + new HtmlElement('div', ['class' => 'flex-overflow'], [ + new Link($role->getName(), Url::fromPath('role/edit', ['role' => $role->getName()]), [ + 'class' => 'role', + 'title' => $role->getName() + ]), + new HtmlElement('code', ['class' => 'restriction'], $roleRestriction) + ]), + $restrictionLinks ? new HtmlElement('div', ['class' => 'previews'], [ + new HtmlElement('em', null, t('Previews:')), + $restrictionLinks + ]) : null + ]); + } + + return [ + ! empty($restrictedBy), + new HtmlElement('div', [ + 'class' => [empty($roles) ? null : 'collapsible', 'restrictions'], + 'data-toggle-element' => '.collapsible-control', + 'data-no-persistence' => true, + 'data-visible-height' => 0 + ], [ + empty($roles) ? $header : $header->addAttributes(['class' => 'collapsible-control']), + new HtmlElement('ul', null, $roles) + ]) + ]; + } + + protected function assemble() + { + list($permissions, $restrictions) = RoleForm::collectProvidedPrivileges(); + list($wildcardState, $wildcardAudit) = $this->auditPermission('*'); + list($unrestrictedState, $unrestrictedAudit) = $this->auditPermission(self::UNRESTRICTED_PERMISSION); + + $this->add(new HtmlElement('li', [ + 'class' => 'collapsible', + 'data-toggle-element' => 'h3', + 'data-visible-height' => 0 + ], [ + new HtmlElement('h3', null, [ + new HtmlElement('span', null, t('Administrative Privileges')), + new HtmlElement('span', ['class' => 'audit-preview'], [ + $wildcardState || $unrestrictedState + ? new Icon('check-circle', ['class' => 'granted']) + : null + ]) + ]), + new HtmlElement('ol', ['class' => 'privilege-list'], [ + new HtmlElement('li', null, [ + new HtmlElement('p', ['class' => 'privilege-label'], t('Administrative Access')), + new HtmlElement('div', ['class' => 'spacer']), + $wildcardAudit + ]), + new HtmlElement('li', null, [ + new HtmlElement('p', ['class' => 'privilege-label'], t('Unrestricted Access')), + new HtmlElement('div', ['class' => 'spacer']), + $unrestrictedAudit + ]) + ]) + ])); + + $privilegeSources = array_unique(array_merge(array_keys($permissions), array_keys($restrictions))); + foreach ($privilegeSources as $source) { + $anythingGranted = false; + $anythingRefused = false; + $anythingRestricted = false; + + $permissionList = new HtmlElement('ol', ['class' => 'privilege-list']); + foreach (isset($permissions[$source]) ? $permissions[$source] : [] as $permission => $metaData) { + list($permissionState, $permissionAudit) = $this->auditPermission($permission); + if ($permissionState !== null) { + if ($permissionState) { + $anythingGranted = true; + } else { + $anythingRefused = true; + } + } + + $permissionList->add(new HtmlElement('li', null, [ + new HtmlElement( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + new HtmlElement('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : new HtmlElement('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $permission, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', ['class' => 'spacer']), + $permissionAudit + ])); + } + + $restrictionList = new HtmlElement('ol', ['class' => 'privilege-list']); + foreach (isset($restrictions[$source]) ? $restrictions[$source] : [] as $restriction => $metaData) { + list($restrictionState, $restrictionAudit) = $this->auditRestriction($restriction); + if ($restrictionState) { + $anythingRestricted = true; + } + + $restrictionList->add(new HtmlElement('li', null, [ + new HtmlElement( + 'p', + ['class' => 'privilege-label'], + isset($metaData['label']) + ? $metaData['label'] + : array_map(function ($segment) { + return $segment[0] === '/' ? [ + // Adds a zero-width char after each slash to help browsers break onto newlines + new HtmlString('/​'), + new HtmlElement('span', ['class' => 'no-wrap'], substr($segment, 1)) + ] : new HtmlElement('em', null, $segment); + }, preg_split( + '~(/[^/]+)~', + $restriction, + -1, + PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY + )) + ), + new HtmlElement('div', ['class' => 'spacer']), + $restrictionAudit + ])); + } + + if ($source === 'application') { + $label = 'Icinga Web 2'; + } else { + $label = [$source, ' ', new HtmlElement('em', null, t('Module'))]; + } + + $this->add(new HtmlElement('li', [ + 'class' => 'collapsible', + 'data-toggle-element' => 'h3', + 'data-visible-height' => 0 + ], [ + new HtmlElement('h3', null, [ + new HtmlElement('span', null, $label), + new HtmlElement('span', ['class' => 'audit-preview'], [ + $anythingGranted ? new Icon('check-circle', ['class' => 'granted']) : null, + $anythingRefused ? new Icon('times-circle', ['class' => 'refused']) : null, + $anythingRestricted ? new Icon('filter', ['class' => 'restricted']) : null + ]) + ]), + $permissionList->isEmpty() ? null : [ + new HtmlElement('h4', null, t('Permissions')), + $permissionList + ], + $restrictionList->isEmpty() ? null : [ + new HtmlElement('h4', null, t('Restrictions')), + $restrictionList + ] + ])); + } + } + + private function createRestrictionLinks($restrictionName, array $restrictions) + { + // TODO: Remove this hardcoded mess. Do this based on the restriction's meta data + switch ($restrictionName) { + case 'icingadb/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/hostgroups', + Url::fromPath('icingadb/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/servicegroups', + Url::fromPath('icingadb/servicegroups')->setQueryString($filterString) + )) + ]); + + break; + case 'icingadb/filter/hosts': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/hosts', + Url::fromPath('icingadb/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ]); + + break; + case 'icingadb/filter/services': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'icingadb/services', + Url::fromPath('icingadb/services')->setQueryString($filterString) + )) + ]); + + break; + case 'monitoring/filter/objects': + $filterString = join('|', $restrictions); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'monitoring/list/hosts', + Url::fromPath('monitoring/list/hosts')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/services', + Url::fromPath('monitoring/list/services')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/hostgroups', + Url::fromPath('monitoring/list/hostgroups')->setQueryString($filterString) + )), + new HtmlElement('li', null, new Link( + 'monitoring/list/servicegroups', + Url::fromPath('monitoring/list/servicegroups')->setQueryString($filterString) + )) + ]); + + break; + case 'application/share/users': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $userNames = StringHelper::trimSplit($roleRestriction); + foreach ($userNames as $userName) { + $filter->add(Filter::equal('user_name', $userName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'user/list', + Url::fromPath('user/list')->setQueryString($filterString) + )) + ]); + + break; + case 'application/share/groups': + $filter = Filter::any(); + foreach ($restrictions as $roleRestriction) { + $groupNames = StringHelper::trimSplit($roleRestriction); + foreach ($groupNames as $groupName) { + $filter->add(Filter::equal('group_name', $groupName)); + } + } + + $filterString = QueryString::render($filter); + $list = new HtmlElement('ul', ['class' => 'links'], [ + new HtmlElement('li', null, new Link( + 'group/list', + Url::fromPath('group/list')->setQueryString($filterString) + )) + ]); + + break; + default: + $filterString = join(', ', $restrictions); + $list = null; + } + + return [$filterString, $list]; + } +} diff --git a/public/css/icinga/audit.less b/public/css/icinga/audit.less new file mode 100644 index 000000000..d65f89ca5 --- /dev/null +++ b/public/css/icinga/audit.less @@ -0,0 +1,318 @@ +// Style + +.privilege-audit { + &, ul, ol { + list-style-type: none; + } + + h3 { + border-bottom: 1px solid @gray-light; + } + + h3 em, + .previews em, + .privilege-label em { + color: @text-color-light; + } + h3 em { + font-weight: normal; + } + .privilege-label em { + font-style: normal; + } + + .icon { + color: @gray-light; + + &.granted { + color: @color-granted; + } + + &.refused { + color: @color-refused; + } + + &.restricted { + color: @color-restricted; + } + } + + .privilege-list > li { + .spacer { + opacity: 0; + .transition(opacity .5s ease-out); + } + + &:hover .spacer { + .transition(opacity .25s .25s ease-in); + border: 0 dashed @gray-light; + border-top-width: .2em; + opacity: 1; + } + } + + .vertical-line { + border: 0 solid; + border-left-width: 2px; + + &.granted { + border-color: @color-granted; + } + + &.refused { + border-color: @color-refused; + } + } + + .connector { + border: 0 solid @gray-lighter; + border-bottom-width: 2px; + + &.granted { + border-color: @color-granted; + } + + &.refused { + border-color: @color-refused; + } + + &:first-child { + border-width: 0 0 2px 2px; + border-bottom-left-radius: .5em; + } + } + + .role { + .rounded-corners(1em); + border: 2px solid @gray-lighter; + + &.granted { + border: 2px solid @color-granted; + } + + &.refused { + border: 2px solid @color-refused; + } + } + + .restriction { + font-family: @font-family-fixed; + background-color: @gray-lighter; + } +} + +// Layout + +.privilege-audit { + &, ul, ol { + margin: 0; + padding: 0; + } + + > li:not(.collapsed) { + margin-bottom: 2em; + } + + .flex-overflow, + .privilege-list > li, + .inheritance-paths > ol { + display: flex; + } + + .privilege-list > li { + margin-top: 1em; + + > :last-child { + // This aids the usage of text-overflow:ellipsis in any of the children. + // It seems that to get this working while none of the children has a + // defined width, any flex item on the way up to the clipped container + // also must have a overflow value of "hidden". + // https://codepen.io/unthinkingly/pen/XMwJLG + overflow: hidden; + } + } + + h3 { + display: flex; + + > :first-child { + flex: 3 1 auto; + min-width: 20em; + max-width: 40em / 1.167em; // privilege label width + spacer width / h3 font-size + } + + .audit-preview { + flex: 1 1 auto; + + .icon:before { + width: 1.25em; + font-size: 1.25em / 1.167em; // privilege state icon font-size / h3 font-size + } + } + + em { + font-size: .857em; + } + } + + h4, + .privilege-label { + flex-shrink: 0; + width: 20em; + margin: 0; + text-align: right; + } + + ol + h4 { + margin-top: 1em; + } + + .spacer { + flex: 20 1 auto; + min-width: 10em; // TODO: Mobile? + max-width: 18.8em; // 20em - (margin-left + margin-right) + margin: .6em; + } + + .inheritance-paths, + .restrictions { + flex: 1 1 auto; + + > div { + line-height: 1; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + > .icon:before { + width: 1.25em; + font-size: 1.25em; + } + } + } + + .vertical-line { + margin-left: ~"calc(.75em - 1px)"; + } + + .connector { + flex: 1 1 auto; + width: 2em; + max-width: 2em; + min-width: 1em; + margin-bottom: ~"calc(1em - 1px)"; + + &:first-child { + margin-left: ~"calc(.75em - 1px)"; + } + + &.initiator { + z-index: 1; + margin-right: ~"calc(-.25em - 2px)"; + } + } + + .vertical-line + .connector { + min-width: ~"calc(.75em - 2px)"; + width: ~"calc(.75em - 2px)"; + flex-grow: 0; + + &.initiator { + width: ~"calc(1em - 1px)"; + } + } + .connector:first-child { + min-width: .75em; + width: .75em; + flex-grow: 0; + + &.initiator { + width: 1em; + } + } + + .role { + padding: .25em .5em .25em .5em; + line-height: 1; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .icon:before { + font-size: 1.25em; + } + } + .inheritance-paths .role { + min-width: 4em; + margin-top: .5em; + padding-left: .25em; + } + .restrictions .role { + display: inline-block; + } + + .previews { + display: flex; + margin-top: .25em; + + em { + // explicit margin + ((header icon width + its margin right) * 125% font-size) + margin: 0 1em 0 1em + ((1.25em + .2em) * 1.25em); + } + } + + .links li:not(:last-child):after { + content: ","; + } + + .restrictions > ul > li { + margin-top: .5em; + + .role { + margin-left: 1.25em + .2em * 1.25em; // (header icon width + its margin right) * 125% font-size + margin-right: 1em; + } + } + + .restriction { + font-size: .8em; + padding: .335em / .8em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .user-select(all); + } +} + +// Integrations + +.privilege-audit .collapsible { + .collapsible-control { + cursor: pointer; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + .collapsible-control:after { + content: "\f103"; + display: inline-block; + font-family: 'ifont'; + font-weight: normal; + padding: 0 .25em; + margin-right: .25em; + width: 1em; + opacity: .6; + float: right; + } + + &.collapsed .collapsible-control:after { + content: "\e87a"; + } +}