File Coverage

blib/lib/Net/LDAP/Class/User/AD.pm
Criterion Covered Total %
statement 174 257 67.7
branch 66 126 52.3
condition 29 65 44.6
subroutine 22 33 66.6
pod 17 17 100.0
total 308 498 61.8


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