[Rt-commit] rt branch, core-rightinspector, created. rt-4.4.4-371-g1eeb8b344
Michel Rodriguez
michel at bestpractical.com
Wed Sep 4 17:48:45 EDT 2019
The branch, core-rightinspector has been created
at 1eeb8b344889aaad0e4b629805e19a790d04d839 (commit)
- Log -----------------------------------------------------------------
commit 1eeb8b344889aaad0e4b629805e19a790d04d839
Author: michel <michel at bestpractical.com>
Date: Wed Sep 4 23:22:32 2019 +0200
Cored RT::Extension::RightsInspector
Missing: the stylesheet is not used, the javascript only works if RT is
at the top level of the web server (ie not in /rt/)
diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 806d23888..35b68aef0 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -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 permissions'),
+ 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..91a338162
--- /dev/null
+++ b/lib/RT/RightsInspector.pm
@@ -0,0 +1,827 @@
+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 = 100;
+
+$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
+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 ($User->Id == RT->SystemUser->Id && $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..aef640fad
--- /dev/null
+++ b/share/html/Admin/Tools/RightsInspector.html
@@ -0,0 +1,69 @@
+<& /Admin/Elements/Header, Title => loc("Rights Inspector") &>
+<& /Elements/Tabs &>
+
+<div class="help">
+<&| /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..21f36c1a9
--- /dev/null
+++ b/share/html/Helpers/RightsInspector/Revoke
@@ -0,0 +1,18 @@
+<%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..6affc7d95
--- /dev/null
+++ b/share/html/Helpers/RightsInspector/Search
@@ -0,0 +1,9 @@
+<%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..a11e68e1c 100644
--- a/share/static/css/elevator-light/admin.css
+++ b/share/static/css/elevator-light/admin.css
@@ -21,3 +21,91 @@
font-size: 0.8em;
white-space: normal;
}
+
+
+#rights-inspector .search input,
+#rights-inspector .results .result .cell {
+ display: inline-block;
+ width: 15em;
+}
+
+#rights-inspector .results .result .cell {
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+#rights-inspector .results .result .cell.revoke {
+ display: inline-block;
+ width: auto;
+}
+
+#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 .cell {
+ padding: 2px;
+ vertical-align: top;
+}
+
+#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 .detail {
+ font-size: 80%;
+ color: #AAA;
+ display: block;
+}
+
+#rights-inspector .results .result .record.disabled .name {
+ text-decoration: line-through;
+}
+
+#rights-inspector .results .result .primary {
+ font-size: 80%;
+ font-style: italic;
+ display: block;
+}
+
+#rights-inspector .results .result .primary .detail {
+ display: none;
+}
+
+#comp-Admin-RightsInspector kbd {
+ color: #111;
+ border: 1px dashed #AAA;
+ background-color: #F6F6F6;
+ padding: 1px;
+}
+
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..cd49fc819
--- /dev/null
+++ b/share/static/js/rights-inspector.js
@@ -0,0 +1,211 @@
+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 revoke = item.disable_revoke ? 'class="ui-state-disabled" disabled="disabled"' : '';
+ 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-2">'
+ + ' <button type="button" data-action="/Helpers/RightsInspector/Revoke?id=' + item.ace.id + '" ' + revoke + '>Revoke</button>'
+ + ' </div>'
+ + '</div>'
+ ;
+}
+
+function cond_text ( cond, text = '') {
+ return cond ? text : '';
+}
+
-----------------------------------------------------------------------
More information about the rt-commit
mailing list