netcmd: Add 'samba-tool group stats' command
[samba.git] / python / samba / netcmd / group.py
1 # Copyright Jelmer Vernooij 2008
2 #
3 # Based on the original in EJS:
4 # Copyright Andrew Tridgell 2005
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 import samba.getopt as options
20 from samba.netcmd import Command, SuperCommand, CommandError, Option
21 import ldb
22 from samba.ndr import ndr_unpack
23 from samba.dcerpc import security
24
25 from samba.auth import system_session
26 from samba.samdb import SamDB
27 from samba.dsdb import (
28     ATYPE_SECURITY_GLOBAL_GROUP,
29     GTYPE_SECURITY_BUILTIN_LOCAL_GROUP,
30     GTYPE_SECURITY_DOMAIN_LOCAL_GROUP,
31     GTYPE_SECURITY_GLOBAL_GROUP,
32     GTYPE_SECURITY_UNIVERSAL_GROUP,
33     GTYPE_DISTRIBUTION_DOMAIN_LOCAL_GROUP,
34     GTYPE_DISTRIBUTION_GLOBAL_GROUP,
35     GTYPE_DISTRIBUTION_UNIVERSAL_GROUP,
36 )
37 from collections import defaultdict
38
39 security_group = dict({"Builtin": GTYPE_SECURITY_BUILTIN_LOCAL_GROUP,
40                        "Domain": GTYPE_SECURITY_DOMAIN_LOCAL_GROUP,
41                        "Global": GTYPE_SECURITY_GLOBAL_GROUP,
42                        "Universal": GTYPE_SECURITY_UNIVERSAL_GROUP})
43 distribution_group = dict({"Domain": GTYPE_DISTRIBUTION_DOMAIN_LOCAL_GROUP,
44                            "Global": GTYPE_DISTRIBUTION_GLOBAL_GROUP,
45                            "Universal": GTYPE_DISTRIBUTION_UNIVERSAL_GROUP})
46
47
48 class cmd_group_add(Command):
49     """Creates a new AD group.
50
51 This command creates a new Active Directory group.  The groupname specified on the command is a unique sAMAccountName.
52
53 An Active Directory group may contain user and computer accounts as well as other groups.  An administrator creates a group and adds members to that group so they can be managed as a single entity.  This helps to simplify security and system administration.
54
55 Groups may also be used to establish email distribution lists, using --group-type=Distribution.
56
57 Groups are located in domains in organizational units (OUs).  The group's scope is a characteristic of the group that designates the extent to which the group is applied within the domain tree or forest.
58
59 The group location (OU), type (security or distribution) and scope may all be specified on the samba-tool command when the group is created.
60
61 The command may be run from the root userid or another authorized userid.  The
62 -H or --URL= option can be used to execute the command on a remote server.
63
64 Example1:
65 samba-tool group add Group1 -H ldap://samba.samdom.example.com --description='Simple group'
66
67 Example1 adds a new group with the name Group1 added to the Users container on a remote LDAP server.  The -U parameter is used to pass the userid and password of a user that exists on the remote server and is authorized to issue the command on that server.  It defaults to the security type and global scope.
68
69 Example2:
70 sudo samba-tool group add Group2 --group-type=Distribution
71
72 Example2 adds a new distribution group to the local server.  The command is run under root using the sudo command.
73
74 Example3:
75 samba-tool group add Group3 --nis-domain=samdom --gid-number=12345
76
77 Example3 adds a new RFC2307 enabled group for NIS domain samdom and GID 12345 (both options are required to enable this feature).
78 """
79
80     synopsis = "%prog <groupname> [options]"
81
82     takes_optiongroups = {
83         "sambaopts": options.SambaOptions,
84         "versionopts": options.VersionOptions,
85         "credopts": options.CredentialsOptions,
86     }
87
88     takes_options = [
89         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
90                metavar="URL", dest="H"),
91         Option("--groupou",
92                help="Alternative location (without domainDN counterpart) to default CN=Users in which new user object will be created",
93                type=str),
94         Option("--group-scope", type="choice", choices=["Domain", "Global", "Universal"],
95                help="Group scope (Domain | Global | Universal)"),
96         Option("--group-type", type="choice", choices=["Security", "Distribution"],
97                help="Group type (Security | Distribution)"),
98         Option("--description", help="Group's description", type=str),
99         Option("--mail-address", help="Group's email address", type=str),
100         Option("--notes", help="Groups's notes", type=str),
101         Option("--gid-number", help="Group's Unix/RFC2307 GID number", type=int),
102         Option("--nis-domain", help="SFU30 NIS Domain", type=str),
103     ]
104
105     takes_args = ["groupname"]
106
107     def run(self, groupname, credopts=None, sambaopts=None,
108             versionopts=None, H=None, groupou=None, group_scope=None,
109             group_type=None, description=None, mail_address=None, notes=None, gid_number=None, nis_domain=None):
110
111         if (group_type or "Security") == "Security":
112             gtype = security_group.get(group_scope, GTYPE_SECURITY_GLOBAL_GROUP)
113         else:
114             gtype = distribution_group.get(group_scope, GTYPE_DISTRIBUTION_GLOBAL_GROUP)
115
116         if (gid_number is None and nis_domain is not None) or (gid_number is not None and nis_domain is None):
117             raise CommandError('Both --gid-number and --nis-domain have to be set for a RFC2307-enabled group. Operation cancelled.')
118
119         lp = sambaopts.get_loadparm()
120         creds = credopts.get_credentials(lp, fallback_machine=True)
121
122         try:
123             samdb = SamDB(url=H, session_info=system_session(),
124                           credentials=creds, lp=lp)
125             samdb.newgroup(groupname, groupou=groupou, grouptype=gtype,
126                            description=description, mailaddress=mail_address, notes=notes,
127                            gidnumber=gid_number, nisdomain=nis_domain)
128         except Exception as e:
129             # FIXME: catch more specific exception
130             raise CommandError('Failed to create group "%s"' % groupname, e)
131         self.outf.write("Added group %s\n" % groupname)
132
133
134 class cmd_group_delete(Command):
135     """Deletes an AD group.
136
137 The command deletes an existing AD group from the Active Directory domain.  The groupname specified on the command is the sAMAccountName.
138
139 Deleting a group is a permanent operation.  When a group is deleted, all permissions and rights that users in the group had inherited from the group account are deleted as well.
140
141 The command may be run from the root userid or another authorized userid.  The -H or --URL option can be used to execute the command on a remote server.
142
143 Example1:
144 samba-tool group delete Group1 -H ldap://samba.samdom.example.com -Uadministrator%passw0rd
145
146 Example1 shows how to delete an AD group from a remote LDAP server.  The -U parameter is used to pass the userid and password of a user that exists on the remote server and is authorized to issue the command on that server.
147
148 Example2:
149 sudo samba-tool group delete Group2
150
151 Example2 deletes group Group2 from the local server.  The command is run under root using the sudo command.
152 """
153
154     synopsis = "%prog <groupname> [options]"
155
156     takes_optiongroups = {
157         "sambaopts": options.SambaOptions,
158         "versionopts": options.VersionOptions,
159         "credopts": options.CredentialsOptions,
160     }
161
162     takes_options = [
163         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
164                metavar="URL", dest="H"),
165     ]
166
167     takes_args = ["groupname"]
168
169     def run(self, groupname, credopts=None, sambaopts=None, versionopts=None, H=None):
170
171         lp = sambaopts.get_loadparm()
172         creds = credopts.get_credentials(lp, fallback_machine=True)
173         samdb = SamDB(url=H, session_info=system_session(),
174                       credentials=creds, lp=lp)
175
176         filter = ("(&(sAMAccountName=%s)(objectClass=group))" %
177                   groupname)
178
179         try:
180             res = samdb.search(base=samdb.domain_dn(),
181                                scope=ldb.SCOPE_SUBTREE,
182                                expression=filter,
183                                attrs=["dn"])
184             group_dn = res[0].dn
185         except IndexError:
186             raise CommandError('Unable to find group "%s"' % (groupname))
187
188         try:
189             samdb.delete(group_dn)
190         except Exception as e:
191             # FIXME: catch more specific exception
192             raise CommandError('Failed to remove group "%s"' % groupname, e)
193         self.outf.write("Deleted group %s\n" % groupname)
194
195
196 class cmd_group_add_members(Command):
197     """Add members to an AD group.
198
199 This command adds one or more members to an existing Active Directory group. The command accepts one or more group member names separated by commas.  A group member may be a user or computer account or another Active Directory group.
200
201 When a member is added to a group the member may inherit permissions and rights from the group.  Likewise, when permission or rights of a group are changed, the changes may reflect in the members through inheritance.
202
203 The member names specified on the command must be the sAMaccountName.
204
205 Example1:
206 samba-tool group addmembers supergroup Group1,Group2,User1 -H ldap://samba.samdom.example.com -Uadministrator%passw0rd
207
208 Example1 shows how to add two groups, Group1 and Group2 and one user account, User1, to the existing AD group named supergroup.  The command will be run on a remote server specified with the -H.  The -U parameter is used to pass the userid and password of a user authorized to issue the command on the remote server.
209
210 Example2:
211 sudo samba-tool group addmembers supergroup User2
212
213 Example2 shows how to add a single user account, User2, to the supergroup AD group.  It uses the sudo command to run as root when issuing the command.
214 """
215
216     synopsis = "%prog <groupname> <listofmembers> [options]"
217
218     takes_optiongroups = {
219         "sambaopts": options.SambaOptions,
220         "versionopts": options.VersionOptions,
221         "credopts": options.CredentialsOptions,
222     }
223
224     takes_options = [
225         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
226                metavar="URL", dest="H"),
227     ]
228
229     takes_args = ["groupname", "listofmembers"]
230
231     def run(self, groupname, listofmembers, credopts=None, sambaopts=None,
232             versionopts=None, H=None):
233
234         lp = sambaopts.get_loadparm()
235         creds = credopts.get_credentials(lp, fallback_machine=True)
236
237         try:
238             samdb = SamDB(url=H, session_info=system_session(),
239                           credentials=creds, lp=lp)
240             groupmembers = listofmembers.split(',')
241             samdb.add_remove_group_members(groupname, groupmembers,
242                                            add_members_operation=True)
243         except Exception as e:
244             # FIXME: catch more specific exception
245             raise CommandError('Failed to add members "%s" to group "%s"' % (
246                 listofmembers, groupname), e)
247         self.outf.write("Added members to group %s\n" % groupname)
248
249
250 class cmd_group_remove_members(Command):
251     """Remove members from an AD group.
252
253 This command removes one or more members from an existing Active Directory group.  The command accepts one or more group member names separated by commas.  A group member may be a user or computer account or another Active Directory group that is a member of the group specified on the command.
254
255 When a member is removed from a group, inherited permissions and rights will no longer apply to the member.
256
257 Example1:
258 samba-tool group removemembers supergroup Group1 -H ldap://samba.samdom.example.com -Uadministrator%passw0rd
259
260 Example1 shows how to remove Group1 from supergroup.  The command will run on the remote server specified on the -H parameter.  The -U parameter is used to pass the userid and password of a user authorized to issue the command on the remote server.
261
262 Example2:
263 sudo samba-tool group removemembers supergroup User1
264
265 Example2 shows how to remove a single user account, User2, from the supergroup AD group.  It uses the sudo command to run as root when issuing the command.
266 """
267
268     synopsis = "%prog <groupname> <listofmembers> [options]"
269
270     takes_optiongroups = {
271         "sambaopts": options.SambaOptions,
272         "versionopts": options.VersionOptions,
273         "credopts": options.CredentialsOptions,
274     }
275
276     takes_options = [
277         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
278                metavar="URL", dest="H"),
279     ]
280
281     takes_args = ["groupname", "listofmembers"]
282
283     def run(self, groupname, listofmembers, credopts=None, sambaopts=None,
284             versionopts=None, H=None):
285
286         lp = sambaopts.get_loadparm()
287         creds = credopts.get_credentials(lp, fallback_machine=True)
288
289         try:
290             samdb = SamDB(url=H, session_info=system_session(),
291                           credentials=creds, lp=lp)
292             samdb.add_remove_group_members(groupname, listofmembers.split(","),
293                                            add_members_operation=False)
294         except Exception as e:
295             # FIXME: Catch more specific exception
296             raise CommandError('Failed to remove members "%s" from group "%s"' % (listofmembers, groupname), e)
297         self.outf.write("Removed members from group %s\n" % groupname)
298
299
300 class cmd_group_list(Command):
301     """List all groups."""
302
303     synopsis = "%prog [options]"
304
305     takes_options = [
306         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
307                metavar="URL", dest="H"),
308         Option("-v", "--verbose",
309                help="Verbose output, showing group type and group scope.",
310                action="store_true"),
311
312     ]
313
314     takes_optiongroups = {
315         "sambaopts": options.SambaOptions,
316         "credopts": options.CredentialsOptions,
317         "versionopts": options.VersionOptions,
318     }
319
320     def run(self, sambaopts=None, credopts=None, versionopts=None, H=None,
321             verbose=False):
322         lp = sambaopts.get_loadparm()
323         creds = credopts.get_credentials(lp, fallback_machine=True)
324
325         samdb = SamDB(url=H, session_info=system_session(),
326                       credentials=creds, lp=lp)
327         attrs=["samaccountname"]
328
329         if verbose:
330             attrs += ["grouptype", "member"]
331         domain_dn = samdb.domain_dn()
332         res = samdb.search(domain_dn, scope=ldb.SCOPE_SUBTREE,
333                            expression=("(objectClass=group)"),
334                            attrs=attrs)
335         if (len(res) == 0):
336             return
337
338         if verbose:
339             self.outf.write("Group Name                                  Group Type      Group Scope  Members\n")
340             self.outf.write("--------------------------------------------------------------------------------\n")
341
342             for msg in res:
343                 self.outf.write("%-44s" % msg.get("samaccountname", idx=0))
344                 hgtype = hex(int("%s" % msg["grouptype"]) & 0x00000000FFFFFFFF)
345                 if (hgtype == hex(int(security_group.get("Builtin")))):
346                     self.outf.write("Security         Builtin  ")
347                 elif (hgtype == hex(int(security_group.get("Domain")))):
348                     self.outf.write("Security         Domain   ")
349                 elif (hgtype == hex(int(security_group.get("Global")))):
350                     self.outf.write("Security         Global   ")
351                 elif (hgtype == hex(int(security_group.get("Universal")))):
352                     self.outf.write("Security         Universal")
353                 elif (hgtype == hex(int(distribution_group.get("Global")))):
354                     self.outf.write("Distribution     Global   ")
355                 elif (hgtype == hex(int(distribution_group.get("Domain")))):
356                     self.outf.write("Distribution     Domain   ")
357                 elif (hgtype == hex(int(distribution_group.get("Universal")))):
358                     self.outf.write("Distribution     Universal")
359                 else:
360                     self.outf.write("                          ")
361                 self.outf.write("   %u\n" % len(msg.get("member", default=[])))
362         else:
363             for msg in res:
364                 self.outf.write("%s\n" % msg.get("samaccountname", idx=0))
365
366
367 class cmd_group_list_members(Command):
368     """List all members of an AD group.
369
370 This command lists members from an existing Active Directory group. The command accepts one group name.
371
372 Example1:
373 samba-tool group listmembers \"Domain Users\" -H ldap://samba.samdom.example.com -Uadministrator%passw0rd
374 """
375
376     synopsis = "%prog <groupname> [options]"
377
378     takes_options = [
379         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
380                metavar="URL", dest="H"),
381     ]
382
383     takes_optiongroups = {
384         "sambaopts": options.SambaOptions,
385         "credopts": options.CredentialsOptions,
386         "versionopts": options.VersionOptions,
387     }
388
389     takes_args = ["groupname"]
390
391     def run(self, groupname, credopts=None, sambaopts=None, versionopts=None, H=None):
392         lp = sambaopts.get_loadparm()
393         creds = credopts.get_credentials(lp, fallback_machine=True)
394
395         try:
396             samdb = SamDB(url=H, session_info=system_session(),
397                           credentials=creds, lp=lp)
398
399             search_filter = "(&(objectClass=group)(samaccountname=%s))" % groupname
400             res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
401                                expression=(search_filter),
402                                attrs=["objectSid"])
403
404             if (len(res) != 1):
405                 return
406
407             group_dn = res[0].get('dn', idx=0)
408             object_sid = res[0].get('objectSid', idx=0)
409
410             object_sid = ndr_unpack(security.dom_sid, object_sid)
411             (group_dom_sid, rid) = object_sid.split()
412
413             search_filter = "(|(primaryGroupID=%s)(memberOf=%s))" % (rid, group_dn)
414             res = samdb.search(samdb.domain_dn(), scope=ldb.SCOPE_SUBTREE,
415                                expression=(search_filter),
416                                attrs=["samAccountName", "cn"])
417
418             if (len(res) == 0):
419                 return
420
421             for msg in res:
422                 member_name = msg.get("samAccountName", idx=0)
423                 if member_name is None:
424                     member_name = msg.get("cn", idx=0)
425                 self.outf.write("%s\n" % member_name)
426
427         except Exception as e:
428             raise CommandError('Failed to list members of "%s" group ' % groupname, e)
429
430
431 class cmd_group_move(Command):
432     """Move a group to an organizational unit/container.
433
434     This command moves a group object into the specified organizational unit
435     or container.
436     The groupname specified on the command is the sAMAccountName.
437     The name of the organizational unit or container can be specified as a
438     full DN or without the domainDN component.
439
440     The command may be run from the root userid or another authorized userid.
441
442     The -H or --URL= option can be used to execute the command against a remote
443     server.
444
445     Example1:
446     samba-tool group move Group1 'OU=OrgUnit,DC=samdom.DC=example,DC=com' \
447         -H ldap://samba.samdom.example.com -U administrator
448
449     Example1 shows how to move a group Group1 into the 'OrgUnit' organizational
450     unit on a remote LDAP server.
451
452     The -H parameter is used to specify the remote target server.
453
454     Example2:
455     samba-tool group move Group1 CN=Users
456
457     Example2 shows how to move a group Group1 back into the CN=Users container
458     on the local server.
459     """
460
461     synopsis = "%prog <groupname> <new_parent_dn> [options]"
462
463     takes_options = [
464         Option("-H", "--URL", help="LDB URL for database or target server",
465                type=str, metavar="URL", dest="H"),
466     ]
467
468     takes_args = ["groupname", "new_parent_dn"]
469     takes_optiongroups = {
470         "sambaopts": options.SambaOptions,
471         "credopts": options.CredentialsOptions,
472         "versionopts": options.VersionOptions,
473     }
474
475     def run(self, groupname, new_parent_dn, credopts=None, sambaopts=None,
476             versionopts=None, H=None):
477         lp = sambaopts.get_loadparm()
478         creds = credopts.get_credentials(lp, fallback_machine=True)
479         samdb = SamDB(url=H, session_info=system_session(),
480                       credentials=creds, lp=lp)
481         domain_dn = ldb.Dn(samdb, samdb.domain_dn())
482
483         filter = ("(&(sAMAccountName=%s)(objectClass=group))" %
484                   groupname)
485         try:
486             res = samdb.search(base=domain_dn,
487                                expression=filter,
488                                scope=ldb.SCOPE_SUBTREE)
489             group_dn = res[0].dn
490         except IndexError:
491             raise CommandError('Unable to find group "%s"' % (groupname))
492
493         try:
494             full_new_parent_dn = samdb.normalize_dn_in_domain(new_parent_dn)
495         except Exception as e:
496             raise CommandError('Invalid new_parent_dn "%s": %s' %
497                                (new_parent_dn, e.message))
498
499         full_new_group_dn = ldb.Dn(samdb, str(group_dn))
500         full_new_group_dn.remove_base_components(len(group_dn) - 1)
501         full_new_group_dn.add_base(full_new_parent_dn)
502
503         try:
504             samdb.rename(group_dn, full_new_group_dn)
505         except Exception as e:
506             raise CommandError('Failed to move group "%s"' % groupname, e)
507         self.outf.write('Moved group "%s" into "%s"\n' %
508                         (groupname, full_new_parent_dn))
509
510
511 class cmd_group_show(Command):
512     """Display a group AD object.
513
514 This command displays a group object and it's attributes in the Active
515 Directory domain.
516 The group name specified on the command is the sAMAccountName of the group.
517
518 The command may be run from the root userid or another authorized userid.
519
520 The -H or --URL= option can be used to execute the command against a remote
521 server.
522
523 Example1:
524 samba-tool group show Group1 -H ldap://samba.samdom.example.com \
525 -U administrator --password=passw1rd
526
527 Example1 shows how to display a group's attributes in the domain against a remote
528 LDAP server.
529
530 The -H parameter is used to specify the remote target server.
531
532 Example2:
533 samba-tool group show Group2
534
535 Example2 shows how to display a group's attributes in the domain against a local
536 LDAP server.
537
538 Example3:
539 samba-tool group show Group3 --attributes=member,objectGUID
540
541 Example3 shows how to display a users objectGUID and member attributes.
542 """
543     synopsis = "%prog <group name> [options]"
544
545     takes_options = [
546         Option("-H", "--URL", help="LDB URL for database or target server",
547                type=str, metavar="URL", dest="H"),
548         Option("--attributes",
549                help=("Comma separated list of attributes, "
550                      "which will be printed."),
551                type=str, dest="group_attrs"),
552     ]
553
554     takes_args = ["groupname"]
555     takes_optiongroups = {
556         "sambaopts": options.SambaOptions,
557         "credopts": options.CredentialsOptions,
558         "versionopts": options.VersionOptions,
559     }
560
561     def run(self, groupname, credopts=None, sambaopts=None, versionopts=None,
562             H=None, group_attrs=None):
563
564         lp = sambaopts.get_loadparm()
565         creds = credopts.get_credentials(lp, fallback_machine=True)
566         samdb = SamDB(url=H, session_info=system_session(),
567                       credentials=creds, lp=lp)
568
569         attrs = None
570         if group_attrs:
571             attrs = group_attrs.split(",")
572
573         filter = ("(&(sAMAccountType=%d)(sAMAccountName=%s))" %
574                   (ATYPE_SECURITY_GLOBAL_GROUP,
575                    ldb.binary_encode(groupname)))
576
577         domaindn = samdb.domain_dn()
578
579         try:
580             res = samdb.search(base=domaindn, expression=filter,
581                                scope=ldb.SCOPE_SUBTREE, attrs=attrs)
582             user_dn = res[0].dn
583         except IndexError:
584             raise CommandError('Unable to find group "%s"' % (groupname))
585
586         for msg in res:
587             user_ldif = samdb.write_ldif(msg, ldb.CHANGETYPE_NONE)
588             self.outf.write(user_ldif)
589
590
591 class cmd_group_stats(Command):
592     """Summary statistics about group memberships."""
593
594     synopsis = "%prog [options]"
595
596     takes_options = [
597         Option("-H", "--URL", help="LDB URL for database or target server", type=str,
598                metavar="URL", dest="H"),
599     ]
600
601     takes_optiongroups = {
602         "sambaopts": options.SambaOptions,
603         "credopts": options.CredentialsOptions,
604         "versionopts": options.VersionOptions,
605     }
606
607     def num_in_range(self, range_min, range_max, group_freqs):
608         total_count = 0
609         for members, count in group_freqs.items():
610             if range_min <= members and members <= range_max:
611                 total_count += count
612
613         return total_count
614
615     def run(self, sambaopts=None, credopts=None, versionopts=None, H=None):
616         lp = sambaopts.get_loadparm()
617         creds = credopts.get_credentials(lp, fallback_machine=True)
618
619         samdb = SamDB(url=H, session_info=system_session(),
620                       credentials=creds, lp=lp)
621
622         domain_dn = samdb.domain_dn()
623         res = samdb.search(domain_dn, scope=ldb.SCOPE_SUBTREE,
624                            expression=("(objectClass=group)"),
625                            attrs=["samaccountname", "member"])
626
627         # first count up how many members each group has
628         group_assignments = {}
629         total_memberships = 0
630
631         for msg in res:
632             name = str(msg.get("samaccountname"))
633             memberships = len(msg.get("member", default=[]))
634             group_assignments[name] = memberships
635             total_memberships += memberships
636
637         self.outf.write("Group membership statistics*\n")
638         self.outf.write("-------------------------------------------------\n")
639         self.outf.write("Total groups: {0}\n".format(res.count))
640         self.outf.write("Total memberships: {0}\n".format(total_memberships))
641         average = float(total_memberships / res.count)
642         self.outf.write("Average members per group: %.2f\n" % average)
643         group_names = list(group_assignments.keys())
644         group_members = list(group_assignments.values())
645         # note that some builtin groups have no members, so this doesn't tell us much
646         idx = group_members.index(min(group_members))
647         self.outf.write("Min members: {0} ({1})\n".format(group_members[idx],
648                                                           group_names[idx]))
649         idx = group_members.index(max(group_members))
650         max_members = group_members[idx]
651         self.outf.write("Max members: {0} ({1})\n\n".format(max_members,
652                                                             group_names[idx]))
653
654         # convert this to the frequency of group membership, i.e. how many
655         # groups have 5 members, how many have 6 members, etc
656         group_freqs = defaultdict(int)
657         for group, count in group_assignments.items():
658             group_freqs[count] += 1
659
660         # now squash this down even further, so that we just display the number
661         # of groups that fall into one of the following membership bands
662         bands = [(0, 1), (2, 4), (5, 9), (10, 14), (15, 19), (20, 24), (25, 29),
663                  (30, 39), (40, 49), (50, 59), (60, 69), (70, 79), (80, 89),
664                  (90, 99), (100, 149), (150, 199), (200, 249), (250, 299),
665                  (300, 399), (400, 499), (500, 999), (1000, 1999),
666                  (2000, 2999), (3000, 3999), (4000, 4999), (5000, 9999),
667                  (10000, max_members)]
668
669         self.outf.write("Members        Number of Groups\n")
670         self.outf.write("-------------------------------------------------\n")
671
672         for band in bands:
673             band_start = band[0]
674             band_end = band[1]
675             if band_start > max_members:
676                 break
677
678             num_groups = self.num_in_range(band_start, band_end, group_freqs)
679
680             if num_groups != 0:
681                 band_str = "{0}-{1}".format(band_start, band_end)
682                 self.outf.write("%13s  %u\n" % (band_str, num_groups))
683
684         self.outf.write("\n* Note this does not include nested group memberships\n")
685
686
687 class cmd_group(SuperCommand):
688     """Group management."""
689
690     subcommands = {}
691     subcommands["add"] = cmd_group_add()
692     subcommands["delete"] = cmd_group_delete()
693     subcommands["addmembers"] = cmd_group_add_members()
694     subcommands["removemembers"] = cmd_group_remove_members()
695     subcommands["list"] = cmd_group_list()
696     subcommands["listmembers"] = cmd_group_list_members()
697     subcommands["move"] = cmd_group_move()
698     subcommands["show"] = cmd_group_show()
699     subcommands["stats"] = cmd_group_stats()