File Coverage

blib/lib/Net/LDAP/Class/User/AD.pm
Criterion Covered Total %
statement 171 244 70.0
branch 64 120 53.3
condition 29 65 44.6
subroutine 22 32 68.7
pod 17 17 100.0
total 303 478 63.3


line stmt bran cond sub pod time code
1             package Net::LDAP::Class::User::AD;
2 4     4   623510 use strict;
  4         10  
  4         84  
3 4     4   12 use warnings;
  4         4  
  4         82  
4 4     4   10 use base qw( Net::LDAP::Class::User );
  4         4  
  4         1378  
5 4     4   18 use Carp;
  4         4  
  4         210  
6 4     4   14 use Data::Dump ();
  4         4  
  4         46  
7 4     4   12 use Net::LDAP::SID;
  4         2  
  4         136  
8              
9             my $PRIMARY_GROUP_NOT_USED = 513;
10             my $AD_TIMESTAMP_OFFSET = 116444736390271392;
11             my $AD_TIMESTAMP_OFFSET2 = 11644524000;
12              
13             use Net::LDAP::Class::MethodMaker (
14 4         16 'scalar --get_set_init' => [qw( default_home_dir default_email_suffix )],
15 4     4   14 );
  4         4  
16              
17             our $VERSION = '0.27';
18              
19             =head1 NAME
20              
21             Net::LDAP::Class::User::AD - Active Directory User class
22              
23             =head1 SYNOPSIS
24              
25             # subclass this class for your local LDAP
26             package MyLDAPUser;
27             use base qw( Net::LDAP::Class::User::AD );
28              
29             __PACKAGE__->metadata->setup(
30             base_dn => 'dc=mycompany,dc=com',
31             attributes => __PACKAGE__->AD_attributes,
32             unique_attributes => __PACKAGE__->AD_unique_attributes,
33             );
34              
35             1;
36              
37             # then use your class
38             my $ldap = get_and_bind_LDAP_object(); # you write this
39              
40             use MyLDAPUser;
41             my $user = MyLDAPUser->new( ldap => $ldap, sAMAccountName => 'foobar' );
42             $user->read_or_create;
43             for my $group ($user->group, @{ $user->groups }) {
44             printf("user %s in group %s\n", $user, $group);
45             }
46              
47             =head1 DESCRIPTION
48              
49             Net::LDAP::Class::User::AD isa Net::LDAP::Class::User implementing
50             the Active Directory LDAP schema.
51              
52             =head1 CLASS METHODS
53              
54             =head2 AD_attributes
55              
56             Returns array ref of a subset of the default Active Directory
57             attributes. Only a subset is used since the default schema contains
58             literally 100s of attributes. The subset was chosen based on its
59             similarity to the POSIX schema.
60              
61             =cut
62              
63             # full attribute list here:
64             # http://windowsitpro.com/article/articleid/84714/jsi-tip-9910-what-attribute-names-exist-in-my-active-directory-schema.html
65             # we list only a "relevant" subset
66              
67             sub AD_attributes {
68 2     2 1 170 [ qw(
69             accountExpires
70             adminCount
71             canonicalName
72             cn
73             codePage
74             countryCode
75             description
76             displayName
77             distinguishedName
78             givenName
79             groupAttributes
80             homeDirectory
81             homeDrive
82             instanceType
83             lastLogoff
84             lastLogon
85             logonCount
86             mail
87             memberOf
88             middleName
89             modifyTimeStamp
90             name
91             notes
92             objectClass
93             objectGUID
94             objectSID
95             primaryGroupID
96             profilePath
97             pwdLastSet
98             sAMAccountName
99             sAMAccountType
100             sn
101             uid
102             unicodePwd
103             userAccountControl
104             userPrincipalName
105             uSNChanged
106             uSNCreated
107             whenCreated
108             whenChanged
109             )
110             ];
111             }
112              
113             =head2 AD_unique_attributes
114              
115             Returns array ref of unique Active Directory attributes.
116              
117             =cut
118              
119             sub AD_unique_attributes {
120 2     2 1 15 [qw( sAMAccountName distinguishedName objectSID )];
121             }
122              
123             =head1 OBJECT METHODS
124              
125             All the init_* methods can be specified to the new() constructor without
126             the init_ prefix.
127              
128             =head2 fetch_group
129              
130             Required MethodMaker method for retrieving primary group from LDAP.
131              
132             Returns an object of type group_class().
133              
134             =cut
135              
136             sub fetch_group {
137 2     2 1 4 my $self = shift;
138 2 50       5 my $class = $self->group_class or croak "group_class() required";
139 2   66     17 my $gid = shift || $self->gid;
140              
141 2 50       4 if ( !$gid ) {
142 0         0 croak "cannot fetch group without a gid (primaryGroupID) set";
143             }
144              
145             # because AD does not store primaryGroupToken but computes it,
146             # we must do gymnastics using SIDs
147 2 50       4 $self->debug and warn "gid = $gid";
148              
149 2         8 my $user_sid_string = Net::LDAP::SID->new( $self->objectSID )->as_string;
150              
151 2 50       69 $self->debug and warn "user_sid_string: $user_sid_string";
152 2         22 ( my $group_sid_string = $user_sid_string ) =~ s/\-[^\-]+$/-$gid/;
153              
154 2 50       6 $self->debug and warn "group_sid_string: $group_sid_string";
155              
156 2         7 return $class->new(
157             objectSID => $group_sid_string,
158             ldap => $self->ldap
159             )->read;
160             }
161              
162             =head2 last_logon_localtime
163              
164             Returns human-readable version of lastLogon attribute.
165              
166             =cut
167              
168             sub last_logon_localtime {
169 0     0 1 0 my $self = shift;
170 0         0 return scalar localtime( $self->ad_time_as_epoch('lastLogon') );
171             }
172              
173             =head2 pwd_last_set_localtime
174              
175             Returns human-readable version of pwdLastSet attribute.
176              
177             =cut
178              
179             sub pwd_last_set_localtime {
180 0     0 1 0 my $self = shift;
181 0         0 return scalar localtime( $self->ad_time_as_epoch('pwdLastSet') );
182             }
183              
184             =head2 ad_time_as_epoch( I )
185              
186             Returns epoch time for I.
187              
188             =cut
189              
190             sub ad_time_as_epoch {
191 0     0 1 0 my $self = shift;
192 0 0       0 my $attr = shift or croak "attribute_name required";
193 0         0 return $self->__ad_ts_to_epoch( $self->$attr );
194             }
195              
196             sub _domain_attrs {
197 0     0   0 my $self = shift;
198 0         0 my $ldap = $self->ldap;
199 0         0 my $base_dn = $self->base_dn;
200 0         0 my %args = (
201             base => $base_dn,
202             scope => 'base',
203             attrs => [],
204             filter => '(objectClass=*)',
205             );
206              
207             #Data::Dump::dump \%args;
208              
209 0         0 my $msg = $ldap->search(%args);
210              
211 0 0       0 if ( $msg->code ) {
212 0         0 croak $self->get_ldap_error($msg);
213             }
214              
215 0         0 return $msg->entries();
216             }
217              
218             sub _pwd_max_age {
219 0     0   0 my $self = shift;
220 0         0 my @domain_attr = $self->_domain_attrs();
221 0         0 my $maxPwdAge = $domain_attr[0]->get_value('maxPwdAge');
222              
223             #warn "maxPwdAge = $maxPwdAge";
224 0         0 my $expires_now = $self->__epoch_to_ad( time() ) + $maxPwdAge;
225              
226             #warn "expires_now = $expires_now";
227 0         0 return $expires_now;
228             }
229              
230             # was helfpul:
231             # http://www.macosxhints.com/article.php?story=20060925114138223
232              
233             =head2 pwd_will_expire_localtime
234              
235             Returns human-readable time when password will expire,
236             based on pwdLastSet attribute and the domain-level maxPwdAge value.
237              
238             =cut
239              
240             sub pwd_will_expire_localtime {
241 0     0 1 0 my $self = shift;
242 0         0 my $expires_now = $self->_pwd_max_age;
243 0         0 my $seconds_till_expire
244             = ( ( $self->pwdLastSet - $expires_now ) / 10000000 );
245              
246             #warn "seconds $seconds_till_expire";
247             #warn "days " . ( $seconds_till_expire / 86400 );
248 0         0 return scalar localtime( time() + $seconds_till_expire );
249             }
250              
251             =head2 fetch_groups
252              
253             Required MethodMaker method for retrieving secondary groups from LDAP.
254              
255             Returns array or array ref (based on context) of objects of type
256             group_class().
257              
258             =cut
259              
260             sub fetch_groups {
261 4     4 1 6 my $self = shift;
262 4         5 my @groups;
263              
264 4 50       9 if ( $self->ldap_entry ) {
265 4         24 my @group_dns = $self->ldap_entry->get_value('memberOf');
266 4         50 my $group_class = $self->group_class;
267              
268 4         22 for my $dn (@group_dns) {
269 5         38 $dn =~ s/^cn=([^,]+),.+/$1/i;
270 5         18 push(
271             @groups,
272             $group_class->new(
273             cn => $dn,
274             ldap => $self->ldap
275             )->read
276             );
277             }
278             }
279              
280 4 50       20 return wantarray ? @groups : \@groups;
281             }
282              
283             =head2 groups_iterator([I])
284              
285             Returns a Net::LDAP::Class::Iterator object with all the secondary
286             groups. This is the same data as fetch_groups() but as an iterator
287             instead of an array.
288              
289             See the advice about iterators versus arrays in L.
290              
291             =cut
292              
293             sub groups_iterator {
294 1     1 1 2 my $self = shift;
295 1 50       4 my $group_class = $self->group_class or croak "group_class required";
296 1 50       10 my $ldap = $self->ldap or croak "ldap required";
297 1         11 my @DNs = $self->memberOf;
298 1 50       3 if ( !@DNs ) {
299 0         0 @DNs = $self->read->memberOf;
300             }
301              
302             return Net::LDAP::Class::SimpleIterator->new(
303             code => sub {
304 2 100   2   10 my $dn = shift @DNs or return undef;
305 1         9 $dn =~ s/^cn=([^,]+),.+/$1/i;
306 1         6 $group_class->new(
307             cn => $dn,
308             ldap => $ldap
309             )->read;
310              
311             }
312 1         33 );
313             }
314              
315             =head2 gid
316              
317             Alias for primaryGroupID() attribute.
318              
319             =cut
320              
321             sub gid {
322 10     10 1 15 my $self = shift;
323 10         35 $self->primaryGroupID(@_);
324             }
325              
326             =head2 init_default_home_dir
327              
328             Returns B<\home>.
329              
330             =cut
331              
332 25     25 1 313 sub init_default_home_dir {'\home'}
333              
334             =head2 init_default_email_suffix
335              
336             Returns an empty string.
337              
338             =cut
339              
340 24     24 1 162 sub init_default_email_suffix {''}
341              
342             =head2 password([I])
343              
344             Convenience wrapper around unicodePwd() attribute method.
345              
346             This method will verify I is in the correct
347             encoding that AD expects and set it in the ldap_entry().
348              
349             If no argument is supplied, returns the
350             string set in ldap_entry() (if any).
351              
352             =cut
353              
354             sub password {
355 25     25 1 32 my $self = shift;
356 25         44 my $attribute = 'unicodePwd';
357              
358 25 100 66     54 if ( !defined $self->ldap_entry && grep { $_ eq $attribute }
  960         815  
359 24         64 @{ $self->attributes } )
360             {
361              
362 24 50       46 if ( scalar @_ ) {
363 0         0 $self->{_not_yet_set}->{$attribute}
364             = $self->_encode_pass( $_[0] );
365             }
366             return
367             exists $self->{_not_yet_set}->{$attribute}
368 24 50       149 ? $self->{_not_yet_set}->{$attribute}
369             : undef;
370              
371             }
372              
373 1 50       10 if (@_) {
374 0         0 my $octets = $self->_encode_pass( $_[0] );
375 0         0 my @old = $self->ldap_entry->get_value($attribute);
376 0         0 $self->ldap_entry->replace( $attribute, $octets );
377 0         0 $self->{_was_set}->{$attribute}->{new} = $octets;
378              
379             # do not overwrite an existing 'old' value, since we might need to know
380             # what was originally in the ldap_entry in order to replace it.
381 0 0       0 unless ( exists $self->{_was_set}->{$attribute}->{old} ) {
382             $self->{_was_set}->{$attribute}->{old}
383 0 0       0 = @old > 1 ? \@old : $old[0];
384             }
385             }
386              
387 1         3 return $self->ldap_entry->get_value($attribute);
388             }
389              
390             sub _is_encoded {
391 24     24   26 my $str = shift;
392 24 50       69 if ( $str =~ m/^"\000.+"\000$/ ) {
393 0         0 return 1;
394             }
395 24         48 return 0;
396             }
397              
398             sub _encode_pass {
399 24     24   22 my $self = shift;
400 24 50       57 my $pass = shift or croak "password required";
401              
402             # detect if password is already encoded and do not double encode
403 24 50       49 if ( _is_encoded($pass) ) {
404 0         0 return $pass;
405             }
406              
407 24         32 my $npass = '';
408 24         110 map { $npass .= "$_\000" } split( //, "\"$pass\"" );
  288         285  
409              
410 24         49 return $npass;
411             }
412              
413             sub _decode_pass {
414 0     0   0 my $self = shift;
415 0 0       0 my $pass = shift or croak "password required";
416 0 0       0 if ( !_is_encoded($pass) ) {
417 0         0 return $pass;
418             }
419              
420 0         0 my $decoded = '';
421 0         0 for my $char ( split( //, $pass ) ) {
422 0         0 $char =~ s/\000$//;
423 0         0 $decoded .= $char;
424             }
425 0         0 $decoded =~ s/^"|"$//g;
426              
427 0         0 return $decoded;
428             }
429              
430             =head2 action_for_create([ sAMAccountName => I ])
431              
432             Returns hash ref suitable for creating a Net::LDAP::Batch::Action::Add.
433              
434             May be called as a class method with explicit B and B
435             key/value pairs.
436              
437             =cut
438              
439             sub action_for_create {
440 24     24 1 43 my $self = shift;
441 24         51 my %opts = @_;
442 24 50 33     126 my $username = delete $opts{sAMAccountName} || $self->sAMAccountName
443             or croak "sAMAccountName required to create()";
444 24   33     105 my $base_dn = delete $opts{base_dn} || $self->base_dn;
445              
446 24         73 my ( $group, $gid, $givenName, $sn, $cn, $email )
447             = $self->setup_for_write;
448              
449             #warn "AD setup_for_write() $base_dn";
450              
451 24   33     73 my $pass = $self->password || $self->random_string(10);
452 24         81 $pass = $self->_encode_pass($pass);
453              
454             # see
455             # http://www.sysoptools.com/support/files/Fixing%20user%20accounts%20flagged%20as%20system%20accounts%20-%20the%20UserAccountControl%20AD%20attribute.doc
456             # for details on userAccountControl.
457             # basically:
458             # 512 - normal active account requiring password
459             # 514 - normal disabled account requiring password
460             # 544 - system active account - no password required
461             # 546 - system disabled account - no password required (default)
462              
463 24         117 my %attr = (
464             objectClass => [ "top", "person", "organizationalPerson", "user" ],
465             sAMAccountName => $username,
466             givenName => $givenName,
467             displayName => $cn,
468             sn => $sn,
469             cn => $cn, # must match $dn below
470             homeDirectory => $self->default_home_dir . "\\$username",
471             mail => $email,
472             userAccountControl => 512, # so AD treats it as a Normal user
473             unicodePwd => $pass,
474             );
475              
476 24 100       72 $attr{primaryGroupID} = $gid if $gid;
477              
478             # mix in whatever has been set
479 24         28 for my $name ( keys %{ $self->{_not_yet_set} } ) {
  24         90  
480              
481 48 100       99 next if $name eq 'cn'; # because we alter this in setup_for_write()
482              
483             #warn "set $name => $self->{_not_yet_set}->{$name}";
484 24 50       53 if ( !exists $attr{$name} ) {
485 0         0 $attr{$name} = delete $self->{_not_yet_set}->{$name};
486             }
487             else {
488 24         46 $attr{$name} = $self->{_not_yet_set}->{$name};
489             }
490             }
491              
492 24         64 my $dn = "CN=$cn,$base_dn";
493              
494 24         162 my @actions = (
495             add => {
496             dn => $dn,
497             attr => [%attr]
498             }
499             );
500              
501             #warn "AD checking groups $base_dn";
502              
503             # groups
504 24 50       88 if ( exists $self->{groups} ) {
505              
506             #carp $self->dump;
507              
508             #warn "User $self has groups assigned";
509             #warn Data::Dump::dump $self->{groups};
510              
511 24         25 G: for my $group ( @{ $self->{groups} } ) {
  24         50  
512 24 50       77 if ( !$group->read ) {
513 0         0 croak
514             "You must create group $group before you add User $self to it";
515             }
516              
517             #warn "checking if $group has user $self";
518              
519             # only interested in new additions
520 24 50       136 next G if $group->has_user($self);
521              
522             #warn "group $group does not yet have user $self";
523              
524 24         85 my $group_cn = $group->cn;
525 24         85 my @members = $group->member;
526 24         56 push( @members, $dn );
527              
528 24         69 push(
529             @actions,
530             update => {
531             search => [
532             base => $group->base_dn,
533             scope => "sub",
534             filter => "(cn=$group_cn)",
535             attrs => $group->attributes,
536             ],
537             replace => { member => \@members },
538             }
539             );
540              
541             }
542             }
543              
544 24         224 return @actions;
545             }
546              
547             =head2 setup_for_write
548              
549             Utility method for generating default values for
550             various attributes. Called by both action_for_create()
551             and action_for_update().
552              
553             Returns array of values in this order:
554              
555             $groupname, $gid, $givenName, $sn, $cn, $email
556              
557             =cut
558              
559             sub setup_for_write {
560 27     27 1 35 my $self = shift;
561              
562 27         29 my $gid;
563 27   100     104 my $group = $self->{group} || $self->gid;
564 27 100       57 if ($group) {
565 24 100 66     159 if ( ref $group and $group->isa('Net::LDAP::Class::Group') ) {
    50          
566 23         83 $gid = $group->gid;
567             }
568             elsif ( $self->primaryGroupID == $PRIMARY_GROUP_NOT_USED ) {
569 0         0 warn "primaryGroup feature not used\n";
570             }
571             else {
572 1         6 my $group_obj = $self->fetch_group($group);
573 1 50       6 if ( !$group_obj ) {
574 0         0 confess "no such group in AD server: $group";
575             }
576 1         15 $gid = $group_obj->gid;
577             }
578             }
579              
580             # set name
581 27 0 66     99 unless ( $self->displayName
      33        
      33        
582             || $self->cn
583             || $self->sn
584             || $self->givenName )
585             {
586 0         0 croak "either displayName, cn, sn or givenName must be set";
587             }
588              
589             # the name logic breaks horribly here for anything but trivial cases.
590 27   50     73 my @name_parts = split( m/\s+/, $self->cn || $self->displayName || '' );
591              
592 27         91 my $givenName = $self->givenName;
593 27 100       70 $givenName = shift(@name_parts) unless defined $givenName;
594 27         74 my $sn = $self->sn;
595 27 100       75 $sn = join( ' ', @name_parts ) unless defined $sn;
596 27         53 my $cn = $self->cn;
597 27 50       60 $cn = join( ' ', $givenName, $sn ) unless defined $cn;
598              
599 27         83 my $un = $self->username;
600 27 100 100     459 if ( $cn ne $un and $cn !~ m!/$un$! ) {
601 21         48 $cn .= "/$un"; # for uniqueness
602             }
603              
604 27         175 my $email = $self->mail;
605 27 100       182 $email = ( $un . $self->default_email_suffix )
606             unless defined $email;
607              
608 27         125 return ( $group, $gid, $givenName, $sn, $cn, $email );
609             }
610              
611             =head2 action_for_update
612              
613             Returns array ref suitable for creating a Net::LDAP::Batch::Action::Update.
614              
615             =cut
616              
617             sub action_for_update {
618 3     3 1 7 my $self = shift;
619 3         7 my %opts = @_;
620 3         9 my $username = $self->username;
621              
622 3 50       7 unless ($username) {
623 0         0 croak "must have sAMAccountName set to update";
624             }
625              
626 3   33     21 my $base_dn = delete $opts{base_dn} || $self->base_dn;
627              
628 3         5 my @actions;
629              
630 3         22 my ( $group, $gid, $givenName, $sn, $cn, $email, $pass )
631             = $self->setup_for_write;
632              
633 3         13 my %derived = (
634             cn => $cn,
635             givenName => $givenName,
636             sn => $sn,
637             sAMAccountName => $username,
638             unicodePwd => $pass,
639             primaryGroupID => $gid,
640             displayName => $cn,
641             mail => $email,
642             homeDirectory => $self->default_home_dir . "\\$username",
643             );
644              
645             # which fields have changed.
646 3         22 my %replace;
647 3         4 for my $attr ( keys %{ $self->{_was_set} } ) {
  3         17  
648              
649 2 100       8 next if $attr eq 'cn'; # because we mangle in setup_for_write()
650              
651 1         3 my $old = $self->{_was_set}->{$attr}->{old};
652 1   33     3 my $new = $self->{_was_set}->{$attr}->{new} || $derived{$attr};
653              
654 1 50 33     15 if ( defined($old) and !defined($new) ) {
    50 33        
    50 33        
    50          
655 0         0 $replace{$attr} = undef;
656             }
657             elsif ( !defined($old) and defined($new) ) {
658 0         0 $replace{$attr} = $new;
659             }
660             elsif ( !defined($old) and !defined($new) ) {
661              
662             #$replace{$attr} = undef;
663             }
664             elsif ( $old ne $new ) {
665 1         3 $replace{$attr} = $new;
666             }
667              
668             }
669              
670             # what group(s) have changed?
671             # compare primary group first
672             # this assumes that setting group() is preferred to
673             # explicitly setting gidNumber.
674 3 100 33     26 if ( defined $group
      33        
      66        
675             && $group ne $PRIMARY_GROUP_NOT_USED
676             && !exists $replace{primaryGroupID}
677             && $self->group->gid != $self->gid )
678             {
679              
680             # primary group has changed
681 2         15 $replace{primaryGroupId} = $self->group->gid;
682              
683             # clear so next access re-fetches
684 2         4 delete $self->{group};
685              
686             }
687              
688             # next, secondary group membership.
689             # check if any have been set explicitly,
690             # since otherwise there is nothing to be done.
691 3 100       16 if ( exists $self->{groups} ) {
692              
693             #carp Data::Dump::dump $self->{groups};
694              
695 2         13 my $existing_groups = $self->fetch_groups;
696              
697             #carp Data::Dump::dump $existing_groups;
698              
699 2         5 my %existing = map { $_->cn => $_ } @$existing_groups;
  3         11  
700              
701             # the delete $self->{groups} has helpful side effect of clearing
702             # cache.
703 2         5 my %new = map { $_->cn => $_ } @{ delete $self->{groups} };
  2         4  
  2         5  
704              
705             #warn "User $self has " . scalar( keys %new ) . " groups set";
706             #warn "existing group: $_" for sort keys %existing;
707             #warn "new group : $_" for sort keys %new;
708              
709             # which should be added
710 2         4 my @to_add;
711 2         6 G: for my $cn ( keys %new ) {
712 2 100       5 if ( !exists $existing{$cn} ) {
713 1         2 my $group = $new{$cn};
714              
715 1 50       3 if ( !$group->ldap_entry ) {
716 0         0 croak(
717             "you must create $group before adding user $self to it"
718             );
719             }
720              
721 1         6 for my $u ( $group->secondary_users ) {
722              
723             #warn " group member: $u <> user $self";
724              
725 0 0       0 next G if "$u" eq "$self";
726              
727             }
728              
729             #warn "group $group does NOT have User $self assigned";
730 1         37 $group->add_user($self);
731              
732 1         7 push( @to_add, $group->action_for_update );
733              
734             }
735             }
736              
737             # which should be removed
738 2         3 my @to_rm;
739 2         5 G: for my $cn ( keys %existing ) {
740 3 100       9 if ( !exists $new{$cn} ) {
741 2         4 my $group = $existing{$cn};
742              
743             #next unless $group->has_user($self);
744              
745 2         8 for my $u ( $group->secondary_users ) {
746 2 50       8 next G unless "$u" eq "$self";
747             }
748              
749             #warn "group $group does have User $self assigned";
750              
751 2         37 $group->remove_user($self);
752              
753 2         27 push( @to_rm, $group->action_for_update );
754              
755             }
756             }
757              
758 2         31 push( @actions, @to_add, @to_rm );
759              
760             }
761              
762 3 50       9 if (%replace) {
763 3         15 push(
764             @actions,
765             update => {
766             search => [
767             base => $base_dn,
768             scope => "sub",
769             filter => "(sAMAccountName=$username)",
770             attrs => $self->attributes,
771             ],
772             replace => \%replace
773             }
774             );
775             }
776              
777 3 50       8 if ( !@actions ) {
778 0         0 warn "no fields have changed for User $username. Skipping update().";
779 0         0 return;
780             }
781              
782 3 50       7 carp "updating User with actions: " . Data::Dump::dump( \@actions )
783             if $self->debug;
784              
785 3         25 return @actions;
786              
787             }
788              
789             =head2 action_for_delete
790              
791             Returns action suitable for creating a Net::LDAP::Batch::Action::Delete.
792              
793             =cut
794              
795             sub action_for_delete {
796 0     0 1   my $self = shift;
797 0           my %opts = @_;
798             my $username
799             = delete $opts{sAMAccountName}
800             || delete $opts{username}
801 0   0       || $self->username;
802              
803 0   0       my $base_dn = delete $opts{base_dn} || $self->base_dn;
804              
805 0 0         if ( !$username ) {
806 0           croak "username required to delete a User";
807             }
808              
809             # delete the user
810 0           my @actions = (
811             delete => {
812             search => [
813             base => $base_dn,
814             scope => "sub",
815             filter => "(sAMAccountName=$username)",
816             attrs => $self->attributes,
817             ]
818             }
819             );
820              
821 0           return @actions;
822             }
823              
824             sub __ad_ts_to_epoch {
825 0     0     my $self = shift;
826 0           my $adts = shift;
827 0 0         defined $adts or croak "Active Directory timestamp required";
828              
829             # convert windows time to unix time
830             # thanks to http://quark.humbug.org.au/blog/?p=27
831              
832 0           return ( $adts / 10000000 ) - $AD_TIMESTAMP_OFFSET2;
833             }
834              
835             sub __epoch_to_ad {
836 0     0     my $self = shift;
837 0           my $epoch = shift;
838 0 0         defined $epoch or croak "epoch seconds required";
839              
840             # convert unix time to windows time
841             # thanks to http://quark.humbug.org.au/blog/?p=27
842              
843 0           return ( $epoch * 10000000 ) + $AD_TIMESTAMP_OFFSET;
844             }
845              
846             1;
847              
848             __END__