X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=rt%2Flib%2FRT%2FUser.pm;h=b9570bd601f07052a419ddd508bdfa3e1113be89;hp=9b4a8268389826fdd4105070a64bf4eb27bc166a;hb=de9d037528895f7151a9aead6724ce2df95f9586;hpb=c24d6e2242ae0e026684b8f95decf156aba6e75e diff --git a/rt/lib/RT/User.pm b/rt/lib/RT/User.pm index 9b4a82683..b9570bd60 100755 --- a/rt/lib/RT/User.pm +++ b/rt/lib/RT/User.pm @@ -2,7 +2,7 @@ # # COPYRIGHT: # -# This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC +# This software is Copyright (c) 1996-2017 Best Practical Solutions, LLC # # # (Except where explicitly superseded by other copyright notices) @@ -66,6 +66,7 @@ package RT::User; use strict; use warnings; +use Scalar::Util qw(blessed); use base 'RT::Record'; @@ -78,31 +79,35 @@ sub Table {'Users'} use Digest::SHA; use Digest::MD5; +use Crypt::Eksblowfish::Bcrypt qw(); use RT::Principals; use RT::ACE; use RT::Interface::Email; -use Encode; use Text::Password::Pronounceable; +use RT::Util; sub _OverlayAccessible { { - Name => { public => 1, admin => 1 }, + Name => { public => 1, admin => 1 }, # loc_left_pair Password => { read => 0 }, - EmailAddress => { public => 1 }, - Organization => { public => 1, admin => 1 }, - RealName => { public => 1 }, - NickName => { public => 1 }, - Lang => { public => 1 }, + EmailAddress => { public => 1 }, # loc_left_pair + Organization => { public => 1, admin => 1 }, # loc_left_pair + RealName => { public => 1 }, # loc_left_pair + NickName => { public => 1 }, # loc_left_pair + Lang => { public => 1 }, # loc_left_pair EmailEncoding => { public => 1 }, WebEncoding => { public => 1 }, ExternalContactInfoId => { public => 1, admin => 1 }, ContactInfoSystem => { public => 1, admin => 1 }, ExternalAuthId => { public => 1, admin => 1 }, AuthSystem => { public => 1, admin => 1 }, - Gecos => { public => 1, admin => 1 }, - PGPKey => { public => 1, admin => 1 }, - + Gecos => { public => 1, admin => 1 }, # loc_left_pair + PGPKey => { public => 1, admin => 1 }, # loc_left_pair + SMIMECertificate => { public => 1, admin => 1 }, # loc_left_pair + City => { public => 1 }, # loc_left_pair + Country => { public => 1 }, # loc_left_pair + Timezone => { public => 1 }, # loc_left_pair } } @@ -166,18 +171,10 @@ sub Create { return ( 0, $self->loc("Must specify 'Name' attribute") ); } - #SANITY CHECK THE NAME AND ABORT IF IT'S TAKEN - if (RT->SystemUser) { #This only works if RT::SystemUser has been defined - my $TempUser = RT::User->new(RT->SystemUser); - $TempUser->Load( $args{'Name'} ); - return ( 0, $self->loc('Name in use') ) if ( $TempUser->Id ); - - my ($val, $message) = $self->ValidateEmailAddress( $args{'EmailAddress'} ); - return (0, $message) unless ( $val ); - } else { - $RT::Logger->warning( "$self couldn't check for pre-existing users"); - } - + my ( $val, $msg ) = $self->ValidateName( $args{'Name'} ); + return ( 0, $msg ) unless $val; + ( $val, $msg ) = $self->ValidateEmailAddress( $args{'EmailAddress'} ); + return ( 0, $msg ) unless ($val); $RT::Handle->BeginTransaction(); # Groups deal with principal ids, rather than user ids. @@ -269,6 +266,30 @@ sub Create { return ( $id, $self->loc('User created') ); } +=head2 ValidateName STRING + +Returns either (0, "failure reason") or 1 depending on whether the given +name is valid. + +=cut + +sub ValidateName { + my $self = shift; + my $name = shift; + + return ( 0, $self->loc('empty name') ) unless defined $name && length $name; + + my $TempUser = RT::User->new( RT->SystemUser ); + $TempUser->Load($name); + + if ( $TempUser->id && ( !$self->id || $TempUser->id != $self->id ) ) { + return ( 0, $self->loc('Name in use') ); + } + else { + return 1; + } +} + =head2 ValidatePassword STRING Returns either (0, "failure reason") or 1 depending on whether the given @@ -281,7 +302,7 @@ sub ValidatePassword { my $password = shift; if ( length($password) < RT->Config->Get('MinimumPasswordLength') ) { - return ( 0, $self->loc("Password needs to be at least [_1] characters long", RT->Config->Get('MinimumPasswordLength')) ); + return ( 0, $self->loc("Password needs to be at least [quant,_1,character,characters] long", RT->Config->Get('MinimumPasswordLength')) ); } return 1; @@ -343,6 +364,12 @@ sub _SetPrivileged { } my ($status, $msg) = $priv->_AddMember( InsideTransaction => 1, PrincipalId => $principal); if ($status) { + $self->_NewTransaction( + Type => 'Set', + Field => 'Privileged', + NewValue => 1, + OldValue => 0, + ); return (1, $self->loc("That user is now privileged")); } else { return (0, $msg); @@ -363,6 +390,12 @@ sub _SetPrivileged { } my ($status, $msg) = $unpriv->_AddMember( InsideTransaction => 1, PrincipalId => $principal); if ($status) { + $self->_NewTransaction( + Type => 'Set', + Field => 'Privileged', + NewValue => 0, + OldValue => 1, + ); return (1, $self->loc("That user is now unprivileged")); } else { return (0, $msg); @@ -534,8 +567,8 @@ sub LoadOrCreateByEmail { } } } - return (0, $message) unless $self->id; - return ($self->Id, $message); + return wantarray ? (0, $message) : 0 unless $self->id; + return wantarray ? ($self->Id, $message) : $self->Id; } =head2 ValidateEmailAddress ADDRESS @@ -571,6 +604,25 @@ sub ValidateEmailAddress { } } +=head2 SetName + +Check to make sure someone else isn't using this name already + +=cut + +sub SetName { + my $self = shift; + my $Value = shift; + + my ( $val, $message ) = $self->ValidateName($Value); + if ($val) { + return $self->_Set( Field => 'Name', Value => $Value ); + } + else { + return ( 0, $message ); + } +} + =head2 SetEmailAddress Check to make sure someone else isn't using this email address already @@ -594,25 +646,13 @@ sub SetEmailAddress { =head2 EmailFrequency -Takes optional Ticket argument in paramhash. Returns 'no email', -'squelched', 'daily', 'weekly' or empty string depending on -user preferences. - -=over 4 - -=item 'no email' - user has no email, so can not recieve notifications. - -=item 'squelched' - returned only when Ticket argument is provided and -notifications to the user has been supressed for this ticket. - -=item 'daily' - retruned when user recieve daily messages digest instead -of immediate delivery. +Takes optional Ticket argument in paramhash. Returns a string, suitable +for localization, describing any notable properties about email delivery +to the user. This includes lack of email address, ticket-level +squelching (if C is provided in the paramhash), or user email +delivery preferences. -=item 'weekly' - previous, but weekly. - -=item empty string returned otherwise. - -=back +Returns the empty string if there are no notable properties. =cut @@ -624,12 +664,18 @@ sub EmailFrequency { ); return '' unless $self->id && $self->id != RT->Nobody->id && $self->id != RT->SystemUser->id; - return 'no email address' unless my $email = $self->EmailAddress; - return 'email disabled for ticket' if $args{'Ticket'} && - grep lc $email eq lc $_->Content, $args{'Ticket'}->SquelchMailTo; + return 'no email address set' # loc + unless my $email = $self->EmailAddress; + return 'email disabled for ticket' # loc + if $args{'Ticket'} && + grep lc $email eq lc $_->Content, $args{'Ticket'}->SquelchMailTo; my $frequency = RT->Config->Get( 'EmailFrequency', $self ) || ''; - return 'daily' if $frequency =~ /daily/i; - return 'weekly' if $frequency =~ /weekly/i; + return 'receives daily digests' # loc + if $frequency =~ /daily/i; + return 'receives weekly digests' # loc + if $frequency =~ /weekly/i; + return 'email delivery suspended' # loc + if $frequency =~ /suspend/i; return ''; } @@ -831,6 +877,39 @@ sub SetPassword { } +sub _GeneratePassword_bcrypt { + my $self = shift; + my ($password, @rest) = @_; + + my $salt; + my $rounds; + if (@rest) { + # The first split is the number of rounds + $rounds = $rest[0]; + + # The salt is the first 22 characters, b64 encoded usign the + # special bcrypt base64. + $salt = Crypt::Eksblowfish::Bcrypt::de_base64( substr($rest[1], 0, 22) ); + } else { + $rounds = RT->Config->Get('BcryptCost'); + + # Generate a random 16-octet base64 salt + $salt = ""; + $salt .= pack("C", int rand(256)) for 1..16; + } + + my $hash = Crypt::Eksblowfish::Bcrypt::bcrypt_hash({ + key_nul => 1, + cost => $rounds, + salt => $salt, + }, Digest::SHA::sha512( Encode::encode( 'UTF-8', $password) ) ); + + return join("!", "", "bcrypt", sprintf("%02d", $rounds), + Crypt::Eksblowfish::Bcrypt::en_base64( $salt ). + Crypt::Eksblowfish::Bcrypt::en_base64( $hash ) + ); +} + sub _GeneratePassword_sha512 { my $self = shift; my ($password, $salt) = @_; @@ -844,7 +923,7 @@ sub _GeneratePassword_sha512 { my $sha = Digest::SHA->new(512); $sha->add($salt); - $sha->add(encode_utf8($password)); + $sha->add(Encode::encode( 'UTF-8', $password)); return join("!", "", "sha512", $salt, $sha->b64digest); } @@ -854,13 +933,13 @@ Returns a string to store in the database. This string takes the form: !method!salt!hash -By default, the method is currently C. +By default, the method is currently C. =cut sub _GeneratePassword { my $self = shift; - return $self->_GeneratePassword_sha512(@_); + return $self->_GeneratePassword_bcrypt(@_); } =head3 HasPassword @@ -909,9 +988,19 @@ sub IsPassword { my $stored = $self->__Value('Password'); if ($stored =~ /^!/) { # If it's a new-style (>= RT 4.0) password, it starts with a '!' - my (undef, $method, $salt, undef) = split /!/, $stored; - if ($method eq "sha512") { - return $self->_GeneratePassword_sha512($value, $salt) eq $stored; + my (undef, $method, @rest) = split /!/, $stored; + if ($method eq "bcrypt") { + return 0 unless RT::Util::constant_time_eq( + $self->_GeneratePassword_bcrypt($value, @rest), + $stored + ); + # Upgrade to a larger number of rounds if necessary + return 1 unless $rest[0] < RT->Config->Get('BcryptCost'); + } elsif ($method eq "sha512") { + return 0 unless RT::Util::constant_time_eq( + $self->_GeneratePassword_sha512($value, @rest), + $stored + ); } else { $RT::Logger->warn("Unknown hash method $method"); return 0; @@ -921,18 +1010,30 @@ sub IsPassword { my $hash = MIME::Base64::decode_base64($stored); # Decoding yields 30 byes; first 4 are the salt, the rest are substr(SHA256,0,26) my $salt = substr($hash, 0, 4, ""); - return 0 unless substr(Digest::SHA::sha256($salt . Digest::MD5::md5($value)), 0, 26) eq $hash; + return 0 unless RT::Util::constant_time_eq( + substr(Digest::SHA::sha256($salt . Digest::MD5::md5(Encode::encode( "UTF-8", $value))), 0, 26), + $hash, 1 + ); } elsif (length $stored == 32) { # Hex nonsalted-md5 - return 0 unless Digest::MD5::md5_hex(encode_utf8($value)) eq $stored; + return 0 unless RT::Util::constant_time_eq( + Digest::MD5::md5_hex(Encode::encode( "UTF-8", $value)), + $stored + ); } elsif (length $stored == 22) { # Base64 nonsalted-md5 - return 0 unless Digest::MD5::md5_base64(encode_utf8($value)) eq $stored; + return 0 unless RT::Util::constant_time_eq( + Digest::MD5::md5_base64(Encode::encode( "UTF-8", $value)), + $stored + ); } elsif (length $stored == 13) { # crypt() output - return 0 unless crypt(encode_utf8($value), $stored) eq $stored; + return 0 unless RT::Util::constant_time_eq( + crypt(Encode::encode( "UTF-8", $value), $stored), + $stored + ); } else { - $RT::Logger->warn("Unknown password form"); + $RT::Logger->warning("Unknown password form"); return 0; } @@ -952,8 +1053,8 @@ sub CurrentUserRequireToSetPassword { RequireCurrent => 1, ); - if ( RT->Config->Get('WebExternalAuth') - && !RT->Config->Get('WebFallbackToInternalAuth') + if ( RT->Config->Get('WebRemoteUserAuth') + && !RT->Config->Get('WebFallbackToRTLogin') ) { $res{'CanSet'} = 0; $res{'Reason'} = $self->loc("External authentication enabled."); @@ -1019,28 +1120,27 @@ sub GenerateAuthString { my $self = shift; my $protect = shift; - my $str = $self->AuthToken . $protect; - utf8::encode($str); + my $str = Encode::encode( "UTF-8", $self->AuthToken . $protect ); return substr(Digest::MD5::md5_hex($str),0,16); } =head3 ValidateAuthString -Takes auth string and protected string. Returns true is protected string +Takes auth string and protected string. Returns true if protected string has been protected by user's L. See also L. =cut sub ValidateAuthString { my $self = shift; - my $auth_string = shift; + my $auth_string_to_validate = shift; my $protected = shift; - my $str = $self->AuthToken . $protected; - utf8::encode( $str ); + my $str = Encode::encode( "UTF-8", $self->AuthToken . $protected ); + my $valid_auth_string = substr(Digest::MD5::md5_hex($str),0,16); - return $auth_string eq substr(Digest::MD5::md5_hex($str),0,16); + return RT::Util::constant_time_eq( $auth_string_to_validate, $valid_auth_string ); } =head2 SetDisabled @@ -1060,11 +1160,11 @@ sub SetDisabled { } $RT::Handle->BeginTransaction(); - my $set_err = $self->PrincipalObj->SetDisabled($val); - unless ($set_err) { + my ($status, $msg) = $self->PrincipalObj->SetDisabled($val); + unless ($status) { $RT::Handle->Rollback(); $RT::Logger->warning(sprintf("Couldn't %s user %s", ($val == 1) ? "disable" : "enable", $self->PrincipalObj->Id)); - return (undef); + return ($status, $msg); } $self->_NewTransaction( Type => ($val == 1) ? "Disabled" : "Enabled" ); @@ -1215,26 +1315,29 @@ public, ourself, or we have AdminUsers sub CurrentUserCanSee { my $self = shift; - my ($what) = @_; + my ($what, $txn) = @_; - # If it's public, fine. Note that $what may be "transaction", which - # doesn't have an Accessible value, and thus falls through below. - if ( $self->_Accessible( $what, 'public' ) ) { - return 1; - } + # If it's a public property, fine + return 1 if $self->_Accessible( $what, 'public' ); - # Users can see their own properties - elsif ( defined($self->Id) && $self->CurrentUser->Id == $self->Id ) { - return 1; - } + # Users can see all of their own properties + return 1 if defined($self->Id) and $self->CurrentUser->Id == $self->Id; # If the user has the admin users right, that's also enough - elsif ( $self->CurrentUser->HasRight( Right => 'AdminUsers', Object => $RT::System) ) { - return 1; - } - else { - return 0; + return 1 if $self->CurrentUserHasRight( 'AdminUsers' ); + + # Transactions of public properties are visible to users with ShowUserHistory + if ($what eq "Transaction" and $self->CurrentUserHasRight( 'ShowUserHistory' )) { + my $type = $txn->__Value('Type'); + my $field = $txn->__Value('Field'); + return 1 if $type eq "Set" and $self->CurrentUserCanSee($field, $txn); + + # RT::Transaction->CurrentUserCanSee deals with ensuring we meet + # the ACLs on CFs, so allow them here + return 1 if $type eq "CustomField"; } + + return 0; } =head2 CurrentUserCanModify RIGHT @@ -1294,7 +1397,7 @@ sub _PrefName { $name = ref($name).'-'.$name->Id; } - return 'Pref-'.$name; + return 'Pref-'. $name; } =head2 Preferences NAME/OBJ DEFAULT @@ -1307,13 +1410,11 @@ override the entries with user preferences. sub Preferences { my $self = shift; - my $name = _PrefName (shift); + my $name = _PrefName(shift); my $default = shift; - my $attr = RT::Attribute->new( $self->CurrentUser ); - $attr->LoadByNameAndObject( Object => $self, Name => $name ); - - my $content = $attr->Id ? $attr->Content : undef; + my ($attr) = $self->Attributes->Named( $name ); + my $content = $attr ? $attr->Content : undef; unless ( ref $content eq 'HASH' ) { return defined $content ? $content : $default; } @@ -1323,7 +1424,7 @@ sub Preferences { exists $content->{$_} or $content->{$_} = $default->{$_}; } } elsif (defined $default) { - $RT::Logger->error("Preferences $name for user".$self->Id." is hash but default is not"); + $RT::Logger->error("Preferences $name for user #".$self->Id." is hash but default is not"); } return $content; } @@ -1342,9 +1443,8 @@ sub SetPreferences { return (0, $self->loc("No permission to set preferences")) unless $self->CurrentUserCanModify('Preferences'); - my $attr = RT::Attribute->new( $self->CurrentUser ); - $attr->LoadByNameAndObject( Object => $self, Name => $name ); - if ( $attr->Id ) { + my ($attr) = $self->Attributes->Named( $name ); + if ( $attr ) { my ($ok, $msg) = $attr->SetContent( $value ); return (1, "No updates made") if $msg eq "That is already the current value"; @@ -1354,6 +1454,26 @@ sub SetPreferences { } } +=head2 DeletePreferences NAME/OBJ VALUE + +Delete user preferences associated with given object or name. + +=cut + +sub DeletePreferences { + my $self = shift; + my $name = _PrefName( shift ); + + return (0, $self->loc("No permission to set preferences")) + unless $self->CurrentUserCanModify('Preferences'); + + my ($attr) = $self->DeleteAttribute( $name ); + return (0, $self->loc("Preferences were not found")) + unless $attr; + + return 1; +} + =head2 Stylesheet Returns a list of valid stylesheets take from preferences. @@ -1366,10 +1486,8 @@ sub Stylesheet { my $style = RT->Config->Get('WebDefaultStylesheet', $self->CurrentUser); if (RT::Interface::Web->ComponentPathIsSafe($style)) { - my @css_paths = map { $_ . '/NoAuth/css' } RT::Interface::Web->ComponentRoots; - - for my $css_path (@css_paths) { - if (-d "$css_path/$style") { + for my $root (RT::Interface::Web->StaticRoots) { + if (-d "$root/css/$style") { return $style } } @@ -1392,7 +1510,7 @@ $user->WatchedQueues('Cc', 'AdminCc'); sub WatchedQueues { my $self = shift; - my @roles = @_ || ('Cc', 'AdminCc'); + my @roles = @_ ? @_ : ('Cc', 'AdminCc'); $RT::Logger->debug('WatcheQueues got user ' . $self->Name); @@ -1410,12 +1528,13 @@ sub WatchedQueues { FIELD => 'Domain', VALUE => 'RT::Queue-Role', ENTRYAGGREGATOR => 'AND', + CASESENSITIVE => 0, ); if (grep { $_ eq 'Cc' } @roles) { $watched_queues->Limit( SUBCLAUSE => 'LimitToWatchers', ALIAS => $group_alias, - FIELD => 'Type', + FIELD => 'Name', VALUE => 'Cc', ENTRYAGGREGATOR => 'OR', ); @@ -1424,7 +1543,7 @@ sub WatchedQueues { $watched_queues->Limit( SUBCLAUSE => 'LimitToWatchers', ALIAS => $group_alias, - FIELD => 'Type', + FIELD => 'Name', VALUE => 'AdminCc', ENTRYAGGREGATOR => 'OR', ); @@ -1526,9 +1645,134 @@ Return the friendly name sub FriendlyName { my $self = shift; - return $self->RealName if defined($self->RealName); - return $self->Name if defined($self->Name); - return ""; + return $self->RealName if defined $self->RealName and length $self->RealName; + return $self->Name; +} + +=head2 Format + +Class or object method. + +Returns a string describing a user in the current user's preferred format. + +May be invoked in three ways: + + $UserObj->Format; + RT::User->Format( User => $UserObj ); # same as above + RT::User->Format( Address => $AddressObj, CurrentUser => $CurrentUserObj ); + +Possible arguments are: + +=over + +=item User + +An L object representing the user to format. Preferred to Address. + +=item Address + +An L object representing the user address to format. Address +will be used to lookup an L if possible. + +=item CurrentUser + +Required when Format is called as a class method with an Address argument. +Otherwise, this argument is ignored in preference to the CurrentUser of the +involved L object. + +=item Format + +Specifies the format to use, overriding any set from the config or current +user's preferences. + +=back + +=cut + +sub Format { + my $self = shift; + my %args = ( + User => undef, + Address => undef, + CurrentUser => undef, + Format => undef, + @_ + ); + + if (blessed($self) and $self->id) { + @args{"User", "CurrentUser"} = ($self, $self->CurrentUser); + } + elsif ($args{User} and $args{User}->id) { + $args{CurrentUser} = $args{User}->CurrentUser; + } + elsif ($args{Address} and $args{CurrentUser}) { + $args{User} = RT::User->new( $args{CurrentUser} ); + $args{User}->LoadByEmail( $args{Address}->address ); + if ($args{User}->id) { + delete $args{Address}; + } else { + delete $args{User}; + } + } + else { + RT->Logger->warning("Invalid arguments to RT::User->Format at @{[join '/', caller]}"); + return ""; + } + + $args{Format} ||= RT->Config->Get("UsernameFormat", $args{CurrentUser}); + $args{Format} =~ s/[^A-Za-z0-9_]+//g; + + my $method = "_FormatUser" . ucfirst lc $args{Format}; + my $formatter = $self->can($method); + + unless ($formatter) { + RT->Logger->error( + "Either system config or user #" . $args{CurrentUser}->id . + " picked UsernameFormat $args{Format}, but RT::User->$method doesn't exist" + ); + $formatter = $self->can("_FormatUserRole"); + } + return $formatter->( $self, map { $_ => $args{$_} } qw(User Address) ); +} + +sub _FormatUserRole { + my $self = shift; + my %args = @_; + + my $user = $args{User}; + return $self->_FormatUserVerbose(@_) + unless $user and $user->Privileged; + + my $name = $user->Name; + $name .= " (".$user->RealName.")" + if $user->RealName and lc $user->RealName ne lc $user->Name; + return $name; +} + +sub _FormatUserConcise { + my $self = shift; + my %args = @_; + return $args{User} ? $args{User}->FriendlyName : $args{Address}->address; +} + +sub _FormatUserVerbose { + my $self = shift; + my %args = @_; + my ($user, $address) = @args{"User", "Address"}; + + my $email = ''; + my $phrase = ''; + my $comment = ''; + + if ($user) { + $email = $user->EmailAddress || ''; + $phrase = $user->RealName if $user->RealName and lc $user->RealName ne lc $email; + $comment = $user->Name if lc $user->Name ne lc $email; + } else { + ($email, $phrase, $comment) = (map { $address->$_ } "address", "phrase", "comment"); + } + + return join " ", grep { $_ } ($phrase || $comment || ''), ($email ? "<$email>" : ""); } =head2 PreferredKey @@ -1555,8 +1799,7 @@ sub PreferredKey return $prefkey->Content if $prefkey; # we don't have a preferred key for this user, so now we must query GPG - require RT::Crypt::GnuPG; - my %res = RT::Crypt::GnuPG::GetKeysForEncryption($self->EmailAddress); + my %res = RT::Crypt->GetKeysForEncryption($self->EmailAddress); return undef unless defined $res{'info'}; my @keys = @{ $res{'info'} }; return undef if @keys == 0; @@ -1594,7 +1837,8 @@ sub SetPrivateKey { my $self = shift; my $key = shift; - unless ($self->CurrentUserCanModify('PrivateKey')) { + # Users should not be able to change their own PrivateKey values + unless ( $self->CurrentUser->HasRight(Right => 'AdminUsers', Object => $RT::System) ) { return (0, $self->loc("Permission Denied")); } @@ -1609,7 +1853,7 @@ sub SetPrivateKey { # check that it's really private key { - my %tmp = RT::Crypt::GnuPG::GetKeysForSigning( $key ); + my %tmp = RT::Crypt->GetKeysForSigning( Signer => $key, Protocol => 'GnuPG' ); return (0, $self->loc("No such key or it's not suitable for signing")) if $tmp{'exit_code'} || !$tmp{'info'}; } @@ -1623,6 +1867,21 @@ sub SetPrivateKey { return ($status, $self->loc("Set private key")); } +sub SetLang { + my $self = shift; + my ($lang) = @_; + + unless ($self->CurrentUserCanModify('Lang')) { + return (0, $self->loc("Permission Denied")); + } + + # Local hack to cause the result message to be in the _new_ language + # if we're updating ourselves + $self->CurrentUser->{LangHandle} = RT::I18N->get_handle( $lang ) + if $self->CurrentUser->id == $self->id; + return $self->_Set( Field => 'Lang', Value => $lang ); +} + sub BasicColumns { ( [ Name => 'Username' ], @@ -1632,6 +1891,79 @@ sub BasicColumns { ); } +=head2 Bookmarks + +Returns an unordered list of IDs representing the user's bookmarked tickets. + +=cut + +sub Bookmarks { + my $self = shift; + my $bookmarks = $self->FirstAttribute('Bookmarks'); + return if !$bookmarks; + + $bookmarks = $bookmarks->Content; + return if !$bookmarks; + + return keys %$bookmarks; +} + +=head2 HasBookmark TICKET + +Returns whether the provided ticket is bookmarked by the user. + +=cut + +sub HasBookmark { + my $self = shift; + my $ticket = shift; + my $id = $ticket->id; + + # maintain bookmarks across merges + my @ids = ($id, $ticket->Merged); + + my $bookmarks = $self->FirstAttribute('Bookmarks'); + $bookmarks = $bookmarks ? $bookmarks->Content : {}; + + my @bookmarked = grep { $bookmarks->{ $_ } } @ids; + return @bookmarked ? 1 : 0; +} + +=head2 ToggleBookmark TICKET + +Toggles whether the provided ticket is bookmarked by the user. + +=cut + +sub ToggleBookmark { + my $self = shift; + my $ticket = shift; + my $id = $ticket->id; + + # maintain bookmarks across merges + my @ids = ($id, $ticket->Merged); + + my $bookmarks = $self->FirstAttribute('Bookmarks'); + $bookmarks = $bookmarks ? $bookmarks->Content : {}; + + my $is_bookmarked; + + if ( grep { $bookmarks->{ $_ } } @ids ) { + delete $bookmarks->{ $_ } foreach @ids; + $is_bookmarked = 0; + } else { + $bookmarks->{ $id } = 1; + $is_bookmarked = 1; + } + + $self->SetAttribute( + Name => 'Bookmarks', + Content => $bookmarks, + ); + + return $is_bookmarked; +} + =head2 Create PARAMHASH Create takes a hash of values and creates a row in the database: @@ -2221,6 +2553,24 @@ Returns (1, 'Status message') on success and (0, 'Error Message') on failure. =cut +=head2 SMIMECertificate + +Returns the current value of SMIMECertificate. +(In the database, SMIMECertificate is stored as text.) + + + +=head2 SetSMIMECertificate VALUE + + +Set SMIMECertificate to VALUE. +Returns (1, 'Status message') on success and (0, 'Error Message') on failure. +(In the database, SMIMECertificate will be stored as a text.) + + +=cut + + =head2 Creator Returns the current value of Creator. @@ -2519,6 +2869,8 @@ sub _CoreAccessible { {read => 1, write => 1, sql_type => 12, length => 50, is_blob => 0, is_numeric => 0, type => 'varchar(50)', default => ''}, PGPKey => {read => 1, write => 1, sql_type => -4, length => 0, is_blob => 1, is_numeric => 0, type => 'text', default => ''}, + SMIMECertificate => + {read => 1, write => 1, sql_type => -4, length => 0, is_blob => 1, is_numeric => 0, type => 'text', default => ''}, Creator => {read => 1, auto => 1, sql_type => 4, length => 11, is_blob => 0, is_numeric => 1, type => 'int(11)', default => '0'}, Created => @@ -2531,6 +2883,219 @@ sub _CoreAccessible { } }; +sub UID { + my $self = shift; + return undef unless defined $self->Name; + return "@{[ref $self]}-@{[$self->Name]}"; +} + +sub FindDependencies { + my $self = shift; + my ($walker, $deps) = @_; + + $self->SUPER::FindDependencies($walker, $deps); + + # ACL equivalence group + my $objs = RT::Groups->new( $self->CurrentUser ); + $objs->Limit( FIELD => 'Domain', VALUE => 'ACLEquivalence', CASESENSITIVE => 0 ); + $objs->Limit( FIELD => 'Instance', VALUE => $self->Id ); + $deps->Add( in => $objs ); + + # Memberships in SystemInternal groups + $objs = RT::GroupMembers->new( $self->CurrentUser ); + $objs->Limit( FIELD => 'MemberId', VALUE => $self->Id ); + my $principals = $objs->Join( + ALIAS1 => 'main', + FIELD1 => 'GroupId', + TABLE2 => 'Principals', + FIELD2 => 'id', + ); + my $groups = $objs->Join( + ALIAS1 => $principals, + FIELD1 => 'ObjectId', + TABLE2 => 'Groups', + FIELD2 => 'Id', + ); + $objs->Limit( + ALIAS => $groups, + FIELD => 'Domain', + VALUE => 'SystemInternal', + CASESENSITIVE => 0 + ); + $deps->Add( in => $objs ); + + # XXX: This ignores the myriad of "in" references from the Creator + # and LastUpdatedBy columns. +} + +sub __DependsOn { + my $self = shift; + my %args = ( + Shredder => undef, + Dependencies => undef, + @_, + ); + my $deps = $args{'Dependencies'}; + my $list = []; + +# Principal + $deps->_PushDependency( + BaseObject => $self, + Flags => RT::Shredder::Constants::DEPENDS_ON | RT::Shredder::Constants::WIPE_AFTER, + TargetObject => $self->PrincipalObj, + Shredder => $args{'Shredder'} + ); + +# ACL equivalence group +# don't use LoadACLEquivalenceGroup cause it may not exists any more + my $objs = RT::Groups->new( $self->CurrentUser ); + $objs->Limit( FIELD => 'Domain', VALUE => 'ACLEquivalence', CASESENSITIVE => 0 ); + $objs->Limit( FIELD => 'Instance', VALUE => $self->Id ); + push( @$list, $objs ); + +# Cleanup user's membership + $objs = RT::GroupMembers->new( $self->CurrentUser ); + $objs->Limit( FIELD => 'MemberId', VALUE => $self->Id ); + push( @$list, $objs ); + +# Cleanup user's membership transactions + $objs = RT::Transactions->new( $self->CurrentUser ); + $objs->Limit( FIELD => 'Type', OPERATOR => 'IN', VALUE => ['AddMember', 'DeleteMember'] ); + $objs->Limit( FIELD => 'Field', VALUE => $self->PrincipalObj->id, ENTRYAGGREGATOR => 'AND' ); + push( @$list, $objs ); + + $deps->_PushDependencies( + BaseObject => $self, + Flags => RT::Shredder::Constants::DEPENDS_ON, + TargetObjects => $list, + Shredder => $args{'Shredder'} + ); + +# TODO: Almost all objects has Creator, LastUpdatedBy and etc. fields +# which are references on users(Principal actualy) + my @OBJECTS = qw( + ACL + Articles + Attachments + Attributes + CachedGroupMembers + Classes + CustomFieldValues + CustomFields + GroupMembers + Groups + Links + ObjectClasses + ObjectCustomFieldValues + ObjectCustomFields + ObjectScrips + Principals + Queues + ScripActions + ScripConditions + Scrips + Templates + Tickets + Transactions + Users + ); + my @var_objs; + foreach( @OBJECTS ) { + my $class = "RT::$_"; + foreach my $method ( qw(Creator LastUpdatedBy) ) { + my $objs = $class->new( $self->CurrentUser ); + next unless $objs->RecordClass->_Accessible( $method => 'read' ); + $objs->Limit( FIELD => $method, VALUE => $self->id ); + push @var_objs, $objs; + } + } + $deps->_PushDependencies( + BaseObject => $self, + Flags => RT::Shredder::Constants::DEPENDS_ON | RT::Shredder::Constants::VARIABLE, + TargetObjects => \@var_objs, + Shredder => $args{'Shredder'} + ); + + return $self->SUPER::__DependsOn( %args ); +} + +sub BeforeWipeout { + my $self = shift; + if( $self->Name =~ /^(RT_System|Nobody)$/ ) { + RT::Shredder::Exception::Info->throw('SystemObject'); + } + return $self->SUPER::BeforeWipeout( @_ ); +} + +sub Serialize { + my $self = shift; + return ( + Disabled => $self->PrincipalObj->Disabled, + Principal => $self->PrincipalObj->UID, + PrincipalId => $self->PrincipalObj->Id, + $self->SUPER::Serialize(@_), + ); +} + +sub PreInflate { + my $class = shift; + my ($importer, $uid, $data) = @_; + + my $principal_uid = delete $data->{Principal}; + my $principal_id = delete $data->{PrincipalId}; + my $disabled = delete $data->{Disabled}; + + my $obj = RT::User->new( RT->SystemUser ); + $obj->LoadByCols( Name => $data->{Name} ); + $obj->LoadByEmail( $data->{EmailAddress} ) unless $obj->Id; + if ($obj->Id) { + # User already exists -- merge + + # XXX: We might be merging a privileged user into an unpriv one, + # in which case we should probably promote the unpriv user to + # being privileged. Of course, we don't know if the user being + # imported is privileged yet, as its group memberships show up + # later in the stream... + $importer->MergeValues($obj, $data); + $importer->SkipTransactions( $uid ); + + # Mark both the principal and the user object as resolved + $importer->Resolve( + $principal_uid, + ref($obj->PrincipalObj), + $obj->PrincipalObj->Id + ); + $importer->Resolve( $uid => ref($obj) => $obj->Id ); + return; + } + + # Create a principal first, so we know what ID to use + my $principal = RT::Principal->new( RT->SystemUser ); + my ($id) = $principal->Create( + PrincipalType => 'User', + Disabled => $disabled, + ObjectId => 0, + ); + + # Now we have a principal id, set the id for the user record + $data->{id} = $id; + + $importer->Resolve( $principal_uid => ref($principal), $id ); + + $importer->Postpone( + for => $uid, + uid => $principal_uid, + column => "ObjectId", + ); + + return $class->SUPER::PreInflate( $importer, $uid, $data ); +} + +sub PostInflate { + my $self = shift; + RT->InitSystemObjects if $self->Name eq "RT_System"; +} + RT::Base->_ImportOverlays();