-# BEGIN LICENSE BLOCK
+# BEGIN BPS TAGGED BLOCK {{{
#
-# Copyright (c) 1996-2003 Jesse Vincent <jesse@bestpractical.com>
+# COPYRIGHT:
#
-# (Except where explictly superceded by other copyright notices)
+# This software is Copyright (c) 1996-2009 Best Practical Solutions, LLC
+# <jesse@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
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
-# Unless otherwise specified, all modifications, corrections or
-# extensions to this work which alter its source code become the
-# property of Best Practical Solutions, LLC when submitted for
-# inclusion in the work.
+# 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.)
#
-# END LICENSE BLOCK
+# 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 }}}
+
# Released under the terms of version 2 of the GNU Public License
=head1 NAME
=head1 SYNOPSIS
- use RT::Group;
+use RT::Group;
my $group = new RT::Group($CurrentUser);
=head1 DESCRIPTION
An RT group object.
-=head1 AUTHOR
-
-Jesse Vincent, jesse@bestpractical.com
-
-=head1 SEE ALSO
-
-RT
-
=head1 METHODS
-=begin testing
-
-# {{{ Tests
-ok (require RT::Group);
-
-ok (my $group = RT::Group->new($RT::SystemUser), "instantiated a group object");
-ok (my ($id, $msg) = $group->CreateUserDefinedGroup( Name => 'TestGroup', Description => 'A test group',
- ), 'Created a new group');
-ok ($id != 0, "Group id is $id");
-ok ($group->Name eq 'TestGroup', "The group's name is 'TestGroup'");
-my $ng = RT::Group->new($RT::SystemUser);
-
-ok($ng->LoadUserDefinedGroup('TestGroup'), "Loaded testgroup");
-ok(($ng->id == $group->id), "Loaded the right group");
-
-
-ok (($id,$msg) = $ng->AddMember('1'), "Added a member to the group");
-ok($id, $msg);
-ok (($id,$msg) = $ng->AddMember('2' ), "Added a member to the group");
-ok($id, $msg);
-ok (($id,$msg) = $ng->AddMember('3' ), "Added a member to the group");
-ok($id, $msg);
-# Group 1 now has members 1, 2 ,3
-my $group_2 = RT::Group->new($RT::SystemUser);
-ok (my ($id_2, $msg_2) = $group_2->CreateUserDefinedGroup( Name => 'TestGroup2', Description => 'A second test group'), , 'Created a new group');
-ok ($id_2 != 0, "Created group 2 ok- $msg_2 ");
-ok (($id,$msg) = $group_2->AddMember($ng->PrincipalId), "Made TestGroup a member of testgroup2");
-ok($id, $msg);
-ok (($id,$msg) = $group_2->AddMember('1' ), "Added member RT_System to the group TestGroup2");
-ok($id, $msg);
-# Group 2 how has 1, g1->{1, 2,3}
-
-my $group_3 = RT::Group->new($RT::SystemUser);
-ok (($id_3, $msg) = $group_3->CreateUserDefinedGroup( Name => 'TestGroup3', Description => 'A second test group'), 'Created a new group');
-ok ($id_3 != 0, "Created group 3 ok - $msg");
-ok (($id,$msg) =$group_3->AddMember($group_2->PrincipalId), "Made TestGroup a member of testgroup2");
-ok($id, $msg);
-
-# g3 now has g2->{1, g1->{1,2,3}}
-
-my $principal_1 = RT::Principal->new($RT::SystemUser);
-$principal_1->Load('1');
-
-my $principal_2 = RT::Principal->new($RT::SystemUser);
-$principal_2->Load('2');
-
-ok (($id,$msg) = $group_3->AddMember('1' ), "Added member RT_System to the group TestGroup2");
-ok($id, $msg);
-
-# g3 now has 1, g2->{1, g1->{1,2,3}}
-
-ok($group_3->HasMember($principal_2) == undef, "group 3 doesn't have member 2");
-ok($group_3->HasMemberRecursively($principal_2), "group 3 has member 2 recursively");
-ok($ng->HasMember($principal_2) , "group ".$ng->Id." has member 2");
-my ($delid , $delmsg) =$ng->DeleteMember($principal_2->Id);
-ok ($delid !=0, "Sucessfully deleted it-".$delid."-".$delmsg);
-
-#Gotta reload the group objects, since we've been messing with various internals.
-# we shouldn't need to do this.
-#$ng->LoadUserDefinedGroup('TestGroup');
-#$group_2->LoadUserDefinedGroup('TestGroup2');
-#$group_3->LoadUserDefinedGroup('TestGroup');
-
-# G1 now has 1, 3
-# Group 2 how has 1, g1->{1, 3}
-# g3 now has 1, g2->{1, g1->{1, 3}}
-
-ok(!$ng->HasMember($principal_2) , "group ".$ng->Id." no longer has member 2");
-ok($group_3->HasMemberRecursively($principal_2) == undef, "group 3 doesn't have member 2");
-ok($group_2->HasMemberRecursively($principal_2) == undef, "group 2 doesn't have member 2");
-ok($ng->HasMember($principal_2) == undef, "group 1 doesn't have member 2");;
-ok($group_3->HasMemberRecursively($principal_2) == undef, "group 3 has member 2 recursively");
-
-# }}}
-
-=end testing
+=cut
-
-=cut
+package RT::Group;
use strict;
no warnings qw(redefine);
AdminGroup => 'Modify group metadata or delete group', # loc_pair
AdminGroupMembership =>
'Modify membership roster for this group', # loc_pair
- ModifyOwnMembership => 'Join or leave this group' # loc_pair
+ DelegateRights =>
+ "Delegate specific rights which have been granted to you.", # loc_pair
+ ModifyOwnMembership => 'Join or leave this group', # loc_pair
+ EditSavedSearches => 'Edit saved searches for this group', # loc_pair
+ ShowSavedSearches => 'Display saved searches for this group', # loc_pair
+ SeeGroup => 'Make this group visible to user', # loc_pair
+
+ SeeGroupDashboard => 'View dashboards for this group', #loc_pair
+ CreateGroupDashboard => 'Create dashboards for this group', #loc_pair
+ ModifyGroupDashboard => 'Modify dashboards for this group', #loc_pair
+ DeleteGroupDashboard => 'Delete dashboards for this group', #loc_pair
};
# Tell RT::ACE that this sort of object can get acls granted
$RT::ACE::LOWERCASERIGHTNAMES{ lc $right } = $right;
}
+=head2 AddRights C<RIGHT>, C<DESCRIPTION> [, ...]
+
+Adds the given rights to the list of possible rights. This method
+should be called during server startup, not at runtime.
+
+=cut
+
+sub AddRights {
+ my $self = shift;
+ my %new = @_;
+ $RIGHTS = { %$RIGHTS, %new };
+ %RT::ACE::LOWERCASERIGHTNAMES = ( %RT::ACE::LOWERCASERIGHTNAMES,
+ map { lc($_) => $_ } keys %new);
+}
=head2 AvailableRights
my $self = shift;
my $identifier = shift || return undef;
- #if it's an int, load by id. otherwise, load by name.
if ( $identifier !~ /\D/ ) {
$self->SUPER::LoadById($identifier);
}
my $self = shift;
my $identifier = shift;
- $self->LoadByCols( "Domain" => 'UserDefined',
- "Name" => $identifier );
+ if ( $identifier =~ /^\d+$/ ) {
+ return $self->LoadByCols(
+ Domain => 'UserDefined',
+ id => $identifier,
+ );
+ } else {
+ return $self->LoadByCols(
+ Domain => 'UserDefined',
+ Name => $identifier,
+ );
+ }
}
# }}}
# {{{ sub LoadACLEquivalenceGroup
-=head2 LoadACLEquivalenceGroup PRINCIPAL
+=head2 LoadACLEquivalenceGroup PRINCIPAL
-Loads a user's acl equivalence group. Takes a principal object.
+Loads a user's acl equivalence group. Takes a principal object or its ID.
ACL equivalnce groups are used to simplify the acl system. Each user
has one group that only he is a member of. Rights granted to the user
are actually granted to that group. This greatly simplifies ACL checks.
While this results in a somewhat more complex setup when creating users
and granting ACLs, it _greatly_ simplifies acl checks.
-
-
=cut
sub LoadACLEquivalenceGroup {
- my $self = shift;
- my $princ = shift;
+ my $self = shift;
+ my $principal = shift;
+ $principal = $principal->id if ref $principal;
- $self->LoadByCols( "Domain" => 'ACLEquivalence',
- "Type" => 'UserEquiv',
- "Instance" => $princ->Id);
+ return $self->LoadByCols(
+ Domain => 'ACLEquivalence',
+ Type => 'UserEquiv',
+ Instance => $principal,
+ );
}
# }}}
my $self = shift;
my $identifier = shift;
- $self->LoadByCols( "Domain" => 'SystemInternal',
- "Type" => $identifier );
+ return $self->LoadByCols(
+ Domain => 'SystemInternal',
+ Type => $identifier,
+ );
}
# }}}
# }}}
# {{{ sub Create
+
=head2 Create
You need to specify what sort of group you're creating by calling one of the other
Type => undef,
Instance => '0',
InsideTransaction => undef,
+ _RecordTransaction => 1,
@_
);
);
my $id = $self->Id;
unless ($id) {
+ $RT::Handle->Rollback() unless ($args{'InsideTransaction'});
return ( 0, $self->loc('Could not create group') );
}
# If we couldn't create a principal Id, get the fuck out.
unless ($principal_id) {
$RT::Handle->Rollback() unless ($args{'InsideTransaction'});
- $self->crit( "Couldn't create a Principal on new user create. Strange things are afoot at the circle K" );
+ $RT::Logger->crit( "Couldn't create a Principal on new user create. Strange things are afoot at the circle K" );
return ( 0, $self->loc('Could not create group') );
}
$cgm->Create(Group =>$self->PrincipalObj, Member => $self->PrincipalObj, ImmediateParent => $self->PrincipalObj);
+ if ( $args{'_RecordTransaction'} ) {
+ $self->_NewTransaction( Type => "Create" );
+ }
$RT::Handle->Commit() unless ($args{'InsideTransaction'});
+
return ( $id, $self->loc("Group created") );
}
It will not appear in most group listings.
This routine finds all the cached group members that are members of this group (recursively) and disables them.
+
=cut
# }}}
#Clear the key cache. TODO someday we may want to just clear a little bit of the keycache space.
# TODO what about the groups key cache?
- RT::Principal->_InvalidateACLCache();
+ RT::Principal->InvalidateACLCache();
}
}
+ $self->_NewTransaction( Type => ($val == 1) ? "Disabled" : "Enabled" );
+
$RT::Handle->Commit();
- return (1, $self->loc("Succeeded"));
+ if ( $val == 1 ) {
+ return (1, $self->loc("Group disabled"));
+ } else {
+ return (1, $self->loc("Group enabled"));
+ }
}
=head2 DeepMembersObj
-Returns an RT::CachedGroupMembers object of this group's members.
+Returns an RT::CachedGroupMembers object of this group's members,
+including all members of subgroups.
=cut
# }}}
-# {{{ UserMembersObj
+# {{{ MembersObj
-=head2 UserMembersObj
+=head2 MembersObj
-Returns an RT::Users object of this group's members, including
-all members of subgroups
+Returns an RT::GroupMembers object of this group's direct members.
=cut
-sub UserMembersObj {
+sub MembersObj {
my $self = shift;
-
- my $users = RT::Users->new($self->CurrentUser);
+ my $members_obj = RT::GroupMembers->new( $self->CurrentUser );
#If we don't have rights, don't include any results
# TODO XXX WHY IS THERE NO ACL CHECK HERE?
+ $members_obj->LimitToMembersOfGroup( $self->PrincipalId );
- my $cached_members = $users->NewAlias('CachedGroupMembers');
- $users->Join(ALIAS1 => $cached_members, FIELD1 => 'MemberId',
- ALIAS2 => $users->PrincipalsAlias, FIELD2 => 'id');
- $users->Limit(ALIAS => $cached_members,
- FIELD => 'GroupId',
- OPERATOR => '=',
- VALUE => $self->PrincipalId);
+ return ( $members_obj );
- return ( $users);
+}
+
+# }}}
+
+# {{{ GroupMembersObj
+
+=head2 GroupMembersObj [Recursively => 1]
+Returns an L<RT::Groups> object of this group's members.
+By default returns groups including all subgroups, but
+could be changed with C<Recursively> named argument.
+
+B<Note> that groups are not filtered by type and result
+may contain as well system groups, personal and other.
+
+=cut
+
+sub GroupMembersObj {
+ my $self = shift;
+ my %args = ( Recursively => 1, @_ );
+
+ my $groups = RT::Groups->new( $self->CurrentUser );
+ my $members_table = $args{'Recursively'}?
+ 'CachedGroupMembers': 'GroupMembers';
+
+ my $members_alias = $groups->NewAlias( $members_table );
+ $groups->Join(
+ ALIAS1 => $members_alias, FIELD1 => 'MemberId',
+ ALIAS2 => $groups->PrincipalsAlias, FIELD2 => 'id',
+ );
+ $groups->Limit(
+ ALIAS => $members_alias,
+ FIELD => 'GroupId',
+ VALUE => $self->PrincipalId,
+ );
+ $groups->Limit(
+ ALIAS => $members_alias,
+ FIELD => 'Disabled',
+ VALUE => 0,
+ ) if $args{'Recursively'};
+
+ return $groups;
}
# }}}
-# {{{ MembersObj
+# {{{ UserMembersObj
-=head2 MembersObj
+=head2 UserMembersObj
-Returns an RT::CachedGroupMembers object of this group's members.
+Returns an L<RT::Users> object of this group's members, by default
+returns users including all members of subgroups, but could be
+changed with C<Recursively> named argument.
=cut
-sub MembersObj {
+sub UserMembersObj {
my $self = shift;
- my $members_obj = RT::GroupMembers->new( $self->CurrentUser );
+ my %args = ( Recursively => 1, @_ );
#If we don't have rights, don't include any results
# TODO XXX WHY IS THERE NO ACL CHECK HERE?
- $members_obj->LimitToMembersOfGroup( $self->PrincipalId );
- return ( $members_obj );
+ my $members_table = $args{'Recursively'}?
+ 'CachedGroupMembers': 'GroupMembers';
+ my $users = RT::Users->new($self->CurrentUser);
+ my $members_alias = $users->NewAlias( $members_table );
+ $users->Join(
+ ALIAS1 => $members_alias, FIELD1 => 'MemberId',
+ ALIAS2 => $users->PrincipalsAlias, FIELD2 => 'id',
+ );
+ $users->Limit(
+ ALIAS => $members_alias,
+ FIELD => 'GroupId',
+ VALUE => $self->PrincipalId,
+ );
+ $users->Limit(
+ ALIAS => $members_alias,
+ FIELD => 'Disabled',
+ VALUE => 0,
+ ) if $args{'Recursively'};
+
+ return ( $users);
}
# }}}
if ( $self->HasMember( $new_member_obj ) ) {
#User is already a member of this group. no need to add it
- return ( 0, $self->loc("Group already has member") );
+ return ( 0, $self->loc("Group already has member: [_1]", $new_member_obj->Object->Name) );
}
if ( $new_member_obj->IsGroup &&
$new_member_obj->Object->HasMemberRecursively($self->PrincipalObj) ) {
InsideTransaction => $args{'InsideTransaction'}
);
if ($id) {
- return ( 1, $self->loc("Member added") );
+ return ( 1, $self->loc("Member added: [_1]", $new_member_obj->Object->Name) );
}
else {
return(0, $self->loc("Couldn't add member to group"));
# {{{ HasMember
-=head2 HasMember RT::Principal
+=head2 HasMember RT::Principal|id
-Takes an RT::Principal object returns a GroupMember Id if that user is a
+Takes an L<RT::Principal> object or its id returns a GroupMember Id if that user is a
member of this group.
Returns undef if the user isn't a member of the group or if the current
user doesn't have permission to find out. Arguably, it should differentiate
my $self = shift;
my $principal = shift;
-
- unless (UNIVERSAL::isa($principal,'RT::Principal')) {
- $RT::Logger->crit("Group::HasMember was called with an argument that".
- "isn't an RT::Principal. It's $principal");
- return(undef);
- }
-
- unless ($principal->Id) {
+ my $id;
+ if ( UNIVERSAL::isa($principal,'RT::Principal') ) {
+ $id = $principal->id;
+ } elsif ( $principal =~ /^\d+$/ ) {
+ $id = $principal;
+ } else {
+ $RT::Logger->error("Group::HasMember was called with an argument that".
+ " isn't an RT::Principal or id. It's ".($principal||'(undefined)'));
return(undef);
}
+ return undef unless $id;
my $member_obj = RT::GroupMember->new( $self->CurrentUser );
- $member_obj->LoadByCols( MemberId => $principal->id,
- GroupId => $self->PrincipalId );
+ $member_obj->LoadByCols(
+ MemberId => $id,
+ GroupId => $self->PrincipalId
+ );
- #If we have a member object
- if ( defined $member_obj->id ) {
- return ( $member_obj->id );
+ if ( my $member_id = $member_obj->id ) {
+ return $member_id;
}
-
- #If Load returns no objects, we have an undef id.
else {
- #$RT::Logger->debug($self." does not contain principal ".$principal->id);
return (undef);
}
}
# {{{ HasMemberRecursively
-=head2 HasMemberRecursively RT::Principal
+=head2 HasMemberRecursively RT::Principal|id
-Takes an RT::Principal object and returns true if that user is a member of
+Takes an L<RT::Principal> object or its id and returns true if that user is a member of
this group.
Returns undef if the user isn't a member of the group or if the current
user doesn't have permission to find out. Arguably, it should differentiate
my $self = shift;
my $principal = shift;
- unless (UNIVERSAL::isa($principal,'RT::Principal')) {
- $RT::Logger->crit("Group::HasMemberRecursively was called with an argument that".
- "isn't an RT::Principal. It's $principal");
+ my $id;
+ if ( UNIVERSAL::isa($principal,'RT::Principal') ) {
+ $id = $principal->id;
+ } elsif ( $principal =~ /^\d+$/ ) {
+ $id = $principal;
+ } else {
+ $RT::Logger->error("Group::HasMemberRecursively was called with an argument that".
+ " isn't an RT::Principal or id. It's $principal");
return(undef);
}
+ return undef unless $id;
+
my $member_obj = RT::CachedGroupMember->new( $self->CurrentUser );
- $member_obj->LoadByCols( MemberId => $principal->Id,
- GroupId => $self->PrincipalId ,
- Disabled => 0
- );
-
- #If we have a member object
- if ( defined $member_obj->id ) {
- return ( 1);
- }
+ $member_obj->LoadByCols(
+ MemberId => $id,
+ GroupId => $self->PrincipalId
+ );
- #If Load returns no objects, we have an undef id.
+ if ( my $member_id = $member_obj->id ) {
+ return $member_id;
+ }
else {
return (undef);
}
# }}}
+# {{{ sub _CleanupInvalidDelegations
+
+=head2 _CleanupInvalidDelegations { InsideTransaction => undef }
+
+Revokes all ACE entries delegated by members of this group which are
+inconsistent with their current delegation rights. Does not perform
+permission checks. Should only ever be called from inside the RT
+library.
+
+If called from inside a transaction, specify a true value for the
+InsideTransaction parameter.
+
+Returns a true value if the deletion succeeded; returns a false value
+and logs an internal error if the deletion fails (should not happen).
+
+=cut
+
+# XXX Currently there is a _CleanupInvalidDelegations method in both
+# RT::User and RT::Group. If the recursive cleanup call for groups is
+# ever unrolled and merged, this code will probably want to be
+# factored out into RT::Principal.
+
+sub _CleanupInvalidDelegations {
+ my $self = shift;
+ my %args = ( InsideTransaction => undef,
+ @_ );
+
+ unless ( $self->Id ) {
+ $RT::Logger->warning("Group not loaded.");
+ return (undef);
+ }
+
+ my $in_trans = $args{InsideTransaction};
+
+ # TODO: Can this be unrolled such that the number of DB queries is constant rather than linear in exploded group size?
+ my $members = $self->DeepMembersObj();
+ $members->LimitToUsers();
+ $RT::Handle->BeginTransaction() unless $in_trans;
+ while ( my $member = $members->Next()) {
+ my $ret = $member->MemberObj->_CleanupInvalidDelegations(InsideTransaction => 1,
+ Object => $args{Object});
+ unless ($ret) {
+ $RT::Handle->Rollback() unless $in_trans;
+ return (undef);
+ }
+ }
+ $RT::Handle->Commit() unless $in_trans;
+ return(1);
+}
+
+# }}}
+
# {{{ ACL Related routines
# {{{ sub _Set
sub _Set {
my $self = shift;
+ my %args = (
+ Field => undef,
+ Value => undef,
+ TransactionType => 'Set',
+ RecordTransaction => 1,
+ @_
+ );
if ($self->Domain eq 'Personal') {
if ($self->CurrentUser->PrincipalId == $self->Instance) {
return ( 0, $self->loc('Permission Denied') );
}
}
- return ( $self->SUPER::_Set(@_) );
+
+ my $Old = $self->SUPER::_Value("$args{'Field'}");
+
+ my ($ret, $msg) = $self->SUPER::_Set( Field => $args{'Field'},
+ Value => $args{'Value'} );
+
+ #If we can't actually set the field to the value, don't record
+ # a transaction. instead, get out of here.
+ if ( $ret == 0 ) { return ( 0, $msg ); }
+
+ if ( $args{'RecordTransaction'} == 1 ) {
+
+ my ( $Trans, $Msg, $TransObj ) = $self->_NewTransaction(
+ Type => $args{'TransactionType'},
+ Field => $args{'Field'},
+ NewValue => $args{'Value'},
+ OldValue => $Old,
+ TimeTaken => $args{'TimeTaken'},
+ );
+ return ( $Trans, scalar $TransObj->Description );
+ }
+ else {
+ return ( $ret, $msg );
+ }
}
# }}}
-=item CurrentUserHasRight RIGHTNAME
+=head2 CurrentUserHasRight RIGHTNAME
Returns true if the current user has the specified right for this group.
if there's no principal object matching this user.
The response is cached. PrincipalObj should never ever change.
-=begin testing
-
-ok(my $u = RT::Group->new($RT::SystemUser));
-ok($u->Load(4), "Loaded the first user");
-ok($u->PrincipalObj->ObjectId == 4, "user 4 is the fourth principal");
-ok($u->PrincipalObj->PrincipalType eq 'Group' , "Principal 4 is a group");
-
-=end testing
=cut
sub PrincipalObj {
my $self = shift;
- unless ($self->{'PrincipalObj'} &&
+ unless ( defined $self->{'PrincipalObj'} &&
+ defined $self->{'PrincipalObj'}->ObjectId &&
($self->{'PrincipalObj'}->ObjectId == $self->Id) &&
- ($self->{'PrincipalObj'}->PrincipalType eq 'Group')) {
+ (defined $self->{'PrincipalObj'}->PrincipalType &&
+ $self->{'PrincipalObj'}->PrincipalType eq 'Group')) {
$self->{'PrincipalObj'} = RT::Principal->new($self->CurrentUser);
$self->{'PrincipalObj'}->LoadByCols('ObjectId' => $self->Id,
}
# }}}
+
+sub BasicColumns {
+ (
+ [ Name => 'Name' ],
+ [ Description => 'Description' ],
+ );
+}
+
1;
+=head1 AUTHOR
+
+Jesse Vincent, jesse@bestpractical.com
+
+=head1 SEE ALSO
+
+RT
+