[Rt-commit] rt branch, 4.6/core-rightinspector, created. rt-4.4.4-373-ga5266b786

Michel Rodriguez michel at bestpractical.com
Mon Sep 9 10:04:41 EDT 2019


The branch, 4.6/core-rightinspector has been created
        at  a5266b786adfbded4e2e24acac1e41121d04e172 (commit)

- Log -----------------------------------------------------------------
commit 40dec2d6e77c7b9c3d8179f060b80edddcd13c1c
Author: michel <michel at bestpractical.com>
Date:   Wed Sep 4 23:22:32 2019 +0200

    Cored RT::Extension::RightsInspector

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 806d23888..fae57c0c4 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -2,7 +2,7 @@
 #
 # COPYRIGHT:
 #
-# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
 #                                          <sales at bestpractical.com>
 #
 # (Except where explicitly superseded by other copyright notices)
@@ -972,6 +972,11 @@ sub _BuildAdminMenu {
            path        => '/Admin/Tools/Queries.html',
        );
     }
+    $admin_tools->child( rights_inspector =>
+        title => loc('Rights Inspector'),
+        description => loc('Search your configured rights'),
+        path  => '/Admin/Tools/RightsInspector.html',
+    );
     $admin_tools->child( shredder =>
         title       => loc('Shredder'),
         description => loc('Permanently wipeout data from RT'),
diff --git a/lib/RT/RightsInspector.pm b/lib/RT/RightsInspector.pm
new file mode 100644
index 000000000..50307c004
--- /dev/null
+++ b/lib/RT/RightsInspector.pm
@@ -0,0 +1,876 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+package RT::RightsInspector;
+use strict;
+use warnings;
+
+# glossary:
+#     inner role - being granted a right by way of ticket role membership
+#                  which is treated in a special way in RT. this is because
+#                  members of ticket AdminCc group are neither members of
+#                  the queue AdminCc group nor the system AdminCc group.
+#                  this means we have to do a really gnarly joins to recover
+#                  such ACLs. to improve comprehensibility we keep track
+#                  of such inner roles then massage the serialized data
+#                  afterwards to reference these implicit relationships
+#     principal  - the recipient of a privilege; e.g. user or group
+#     object     - the scope of the privilege; e.g. queue or system
+#     record     - generalization of principal and object since rendering
+#                  and whatnot can share code
+
+my $PageLimit = 10;
+
+$RT::Interface::Web::WHITELISTED_COMPONENT_ARGS{'/Admin/RightsInspector/index.html'} = ['Principal', 'Object', 'Right'];
+
+sub CurrentUser {
+    return $HTML::Mason::Commands::session{CurrentUser};
+}
+
+sub _EscapeHTML {
+    my $s = shift;
+    RT::Interface::Web::EscapeHTML(\$s);
+    return $s;
+}
+
+sub _EscapeURI {
+    my $s = shift;
+    RT::Interface::Web::EscapeURI(\$s);
+    return $s;
+}
+
+# used to convert a search term (e.g. "root") into a regex for highlighting
+# in the UI. potentially useful hook point for implementing say, "ro*t"
+sub RegexifyTermForHighlight {
+    my $self = shift;
+    my $term = shift || '';
+    return qr/\Q$term\E/i;
+}
+
+# takes a text label and returns escaped html, highlighted using the search
+# term(s)
+sub HighlightTextForSearch {
+    my $self = shift;
+    my $text = shift;
+    my $term = shift;
+
+    my $re = ref($term) eq 'ARRAY'
+           ? join '|', map { $self->RegexifyTermForHighlight($_) } @$term
+           : $self->RegexifyTermForHighlight($term);
+
+    # if $term is an arrayref, make sure we qr-ify it
+    # without this, then if $term has no elements, we interpolate $re
+    # as an empty string which causes the regex engine to fall into
+    # an infinite loop
+    $re = qr/$re/ unless ref($re);
+
+    $text =~ s{
+        \G         # where we left off the previous iteration thanks to /g
+        (.*?)      # non-matching text before the match
+        ($re|$)    # matching text, or the end of the line (to escape any
+                   # text after the last match)
+    }{
+      _EscapeHTML($1) .
+      (length $2 ? '<span class="match">' . _EscapeHTML($2) . '</span>' : '')
+    }xeg;
+
+    return $text; # now escaped as html
+}
+
+# takes a serialized result and highlights its labels according to the search
+# terms
+sub HighlightSerializedForSearch {
+    my $self         = shift;
+    my $serialized   = shift;
+    my $args         = shift;
+    my $regex_search = shift;
+
+    # highlight matching terms
+    $serialized->{right_highlighted} = $self->HighlightTextForSearch($serialized->{right}, [split ' ', $args->{right} || '']);
+
+    for my $key (qw/principal object/) {
+        for my $record ($serialized->{$key}, $serialized->{$key}->{primary_record}) {
+            next if !$record;
+
+            # if we used a regex search for this record, then highlight the
+            # text that the regex matched
+            if ($regex_search->{$key}) {
+                for my $column (qw/label detail/) {
+                    $record->{$column . '_highlighted'} = $self->HighlightTextForSearch($record->{$column}, $args->{$key});
+                }
+            }
+            # otherwise we used a search like user:root and so we should
+            # highlight just that user completely (but not its parent group)
+            else {
+                $record->{'highlight'} = $record->{primary_record} ? 0 : 1;
+                for my $column (qw/label detail/) {
+                    $record->{$column . '_highlighted'} = _EscapeHTML($record->{$column});
+                }
+            }
+        }
+    }
+
+    return;
+}
+
+# takes "u:root" "group:37" style specs and returns the RT::Principal
+sub PrincipalForSpec {
+    my $self       = shift;
+    my $type       = shift;
+    my $identifier = shift;
+
+    if ($type =~ /^(g|group)$/i) {
+        my $group = RT::Group->new($self->CurrentUser);
+        if ( $identifier =~ /^\d+$/ ) {
+            $group->LoadByCols(
+                id => $identifier,
+            );
+        } else {
+            $group->LoadByCols(
+                Domain => 'UserDefined',
+                Name   => $identifier,
+            );
+        }
+
+        return $group->PrincipalObj if $group->Id;
+        return (0, "Unable to load group $identifier");
+    }
+    elsif ($type =~ /^(u|user)$/i) {
+        my $user = RT::User->new($self->CurrentUser);
+        my ($ok, $msg) = $user->Load($identifier);
+        return $user->PrincipalObj if $user->Id;
+        return (0, "Unable to load user $identifier");
+    }
+    else {
+        RT->Logger->debug("Unexpected type '$type'");
+    }
+
+    return undef;
+}
+
+# takes "t#1" "queue:General", "asset:37" style specs and returns that object
+# limited to thinks you can grant rights on
+sub ObjectForSpec {
+    my $self       = shift;
+    my $type       = shift;
+    my $identifier = shift;
+
+    my $record;
+
+    if ($type =~ /^(t|ticket)$/i) {
+        $record = RT::Ticket->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^(q|queue)$/i) {
+        $record = RT::Queue->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^asset$/i) {
+        $record = RT::Asset->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^catalog$/i) {
+        $record = RT::Catalog->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^(a|article)$/i) {
+        $record = RT::Article->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^class$/i) {
+        $record = RT::Class->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^cf|customfield$/i) {
+        $record = RT::CustomField->new($self->CurrentUser);
+    }
+    elsif ($type =~ /^(g|group)$/i) {
+        return $self->PrincipalForSpec($type, $identifier);
+    }
+    else {
+        RT->Logger->debug("Unexpected type '$type'");
+        return undef;
+    }
+
+    $record->Load($identifier);
+    return $record if $record->Id;
+    my $class = ref($record); $class =~ s/^RT:://;
+    return (0, "Unable to load $class '$identifier'");
+
+    return undef;
+}
+
+our %ParentMap = (
+    'RT::Ticket' => [Queue => 'RT::Queue'],
+    'RT::Asset' => [Catalog => 'RT::Catalog'],
+);
+
+# see inner role glossary entry
+# this has three modes, depending on which parameters are passed
+# - principal_id but no inner_id: find tickets/assets this principal
+#   has permissions for
+# - inner_id but no principal_id: find the queue/system permissions that affect
+#   this ticket
+# - principal and inner_id: find all permissions this principal has on
+#   this "inner" object
+# there's no analagous query in the RT codebase because it uses a caching approach;
+# see RT::Tickets::_RolesCanSee
+sub InnerRoleQuery {
+    my $self = shift;
+    my %args = (
+        inner_class  => '', # RT::Ticket, RT::Asset
+        principal_id => undef,
+        inner_id     => undef,
+        right_search => undef,
+        @_,
+    );
+
+    my $inner_class  = $args{inner_class};
+    my $principal_id = $args{principal_id};
+    my $inner_id     = $args{inner_id};
+    my $inner_table  = $inner_class->Table;
+
+    my ($parent_column, $parent_class) = @{ $ParentMap{$inner_class} || [] }
+        or die "No parent mapping specified for $inner_class";
+    my $parent_table = $parent_class->Table;
+
+    my @query = qq[
+        SELECT main.id,
+               MIN(InnerRecords.id) AS example_record,
+               COUNT(InnerRecords.id)-1 AS other_count
+        FROM ACL main
+        JOIN Groups ParentRoles
+             ON main.PrincipalId = ParentRoles.id
+        JOIN $inner_table InnerRecords
+             ON   (ParentRoles.Domain = '$parent_class-Role' AND InnerRecords.$parent_column = ParentRoles.Instance)
+                OR ParentRoles.Domain = 'RT::System-Role'
+        JOIN Groups InnerRoles
+             ON  InnerRoles.Instance = InnerRecords.Id
+             AND InnerRoles.Name = main.PrincipalType
+    ];
+    if ($principal_id) {
+        push @query, qq[
+            JOIN CachedGroupMembers CGM
+                 ON CGM.GroupId = InnerRoles.id
+        ];
+    }
+
+    push @query, qq[ WHERE ];
+
+    if ($args{right_search}) {
+        my $LIKE = RT->Config->Get('DatabaseType') eq 'Pg' ? 'ILIKE' : 'LIKE';
+
+        push @query, qq[ ( ];
+        for my $term (split ' ', $args{right_search}) {
+            my $quoted = $RT::Handle->Quote('%' . $term . '%');
+            push @query, qq[
+                main.RightName $LIKE $quoted OR
+            ],
+        }
+        push @query, qq[main.RightName $LIKE 'SuperUser'];
+        push @query, qq[ ) AND ];
+    }
+
+    if ($principal_id) {
+        push @query, qq[
+             CGM.MemberId = $principal_id AND
+             CGM.Disabled = 0 AND
+        ];
+    }
+    else {
+        #push @query, qq[
+        #         CGM.MemberId = $principal_id AND
+        #];
+    }
+
+    push @query, qq[
+             InnerRecords.id = $inner_id AND
+    ] if $inner_id;
+
+    push @query, qq[
+             InnerRoles.Domain = '$inner_class-Role'
+        GROUP BY main.id
+    ];
+
+    return join "\n", @query;
+}
+
+# key entry point into this extension; takes a query (principal, object, right)
+# and produces a list of highlighted results
+sub Search {
+    my $self = shift;
+    my %args = (
+        principal => '',
+        object    => '',
+        right     => '',
+        @_,
+    );
+
+    my @results;
+
+    my $ACL = RT::ACL->new($self->CurrentUser);
+
+    my $has_search = 0;
+    my %use_regex_search_for = (
+        principal => 1,
+        object    => 1,
+    );
+    my %primary_records = (
+        principal => undef,
+        object    => undef,
+    );
+    my %filter_out;
+    my %inner_role;
+
+    if ($args{right}) {
+        $has_search = 1;
+
+        push @{ $filter_out{right} }, $2
+            while $args{right} =~ s/( |^)!(\S+)/$1/;
+
+        for my $term (split ' ', $args{right}) {
+            $ACL->Limit(
+                FIELD           => 'RightName',
+                OPERATOR        => 'LIKE',
+                VALUE           => $term,
+                CASESENSITIVE   => 0,
+                ENTRYAGGREGATOR => 'OR',
+            );
+        }
+        $ACL->Limit(
+            FIELD           => 'RightName',
+            OPERATOR        => '=',
+            VALUE           => 'SuperUser',
+            ENTRYAGGREGATOR => 'OR',
+        );
+    }
+
+    if ($args{object}) {
+        push @{ $filter_out{object} }, $2
+            while $args{object} =~ s/( |^)!(\S+)/$1/;
+
+        if (my ($type, $identifier) = $args{object} =~ m{
+            ^
+                \s*
+                (t|ticket|q|queue|asset|catalog|a|article|class|g|group|cf|customfield)
+                \s*
+                [:#]
+                \s*
+                (.+?)
+                \s*
+            $
+        }xi) {
+            my ($record, $msg) = $self->ObjectForSpec($type, $identifier);
+            if (!$record) {
+                return { error => $msg || 'Unable to find row' };
+            }
+
+            $has_search = 1;
+            $use_regex_search_for{object} = 0;
+
+            $primary_records{object} = $record;
+
+            for my $obj ($record, $record->ACLEquivalenceObjects, RT->System) {
+                $ACL->_OpenParen('object');
+                $ACL->Limit(
+                    SUBCLAUSE          => 'object',
+                    FIELD           => 'ObjectType',
+                    OPERATOR        => '=',
+                    VALUE           => ref($obj),
+                    ENTRYAGGREGATOR => 'OR',
+                );
+                $ACL->Limit(
+                    SUBCLAUSE          => 'object',
+                    FIELD           => 'ObjectId',
+                    OPERATOR        => '=',
+                    VALUE           => $obj->Id,
+                    QUOTEVALUE      => 0,
+                    ENTRYAGGREGATOR => 'AND',
+                );
+                $ACL->_CloseParen('object');
+            }
+        }
+    }
+
+    my $principal_paren = 0;
+
+    if ($args{principal}) {
+        push @{ $filter_out{principal} }, $2
+            while $args{principal} =~ s/( |^)!(\S+)/$1/;
+
+        if (my ($type, $identifier) = $args{principal} =~ m{
+            ^
+                \s*
+                (u|user|g|group)
+                \s*
+                [:#]
+                \s*
+                (.+?)
+                \s*
+            $
+        }xi) {
+            my ($principal, $msg) = $self->PrincipalForSpec($type, $identifier);
+            if (!$principal) {
+                return { error => $msg || 'Unable to find row' };
+            }
+
+            $has_search = 1;
+            $use_regex_search_for{principal} = 0;
+
+            $primary_records{principal} = $principal;
+
+            my $principal_alias = $ACL->Join(
+                ALIAS1 => 'main',
+                FIELD1 => 'PrincipalId',
+                TABLE2 => 'Principals',
+                FIELD2 => 'id',
+            );
+
+            my $cgm_alias = $ACL->Join(
+                ALIAS1 => 'main',
+                FIELD1 => 'PrincipalId',
+                TABLE2 => 'CachedGroupMembers',
+                FIELD2 => 'GroupId',
+            );
+            $ACL->_OpenParen('principal');
+            $principal_paren = 1;
+            $ACL->Limit(
+                ALIAS => $cgm_alias,
+                SUBCLAUSE => 'principal',
+                FIELD => 'Disabled',
+                QUOTEVALUE => 0,
+                VALUE => 0,
+                ENTRYAGGREGATOR => 'AND',
+            );
+            $ACL->Limit(
+                ALIAS => $cgm_alias,
+                SUBCLAUSE => 'principal',
+                FIELD => 'MemberId',
+                VALUE => $principal->Id,
+                QUOTEVALUE => 0,
+                ENTRYAGGREGATOR => 'AND',
+            );
+        }
+    }
+
+    # now we need to address the unfortunate fact that ticket role
+    # members are not listed as queue role members. the way we do this
+    # is with a many-join query to map queue roles to ticket roles
+    if ($primary_records{principal} || $primary_records{object}) {
+        for my $inner_class (keys %ParentMap) {
+            next if $primary_records{object}
+                 && !$primary_records{object}->isa($inner_class);
+
+            my $query = $self->InnerRoleQuery(
+                inner_class  => $inner_class,
+                principal_id => ($primary_records{principal} ? $primary_records{principal}->Id : undef),
+                inner_id     => ($primary_records{object} ? $primary_records{object}->Id : undef),
+                right_search => $args{right},
+            );
+            my $sth = $ACL->_Handle->SimpleQuery($query);
+            my @acl_ids;
+            while (my ($acl_id, $record_id, $other_count) = $sth->fetchrow_array) {
+                push @acl_ids, $acl_id;
+                $inner_role{$acl_id} = [$inner_class, $record_id, $other_count];
+            }
+            if (@acl_ids) {
+                if (!$principal_paren) {
+                    $ACL->_OpenParen('principal');
+                    $principal_paren = 1;
+                }
+
+                $ACL->Limit(
+                    SUBCLAUSE => 'principal',
+                    FIELD     => 'id',
+                    OPERATOR  => 'IN',
+                    VALUE     => \@acl_ids,
+                    ENTRYAGGREGATOR => 'OR',
+                );
+            }
+        }
+    }
+
+    $ACL->_CloseParen('principal') if $principal_paren;
+
+    if ($args{continueAfter}) {
+        $has_search = 1;
+        $ACL->Limit(
+            FIELD     => 'id',
+            OPERATOR  => '>',
+            VALUE     => int($args{continueAfter}),
+            QUOTEVALUE => 0,
+        );
+    }
+
+    $ACL->OrderBy(
+        ALIAS => 'main',
+        FIELD => 'id',
+        ORDER => 'ASC',
+    );
+
+    $ACL->UnLimit unless $has_search;
+
+    $ACL->RowsPerPage($PageLimit);
+
+    my $continueAfter;
+
+    ACE: while (my $ACE = $ACL->Next) {
+        $continueAfter = $ACE->Id;
+        my $serialized = $self->SerializeACE($ACE, \%primary_records, \%inner_role);
+
+        for my $key (keys %filter_out) {
+            for my $term (@{ $filter_out{$key} }) {
+                my $re = qr/\Q$term\E/i;
+                if ($key eq 'right') {
+                    next ACE if $serialized->{right} =~ $re;
+                }
+                else {
+                    my $record = $serialized->{$key};
+                    next ACE if $record->{class}  =~ $re
+                             || $record->{id}     =~ $re
+                             || $record->{label}  =~ $re
+                             || $record->{detail} =~ $re;
+                }
+            }
+        }
+
+        KEY: for my $key (qw/principal object/) {
+            # filtering on the serialized record is hacky, but doing the
+            # searching in SQL is absolutely a nonstarter
+            next KEY unless $use_regex_search_for{$key};
+
+            if (my $term = $args{$key}) {
+                my $record = $serialized->{$key};
+                $term =~ s/^\s+//;
+                $term =~ s/\s+$//;
+                my $re = qr/\Q$term\E/i;
+                next KEY if $record->{class}  =~ $re
+                         || $record->{id}     =~ $re
+                         || $record->{label}  =~ $re
+                         || $record->{detail} =~ $re;
+
+                # no matches
+                next ACE;
+            }
+        }
+
+        $self->HighlightSerializedForSearch($serialized, \%args, \%use_regex_search_for);
+
+        push @results, $serialized;
+    }
+
+    return {
+        results => \@results,
+        continueAfter => $continueAfter,
+    };
+}
+
+# takes an ACE (singular version of ACL) and produces a JSON-serializable
+# dictionary for transmitting over the wire
+sub SerializeACE {
+    my $self = shift;
+    my $ACE = shift;
+    my $primary_records = shift;
+    my $inner_role = shift;
+
+    my $serialized = {
+        principal      => $self->SerializeRecord($ACE->PrincipalObj, $primary_records->{principal}),
+        object         => $self->SerializeRecord($ACE->Object, $primary_records->{object}),
+        right          => $ACE->RightName,
+        ace            => { id => $ACE->Id },
+        disable_revoke => $self->DisableRevoke($ACE),
+    };
+
+    if ($inner_role->{$ACE->Id}) {
+        $self->InjectSerializedWithInnerRoleDetails($serialized, $ACE, $inner_role->{$ACE->Id}, $primary_records);
+    }
+
+    return $serialized;
+}
+
+# should the "Revoke" button be disabled? by default it is for the two required
+# system privileges; if such privileges needed to be revoked they can be done
+# through the ordinary ACL management UI
+# it is also disabled for SuperUser, otherwise it is too easy to completely hose the system
+sub DisableRevoke {
+    my $self = shift;
+    my $ACE = shift;
+    my $Principal = $ACE->PrincipalObj;
+    my $Object    = $ACE->Object;
+    my $Right     = $ACE->RightName;
+
+    if ($Principal->Object->Domain eq 'ACLEquivalence') {
+        my $User = $Principal->Object->InstanceObj;
+        if ($Object->isa('RT::System') && $Right eq 'SuperUser') {
+            return 1;
+        }
+        if ($User->Id == RT->Nobody->Id && $Object->isa('RT::System') && $Right eq 'OwnTicket') {
+            return 1;
+        }
+    }
+
+    return 0;
+}
+
+# convert principal to its user/group, custom role group to its custom role, etc
+sub CanonicalizeRecord {
+    my $self = shift;
+    my $record = shift;
+
+    return undef unless $record;
+
+    if ($record->isa('RT::Principal')) {
+        $record = $record->Object;
+    }
+
+    if ($record->isa('RT::Group')) {
+        if ($record->Domain eq 'ACLEquivalence') {
+            my $principal = RT::Principal->new($record->CurrentUser);
+            $principal->Load($record->Instance);
+            $record = $principal->Object;
+        }
+        elsif ($record->Domain =~ /-Role$/) {
+            my ($id) = $record->Name =~ /^RT::CustomRole-(\d+)$/;
+            if ($id) {
+                my $role = RT::CustomRole->new($record->CurrentUser);
+                $role->Load($id);
+                $record = $role;
+            }
+        }
+    }
+
+    return $record;
+}
+
+# takes a user, group, ticket, queue, etc and produces a JSON-serializable
+# dictionary
+sub SerializeRecord {
+    my $self = shift;
+    my $record = shift;
+    my $primary_record = shift;
+
+    return undef unless $record;
+
+    $record = $self->CanonicalizeRecord($record);
+    $primary_record = $self->CanonicalizeRecord($primary_record);
+
+    undef $primary_record if $primary_record
+                          && ref($record) eq ref($primary_record)
+                          && $record->Id == $primary_record->Id;
+
+    my $serialized = {
+        class           => ref($record),
+        id              => $record->id,
+        label           => $self->LabelForRecord($record),
+        detail          => $self->DetailForRecord($record),
+        url             => $self->URLForRecord($record),
+        disabled        => $self->DisabledForRecord($record) ? JSON::true : JSON::false,
+        primary_record  => $self->SerializeRecord($primary_record),
+    };
+
+    return $serialized;
+}
+
+sub InjectSerializedWithInnerRoleDetails {
+    my $self = shift;
+    my $serialized = shift;
+    my $ACE = shift;
+    my $inner_role = shift;
+    my $primary_records = shift;
+
+    my $principal = $self->CanonicalizeRecord($ACE->PrincipalObj);
+    my $object = $self->CanonicalizeRecord($ACE->Object);
+    my $primary_principal = $self->CanonicalizeRecord($primary_records->{principal}) || $principal;
+    my $primary_object = $self->CanonicalizeRecord($primary_records->{object}) || $object;
+
+    if ($principal->isa('RT::Group') || $principal->isa('RT::CustomRole')) {
+        my ($inner_class, $inner_id, $inner_count) = @$inner_role;
+        my $inner_record = $inner_class->new($self->CurrentUser);
+        $inner_record->Load($inner_id);
+
+        $inner_class =~ s/^RT:://i;
+        my $detail = "$inner_class #$inner_id ";
+        $detail .= $principal->isa('RT::Group') ? 'Role' : 'CustomRole';
+
+        $serialized->{principal}{detail} = $detail;
+        $serialized->{principal}{detail_url} = $self->URLForRecord($inner_record);
+
+        if ($inner_count) {
+            $serialized->{principal}{detail_extra} = $self->CurrentUser->loc("(+[quant,_1,other,others])", $inner_count);
+
+            if ($inner_class eq 'Ticket' && $primary_principal->isa('RT::User')) {
+                my $query;
+                if ($ACE->Object->isa('RT::Queue')) {
+                    my $name = $ACE->Object->Name;
+                    $name =~ s/(['\\])/\\$1/g;
+                    $query .= "Queue = '$name' AND ";
+                }
+                my $user_name = $primary_principal->Name;
+                $user_name =~ s/(['\\])/\\$1/g;
+
+                my $role_name = $principal->Name;
+                $role_name =~ s/(['\\])/\\$1/g;
+
+                my $role_term = $principal->isa('RT::Group') ? $role_name
+                              : "CustomRole.{$role_name}";
+
+                $query .= "$role_term.Name = '$user_name'";
+
+                $serialized->{principal}{detail_extra_url} = RT->Config->Get('WebURL') . 'Search/Results.html?Query=' . _EscapeURI($query);
+            }
+        }
+    }
+}
+
+# primary display label for a record (e.g. user name, ticket subject)
+sub LabelForRecord {
+    my $self = shift;
+    my $record = shift;
+
+    if ($record->isa('RT::Ticket')) {
+        return $record->Subject || $self->CurrentUser->loc('(No subject)');
+    }
+
+    return $record->Name || $self->CurrentUser->loc('(No name)');
+}
+
+# boolean indicating whether the record should be labeled as disabled in the UI
+sub DisabledForRecord {
+    my $self = shift;
+    my $record = shift;
+
+    if ($record->can('Disabled') || $record->_Accessible('Disabled', 'read')) {
+        return $record->Disabled;
+    }
+
+    return 0;
+}
+
+# secondary detail information for a record (e.g. ticket #)
+sub DetailForRecord {
+    my $self = shift;
+    my $record = shift;
+
+    my $id = $record->Id;
+
+    return 'Global System' if $record->isa('RT::System');
+
+    return 'System User' if $record->isa('RT::User')
+                         && ($id == RT->SystemUser->Id || $id == RT->Nobody->Id);
+
+    # like RT::Group->SelfDescription but without the redundant labels
+    if ($record->isa('RT::Group')) {
+        if ($record->RoleClass) {
+            my $class = $record->RoleClass;
+            $class =~ s/^RT:://i;
+            return "$class Role";
+        }
+        elsif ($record->Domain eq 'SystemInternal') {
+            return "System Group";
+        }
+    }
+
+    my $type = ref($record);
+    $type =~ s/^RT:://;
+
+    return $type . ' #' . $id;
+}
+
+# most appropriate URL for a record. admin UI preferred, but for objects without
+# admin UI (such as ticket) then user UI is fine
+sub URLForRecord {
+    my $self = shift;
+    my $record = shift;
+    my $id = $record->id;
+
+    if ($record->isa('RT::Queue')) {
+        return RT->Config->Get('WebURL') . 'Admin/Queues/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::User')) {
+        return undef if $id == RT->SystemUser->id
+                     || $id == RT->Nobody->id;
+
+        return RT->Config->Get('WebURL') . 'Admin/Users/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Group')) {
+        if ($record->Domain eq 'UserDefined') {
+            return RT->Config->Get('WebURL') . 'Admin/Groups/Modify.html?id=' . $id;
+        }
+        elsif ($record->Domain eq 'RT::System-Role') {
+            return RT->Config->Get('WebURL') . 'Admin/Global/GroupRights.html#acl-' . $id;
+        }
+        elsif ($record->Domain eq 'RT::Queue-Role') {
+            return RT->Config->Get('WebURL') . 'Admin/Queues/GroupRights.html?id=' . $record->Instance . '#acl-' . $id;
+        }
+        elsif ($record->Domain eq 'RT::Catalog-Role') {
+            return RT->Config->Get('WebURL') . 'Admin/Assets/Catalogs/GroupRights.html?id=' . $record->Instance . '#acl-' . $id;
+        }
+        else {
+            return undef;
+        }
+    }
+    elsif ($record->isa('RT::CustomField')) {
+        return RT->Config->Get('WebURL') . 'Admin/CustomFields/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Class')) {
+        return RT->Config->Get('WebURL') . 'Admin/Articles/Classes/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Catalog')) {
+        return RT->Config->Get('WebURL') . 'Admin/Assets/Catalogs/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::CustomRole')) {
+        return RT->Config->Get('WebURL') . 'Admin/CustomRoles/Modify.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Ticket')) {
+        return RT->Config->Get('WebURL') . 'Ticket/Display.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Asset')) {
+        return RT->Config->Get('WebURL') . 'Asset/Display.html?id=' . $id;
+    }
+    elsif ($record->isa('RT::Article')) {
+        return RT->Config->Get('WebURL') . 'Articles/Article/Display.html?id=' . $id;
+    }
+
+    return undef;
+}
+
+1;
diff --git a/share/html/Admin/Tools/RightsInspector.html b/share/html/Admin/Tools/RightsInspector.html
new file mode 100644
index 000000000..176cbbcad
--- /dev/null
+++ b/share/html/Admin/Tools/RightsInspector.html
@@ -0,0 +1,116 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+<& /Admin/Elements/Header, Title => loc("Rights Inspector") &>
+<& /Elements/Tabs &>
+
+<div class="help" id="rt-help-text">
+<&| /Widgets/TitleBox, title => loc('Usage Help') &>
+<p>The Rights Inspector lets you search your configured permissions based
+on several different criteria. Each permission consists of a "Principal"
+(which user, group, or role has the permission), an "Object" (what
+record they have permissions on), and a "Right" (the name of the
+permission). Rights Inspector lets you filter your permissions for any
+combination of those three.</p>
+
+<p>The "Right" field lets you specify partial and/or multiple rights
+(e.g. searching <kbd>Ticket</kbd> will match both "ShowTicket" and
+"ModifyTicket", while <kbd>ShowAsset ShowCatalog</kbd> will show results
+for both rights). Since "SuperUser" provides every other right, it will
+also be included in results when applicable.</p>
+
+<p>The "Principal" and "Object" search fields by default work based on
+filtering. For example typing Principal <kbd>arch</kbd> will show
+permissions granted to the user "Archibald", the Group "Monarchs", the
+custom role "Researcher", and so on. You can also filter using other RT
+concepts by providing search terms like <kbd>user</kbd>,
+<kbd>article</kbd>, and so on.</p>
+
+<p>Alternatively, these two search fields support a special mode where
+you may specify a unique record directly using syntax like
+<kbd>group:Sales</kbd>. This will show recursive memberships (such as
+rights granted to any groups that the Sales group is a member of). It
+will also show rights granted by being a member of an individual
+ticket's or asset's role groups. Similarly, searching for a specific
+ticket with syntax like <kbd>t:10</kbd> will show you the permissions
+for that single ticket and its queue.</p>
+
+<p>Any word prefixed with a <kbd>!</kbd> will be filtered out from the
+search results, for example searching for right
+<kbd>ShowTicket !SuperUser</kbd>.</p>
+
+<p>For example, to help answer the question "why can Joe see asset #39?"
+you may specify principal <kbd>user:Joe</kbd>, object <kbd>asset
+#39</kbd>, right <kbd>ShowAsset</kbd>. This will produce multiple
+results if Joe has access due to multiple different reasons.</p>
+</&>
+</div>
+
+<&|/Widgets/TitleBox, title => loc("Rights Inspector") &>
+<form action="<%RT->Config->Get('WebPath')%>/Helpers/RightsInspector/Search" id="rights-inspector">
+  <div class="search row">
+    <input class="col-md-3" value="<% $ARGS{Principal} %>" type="text" name="principal" placeholder="Principal">
+    <input class="col-md-3" value="<% $ARGS{Object} %>" type="text" name="object" placeholder="Object">
+    <input class="col-md-3" value="<% $ARGS{Right} %>" type="text" name="right" placeholder="Right">
+    <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+  </div>
+
+  <script type="text/javascript" src="<%RT->Config->Get('WebPath')%>/static/js/rights-inspector.js"></script>
+
+  <div class="results">
+  </div>
+  <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+</form>
+</&>
+
+
+
+<%INIT>
+unless ($session{'CurrentUser'}->HasRight( Object=> RT->System, Right => 'SuperUser')) {
+    Abort(loc('This feature is only available to system administrators.'));
+}
+</%INIT>
diff --git a/share/html/Helpers/RightsInspector/Revoke b/share/html/Helpers/RightsInspector/Revoke
new file mode 100644
index 000000000..c218c00d4
--- /dev/null
+++ b/share/html/Helpers/RightsInspector/Revoke
@@ -0,0 +1,65 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+<%ARGS>
+$id => undef
+</%ARGS>
+<%INIT>
+my $ACE = RT::ACE->new($session{CurrentUser});
+$ACE->Load($id);
+
+my $Principal = $ACE->PrincipalObj;
+my $Object    = $ACE->Object;
+my $Right     = $ACE->RightName;
+
+my ($ok, $msg) = $Principal->RevokeRight(Object => $Object, Right => $Right);
+
+$r->content_type('application/json; charset=utf-8');
+$m->out(JSON({ok => $ok, msg => $msg}));
+$m->abort;
+</%INIT>
+
diff --git a/share/html/Helpers/RightsInspector/Search b/share/html/Helpers/RightsInspector/Search
new file mode 100644
index 000000000..d6cafd1b3
--- /dev/null
+++ b/share/html/Helpers/RightsInspector/Search
@@ -0,0 +1,56 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+<%INIT>
+use RT::RightsInspector;
+
+my $results = RT::RightsInspector->Search(%ARGS);
+$r->content_type('application/json; charset=utf-8');
+RT::Interface::Web::CacheControlExpiresHeaders( Time => 'no-cache' );
+$m->out(JSON($results));
+$m->abort;
+</%INIT>
diff --git a/share/static/css/elevator-light/admin.css b/share/static/css/elevator-light/admin.css
index 152aaa967..46cb98e14 100644
--- a/share/static/css/elevator-light/admin.css
+++ b/share/static/css/elevator-light/admin.css
@@ -21,3 +21,81 @@
     font-size: 0.8em;
     white-space: normal;
 }
+
+
+
+#rights-inspector .search .loading,
+#rights-inspector > .loading {
+    display: none;
+}
+
+#rights-inspector .search .loading img,
+#rights-inspector > .loading img,
+#rights-inspector .results .revoke img {
+    height: 1.5em;
+    width: 1.5em;
+}
+
+#rights-inspector.awaiting-first-result .search .loading {
+    display: inline;
+}
+
+#rights-inspector.continuing-load > .loading {
+    display: inline;
+}
+
+#rights-inspector.error .results {
+    color: red;
+    font-weight: bold;
+    
+}
+
+#rights-inspector.awaiting-first-result .results {
+    opacity: 0.5;
+}
+
+#rights-inspector .results .result:nth-child(even) {
+    background-color: rgb(236, 246, 252);
+}
+
+#rights-inspector .results .result .match {
+    background-color: rgb(255, 253, 56);
+    font-weight: bold;
+}
+
+#rights-inspector .results .result .name {
+    display: block;
+}
+
+#rights-inspector .results .result .record.disabled .name {
+    text-decoration: line-through;
+}
+
+#rights-inspector .results .result .detail {
+    font-size: 80%;
+    color: #AAA;
+    display: inline-block;
+}
+
+#rights-inspector .results .result .primary {
+    font-size: 80%;
+    font-style: italic;
+    display: inline-block;
+    padding-left: 3px;
+}
+
+#rights-inspector .results .result .primary .name {
+    display: inline-block;
+}
+
+#rights-inspector .results .result .primary .detail {
+    display: none;
+}
+
+#rt-help-text kbd {
+    color: #111;
+    border: 1px dashed #AAA;
+    background-color: #F6F6F6;
+    padding: 2px 3px;
+}
+
diff --git a/share/static/images/loading.gif b/share/static/images/loading.gif
new file mode 100644
index 000000000..3288d1035
Binary files /dev/null and b/share/static/images/loading.gif differ
diff --git a/share/static/js/rights-inspector.js b/share/static/js/rights-inspector.js
new file mode 100644
index 000000000..a0b199a19
--- /dev/null
+++ b/share/static/js/rights-inspector.js
@@ -0,0 +1,216 @@
+jQuery(function () {
+
+    var form = jQuery('form#rights-inspector');
+    var display = form.find('.results');
+    var loading = form.find('.search .loading');
+
+    var revoking = {};
+    var existingRequest;
+    var requestTimer;
+
+    var buttonForAction = function (action) {
+        return display.find('.revoke button[data-action="' + action + '"]');
+    };
+
+    var displayRevoking = function (button) {
+        if (button.hasClass('ui-state-disabled')) {
+            return;
+        }
+
+        button.addClass('ui-state-disabled').prop('disabled', true);
+        button.after(loading.clone());
+    };
+
+    var displayError = function (message) {
+        form.removeClass('awaiting-first-result').removeClass('continuing-load').addClass('error');
+        display.empty();
+        display.text('Error: ' + message);
+    }
+
+    var requestPage;
+    requestPage = function (search, continueAfter) {
+        search.continueAfter = continueAfter;
+
+        if (requestTimer) {
+            clearTimeout(requestTimer);
+            requestTimer = null;
+        }
+
+        existingRequest = jQuery.ajax({
+            url: form.attr('action'),
+            data: search,
+            timeout: 30000, /* 30 seconds */
+            success: function (response) {
+                if (response.error) {
+                    displayError(response.error);
+                    return;
+                }
+
+                form.removeClass('error');
+
+                var items = response.results;
+
+                /* change UI only after we find a result */
+                if (items.length && form.hasClass('awaiting-first-result')) {
+                    display.empty();
+                    form.removeClass('awaiting-first-result').addClass('continuing-load');
+                }
+
+                jQuery.each(items, function (i, item) {
+                    display.append( render_inspector_result( item ) );
+                });
+
+                jQuery.each(revoking, function (key, value) {
+                    var revokeButton = buttonForAction(key);
+                    displayRevoking(revokeButton);
+                });
+
+                if (response.continueAfter) {
+                    requestPage(search, response.continueAfter);
+                }
+                else {
+                    form.removeClass('continuing-load');
+
+                    if (form.hasClass('awaiting-first-result')) {
+                        display.empty();
+                        form.removeClass('awaiting-first-result');
+                        display.text('No results');
+                    }
+                }
+            },
+            error: function (xhr, reason) {
+                if (reason == 'abort') {
+                    return;
+                }
+
+                displayError(xhr.statusText);
+            }
+        });
+    };
+
+    var beginSearch = function (delay) {
+        form.removeClass('continuing-load').addClass('awaiting-first-result');
+        form.find('button').addClass('ui-state-disabled').prop('disabled', true);
+
+        var serialized = form.serializeArray();
+        var search = {};
+
+        jQuery.each(serialized, function(i, field){
+            search[field.name] = field.value;
+        });
+
+        if (requestTimer) {
+            clearTimeout(requestTimer);
+            requestTimer = null;
+        }
+
+        if (existingRequest) {
+            existingRequest.abort();
+        }
+
+        if (delay) {
+            requestTimer = setTimeout(function () {
+                requestPage(search, 0);
+            }, delay);
+        }
+        else {
+            requestPage(search, 0);
+        }
+    };
+
+    display.on('click', '.revoke button', function (e) {
+        e.preventDefault();
+        var button = jQuery(e.target);
+        var action = button.data('action');
+
+        displayRevoking(button);
+
+        revoking[action] = 1;
+
+        jQuery.ajax({
+            url: action,
+            timeout: 30000, /* 30 seconds */
+            success: function (response) {
+                button = buttonForAction(action);
+                if (!button.length) {
+                    alert(response.msg);
+                }
+                else {
+                    button.closest('.revoke').text(response.msg);
+                }
+                delete revoking[action];
+            },
+            error: function (xhr, reason) {
+                button = buttonForAction(action);
+                button.closest('.revoke').text(reason);
+                delete revoking[action];
+                alert(reason);
+            }
+        });
+    });
+
+    form.find('.search input').on('input', function () {
+        beginSearch(200);
+    });
+
+    beginSearch();
+});
+
+
+function render_inspector_record (record) {
+    return '<span class="record ' + cond_text( record.disabled, 'disabled') + '">'
+        +  '  <span class="name ' + cond_text( record.highlight, record.match) + '">'
+        +       link_or_text( record.label_highlighted, record.url)
+        +  '  </span>'
+        +  '  <span class="detail">'
+        +       link_or_text( record.detail_highlighted, record.detail_url)
+        +       link_or_text( record.detail_extra, record.detail_extra_url)
+        +       cond_text( record.disabled, '(disabled)')
+        +  '  </span>'
+        +     render_inspector_primary_record( record.primary_record)
+        +  '</span>'
+    ;
+
+}
+
+// rendering functions
+
+function render_inspector_primary_record (primary_record) {
+    return primary_record ? '<span class="primary">Contains ' + render_inspector_record( primary_record) + '</span>'
+                          : '';
+}
+
+function link_or_text (text, url) {
+    if( typeof text == 'undefined') {
+        return '';
+    }
+    else if( url && url.length > 0 ) {
+        return '<a target="_blank" href="' + url + '">' + text + '</a>';
+    }
+    else {
+        return text;
+    }
+}
+
+function render_inspector_result (item) {
+    var disabled_state = item.disable_revoke ? ' disabled="disabled"' : '';
+    var disabled_class = item.disable_revoke ? ' ui-state-disabled'   : '';
+    var revoke_action  = RT.Config.WebPath + '/Helpers/RightsInspector/Revoke?id=' + item.ace.id;
+
+    return '<div class="result row">'
+        +  '  <div class="principal cell col-md-3">' + render_inspector_record( item.principal) + '</div>'
+        +  '  <div class="object cell col-md-3">' + render_inspector_record( item.object) + '</div>'
+        +  '  <div class="right cell col-md-3">' + item.right_highlighted + '</div>'
+        +  '  <div class="revoke cell col-md-1">'
+        +  '      <button type="button" class="revoke-button' + disabled_class + '"'
+        +             ' data-action="' + revoke_action + '" '
+        +             disabled_state + '>Revoke</button>'
+        + '  </div>'
+        + '</div>'
+    ;
+}
+
+function cond_text ( cond, text = '') {
+    return cond ? text : '';
+}
+

commit cd5a7d820c295a0330b427702e008fac576d6557
Author: michel <michel at bestpractical.com>
Date:   Thu Sep 5 22:33:49 2019 +0200

    Improve formating with new themes

diff --git a/lib/RT/RightsInspector.pm b/lib/RT/RightsInspector.pm
index 50307c004..eac2a5cf9 100644
--- a/lib/RT/RightsInspector.pm
+++ b/lib/RT/RightsInspector.pm
@@ -64,7 +64,7 @@ use warnings;
 #     record     - generalization of principal and object since rendering
 #                  and whatnot can share code
 
-my $PageLimit = 10;
+my $PageLimit = 100;
 
 $RT::Interface::Web::WHITELISTED_COMPONENT_ARGS{'/Admin/RightsInspector/index.html'} = ['Principal', 'Object', 'Right'];
 
diff --git a/share/html/Admin/Tools/RightsInspector.html b/share/html/Admin/Tools/RightsInspector.html
index 176cbbcad..5372af48a 100644
--- a/share/html/Admin/Tools/RightsInspector.html
+++ b/share/html/Admin/Tools/RightsInspector.html
@@ -1,50 +1,50 @@
-# BEGIN BPS TAGGED BLOCK {{{
-#
-# COPYRIGHT:
-#
-# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
-#                                          <sales at bestpractical.com>
-#
-# (Except where explicitly superseded by other copyright notices)
-#
-#
-# LICENSE:
-#
-# This work is made available to you under the terms of Version 2 of
-# the GNU General Public License. A copy of that license should have
-# been provided with this software, but in any event can be snarfed
-# from www.gnu.org.
-#
-# This work 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 or visit their web page on the internet at
-# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-#
-#
-# CONTRIBUTION SUBMISSION POLICY:
-#
-# (The following paragraph is not intended to limit the rights granted
-# to you to modify and distribute this software under the terms of
-# the GNU General Public License and is only of importance to you if
-# you choose to contribute your changes and enhancements to the
-# community by submitting them to Best Practical Solutions, LLC.)
-#
-# By intentionally submitting any modifications, corrections or
-# derivatives to this work, or any other work intended for use with
-# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-# you are the copyright holder for those contributions and you grant
-# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-# royalty-free, perpetual, license to use, copy, create derivative
-# works based on those contributions, and sublicense and distribute
-# those contributions and any derivatives thereof.
-#
-# END BPS TAGGED BLOCK }}}
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
 <& /Admin/Elements/Header, Title => loc("Rights Inspector") &>
 <& /Elements/Tabs &>
 
@@ -91,19 +91,16 @@ results if Joe has access due to multiple different reasons.</p>
 </div>
 
 <&|/Widgets/TitleBox, title => loc("Rights Inspector") &>
-<form action="<%RT->Config->Get('WebPath')%>/Helpers/RightsInspector/Search" id="rights-inspector">
-  <div class="search row">
-    <input class="col-md-3" value="<% $ARGS{Principal} %>" type="text" name="principal" placeholder="Principal">
-    <input class="col-md-3" value="<% $ARGS{Object} %>" type="text" name="object" placeholder="Object">
-    <input class="col-md-3" value="<% $ARGS{Right} %>" type="text" name="right" placeholder="Right">
-    <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+<form action="<%RT->Config->Get('WebPath')%>/Helpers/RightsInspector/Search" id="rights-inspector" class="search">
+  <div class="row">
+    <input class="col-md-3 form-control" value="<% $ARGS{Principal} %>" type="text" name="principal" placeholder="Principal">
+    <input class="col-md-3 form-control" value="<% $ARGS{Object} %>" type="text" name="object" placeholder="Object">
+    <input class="col-md-3 form-control" value="<% $ARGS{Right} %>" type="text" name="right" placeholder="Right">
   </div>
 
-  <script type="text/javascript" src="<%RT->Config->Get('WebPath')%>/static/js/rights-inspector.js"></script>
-
   <div class="results">
   </div>
-  <span class="loading"><img src="<%RT->Config->Get('WebPath')%>/static/images/loading.gif" alt="<%loc('Loading')%>" title="<%loc('Loading')%>" /></span>
+  <& /Widgets/Spinner &>
 </form>
 </&>
 
diff --git a/share/html/Helpers/RightsInspector/Search b/share/html/Helpers/RightsInspector/Search
index d6cafd1b3..d36bd7574 100644
--- a/share/html/Helpers/RightsInspector/Search
+++ b/share/html/Helpers/RightsInspector/Search
@@ -47,7 +47,6 @@
 # END BPS TAGGED BLOCK }}}
 <%INIT>
 use RT::RightsInspector;
-
 my $results = RT::RightsInspector->Search(%ARGS);
 $r->content_type('application/json; charset=utf-8');
 RT::Interface::Web::CacheControlExpiresHeaders( Time => 'no-cache' );
diff --git a/share/html/Widgets/Spinner b/share/html/Widgets/Spinner
new file mode 100644
index 000000000..eb4a5d42b
--- /dev/null
+++ b/share/html/Widgets/Spinner
@@ -0,0 +1,55 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<div class="spinner">
+  <div class="d-flex justify-content-center">
+    <div class="spinner-border" role="status">
+      <span class="sr-only"><% loc( "Loading...") %></span> 
+    </div>
+  </div>
+</div>
+
diff --git a/share/static/css/elevator-light/admin.css b/share/static/css/elevator-light/admin.css
index 46cb98e14..dcf4bf5d2 100644
--- a/share/static/css/elevator-light/admin.css
+++ b/share/static/css/elevator-light/admin.css
@@ -24,26 +24,10 @@
 
 
 
-#rights-inspector .search .loading,
-#rights-inspector > .loading {
+#rights-inspector .loading {
     display: none;
 }
 
-#rights-inspector .search .loading img,
-#rights-inspector > .loading img,
-#rights-inspector .results .revoke img {
-    height: 1.5em;
-    width: 1.5em;
-}
-
-#rights-inspector.awaiting-first-result .search .loading {
-    display: inline;
-}
-
-#rights-inspector.continuing-load > .loading {
-    display: inline;
-}
-
 #rights-inspector.error .results {
     color: red;
     font-weight: bold;
@@ -54,7 +38,11 @@
     opacity: 0.5;
 }
 
-#rights-inspector .results .result:nth-child(even) {
+#rights-inspector.awaiting-first-result .loading, .continuing-load .loading {
+    display: inline;
+}
+
+#rights-inspector .results .result:nth-child(even) .cell {
     background-color: rgb(236, 246, 252);
 }
 
@@ -92,6 +80,15 @@
     display: none;
 }
 
+#rights-inspector .results .result .right {
+    padding-top: .4em;
+}
+
+#rights-inspector .results .result .revoke {
+    padding-top: .3em;
+}
+
+
 #rt-help-text kbd {
     color: #111;
     border: 1px dashed #AAA;
diff --git a/share/static/js/rights-inspector.js b/share/static/js/rights-inspector.js
index a0b199a19..05e148b24 100644
--- a/share/static/js/rights-inspector.js
+++ b/share/static/js/rights-inspector.js
@@ -1,8 +1,7 @@
-jQuery(function () {
-
+jQuery(document).ready( function () {
     var form = jQuery('form#rights-inspector');
     var display = form.find('.results');
-    var loading = form.find('.search .loading');
+    var spinner = form.find('.spinner');
 
     var revoking = {};
     var existingRequest;
@@ -18,7 +17,7 @@ jQuery(function () {
         }
 
         button.addClass('ui-state-disabled').prop('disabled', true);
-        button.after(loading.clone());
+        button.after(spinner.clone());
     };
 
     var displayError = function (message) {
@@ -76,6 +75,7 @@ jQuery(function () {
                         form.removeClass('awaiting-first-result');
                         display.text('No results');
                     }
+                jQuery( '.spinner').hide();
                 }
             },
             error: function (xhr, reason) {
@@ -91,6 +91,7 @@ jQuery(function () {
     var beginSearch = function (delay) {
         form.removeClass('continuing-load').addClass('awaiting-first-result');
         form.find('button').addClass('ui-state-disabled').prop('disabled', true);
+        jQuery( '.spinner').show();
 
         var serialized = form.serializeArray();
         var search = {};
@@ -136,7 +137,7 @@ jQuery(function () {
                     alert(response.msg);
                 }
                 else {
-                    button.closest('.revoke').text(response.msg);
+                    button.closest('.revoke').removeClass('col-md-1').addClass('col-md-3').text(response.msg);
                 }
                 delete revoking[action];
             },
@@ -149,7 +150,7 @@ jQuery(function () {
         });
     });
 
-    form.find('.search input').on('input', function () {
+    form.find('input').on('input', function () {
         beginSearch(200);
     });
 
@@ -157,6 +158,8 @@ jQuery(function () {
 });
 
 
+// rendering functions
+
 function render_inspector_record (record) {
     return '<span class="record ' + cond_text( record.disabled, 'disabled') + '">'
         +  '  <span class="name ' + cond_text( record.highlight, record.match) + '">'
@@ -173,8 +176,6 @@ function render_inspector_record (record) {
 
 }
 
-// rendering functions
-
 function render_inspector_primary_record (primary_record) {
     return primary_record ? '<span class="primary">Contains ' + render_inspector_record( primary_record) + '</span>'
                           : '';

commit a5266b786adfbded4e2e24acac1e41121d04e172
Author: michel <michel at bestpractical.com>
Date:   Fri Sep 6 18:03:48 2019 +0200

    Improve look for the dark theme

diff --git a/share/static/css/elevator-dark/main.css b/share/static/css/elevator-dark/main.css
index 0ce5e757d..4e981fc10 100644
--- a/share/static/css/elevator-dark/main.css
+++ b/share/static/css/elevator-dark/main.css
@@ -153,3 +153,12 @@
 .darkmode .btn-outline:hover svg {
     fill: #171A1F;
 }
+
+.darkmode #rights-inspector .results .result:nth-child(even) * {
+    background-color: #111 !important;
+}
+
+.darkmode #rights-inspector .results .result .match {
+    background-color: #114 !important;
+}
+

-----------------------------------------------------------------------


More information about the rt-commit mailing list