b1c610bb2e94286608b3d05cd596c6d4205707a3
[mspang/pyceo.git] / pylib / csc / adm / accounts.py
1 """
2 UNIX Accounts Administration
3
4 This module contains functions for creating, deleting, and manipulating
5 UNIX user accounts and account groups in the CSC LDAP directory.
6 """
7 import re, pwd, grp, os, pwd
8 from csc.common import conf
9 from csc.common.excep import InvalidArgument
10 from csc.backends import ldapi, krb
11
12
13 ### Configuration ###
14
15 CONFIG_FILE = '/etc/csc/accounts.cf'
16
17 cfg = {}
18
19 def configure():
20     """Helper to load the accounts configuration. You need not call this."""
21
22     string_fields = [ 'member_shell', 'member_home', 'member_desc',
23             'member_group', 'club_shell', 'club_home', 'club_desc',
24             'club_group', 'admin_shell', 'admin_home', 'admin_desc',
25             'admin_group', 'group_desc', 'username_regex', 'groupname_regex',
26             'shells_file', 'server_url', 'users_base', 'groups_base',
27             'sasl_mech', 'sasl_realm', 'admin_bind_keytab', 'admin_bind_dn',
28             'admin_bind_userid', 'realm', 'admin_principal', 'admin_keytab' ]
29     numeric_fields = [ 'member_min_id', 'member_max_id', 'club_min_id',
30             'club_max_id', 'admin_min_id', 'admin_max_id', 'group_min_id',
31             'group_max_id', 'min_password_length' ]
32
33     # read configuration file
34     cfg_tmp = conf.read(CONFIG_FILE)
35
36     # verify configuration (not necessary, but prints a useful error)
37     conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
38     conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
39
40     # update the current configuration with the loaded values
41     cfg.update(cfg_tmp)
42
43
44
45 ### Exceptions  ###
46
47 KrbException = krb.KrbException
48 LDAPException = ldapi.LDAPException
49 ConfigurationException = conf.ConfigurationException
50
51 class AccountException(Exception):
52     """Base exception class for account-related errors."""
53
54 class NoAvailableIDs(AccountException):
55     """Exception class for exhausted userid ranges."""
56     def __init__(self, minid, maxid):
57         self.minid, self.maxid = minid, maxid
58     def __str__(self):
59         return "No free ID pairs found in range [%d, %d]" % (self.minid, self.maxid)
60
61 class NameConflict(AccountException):
62     """Exception class for name conflicts with existing accounts/groups."""
63     def __init__(self, name, nametype, source):
64         self.name, self.nametype, self.source = name, nametype, source
65     def __str__(self):
66         return 'Name Conflict: %s "%s" already exists in %s' % (self.nametype, self.name, self.source)
67
68 class NoSuchAccount(AccountException):
69     """Exception class for missing LDAP entries for accounts."""
70     def __init__(self, account, source):
71         self.account, self.source = account, source
72     def __str__(self):
73         return 'Account "%s" not found in %s' % (self.account, self.source)
74
75 class NoSuchGroup(AccountException):
76     """Exception class for missing LDAP entries for groups."""
77     def __init__(self, account, source):
78         self.account, self.source = account, source
79     def __str__(self):
80         return 'Account "%s" not found in %s' % (self.account, self.source)
81
82
83
84 ### Connection Management ###
85
86 ldap_connection = ldapi.LDAPConnection()
87 krb_connection = krb.KrbConnection()
88
89 def connect():
90     """Connect to LDAP and Kerberos and load configuration. You must call before anything else."""
91
92     configure()
93
94     # connect to the LDAP server
95     ldap_connection.connect_sasl(cfg['server_url'], cfg['admin_bind_dn'],
96         cfg['sasl_mech'], cfg['sasl_realm'], cfg['admin_bind_userid'],
97         ('keytab', cfg['admin_bind_keytab']), cfg['users_base'],
98         cfg['groups_base'])
99
100     # connect to the Kerberos master server
101     krb_connection.connect(cfg['admin_principal'], cfg['admin_keytab'])
102
103
104 def disconnect():
105     """Disconnect from LDAP and Kerberos. Call this before quitting."""
106
107     ldap_connection.disconnect()
108     krb_connection.disconnect()
109
110
111 def connected():
112     """Determine whether a connection has been established."""
113
114     return ldap_connection.connected() and krb_connection.connected()
115
116
117
118 ### General Account Management ###
119
120 def create(username, name, minimum_id, maximum_id, home, password=None, description='', gecos='', shell=None, group=None):
121     """
122     Creates a UNIX user account. This involves first creating an LDAP
123     directory entry, then creating a Kerberos principal.
124
125     The UID/GID namespace may be divided into ranges according to account type
126     or purpose. This function requires such a range to allocate ids from.
127
128     If no password is specified or password is None, no Kerberos principal
129     will be created and the account will not be capable of direct login.
130     This is desirable for administrative and club accounts.
131
132     If no group is specified, a new group will be created with the same name
133     as the user. The uid of the created user and gid of the created group
134     will be numerically equal. There is generally no reason to specify a
135     group. Furthermore, only groups present in the directory are allowed.
136
137     If an account is relevant to only one system and will not own files on
138     NFS, please use adduser(8) on the relevant system instead.
139
140     Generally do not directly use this function. The create_member(),
141     create_club(), and create_adm() functions will fill in most of
142     the details for you and may do additional checks.
143
144     Parameters:
145         username    - UNIX username for the account
146         name        - common name LDAP attribute
147         minimum_id  - the smallest UID/GID to assign
148         maximum_id  - the largest UID/GID to assign
149         home        - home directory LDAP attribute
150         password    - password for the account
151         description - description LDAP attribute
152         gecos       - gecos LDAP attribute
153         shell       - user shell LDAP attribute
154         group       - primary group for account
155
156     Exceptions:
157         NameConflict     - when the name conflicts with an existing account
158         NoSuchGroup      - when the group parameter corresponds to no group
159         NoAvailableIDs   - when the ID range is exhausted
160         AccountException - when not connected
161
162     Returns: the uid number of the new account
163
164     Example: create('mspang', 'Michael Spang', 20000, 39999,
165                  '/users/mspang', 'secret', 'CSC Member Account',
166                  build_gecos('Michael Spang', other='3349'),
167                  '/bin/bash', 'users')
168     """
169  
170     # check connection
171     if not connected():
172         raise AccountException("Not connected to LDAP and Kerberos")
173
174     # check for path characters in username (. and /)
175     if re.search('[\\./]', username):
176         raise InvalidArgument("username", username, "invalid characters")
177
178     check_name_usage(username)
179
180     # determine the first available userid
181     userid = first_available_id(minimum_id, maximum_id)
182     if not userid:
183         raise NoAvailableIDs(minimum_id, maximum_id)
184
185     # determine the account's default group
186     if group: 
187         group_data = ldap_connection.group_lookup(group)
188         if not group_data:
189             raise NoSuchGroup(group, "LDAP")
190         gid = int(group_data['gidNumber'][0])
191     else:
192         gid = userid
193
194     ### User creation ###
195
196     if not ldap_connection.user_lookup(username):
197
198         # create the LDAP entry
199         ldap_connection.account_add(username, name, userid, gid, home, shell, gecos, description)
200
201     else:
202
203         # add the required attribute to the LDAP entry
204         ldap_connection.member_add_account(username, userid, gid, home, shell, gecos)
205
206     # create a user group if no other group was specified
207     if not group:
208         ldap_connection.group_add(username, gid)
209
210     # create the Kerberos principal
211     if password:    
212         principal = username + '@' + cfg['realm']
213         krb_connection.add_principal(principal, password)
214
215     return userid
216
217
218 def delete(username):
219     """
220     Deletes a UNIX account. Both LDAP entries and Kerberos principals that
221     match username are deleted. A group with the same name is deleted too,
222     if it exists and has the same id as the account.
223
224     Returns: tuple with deleted LDAP and Kerberos information
225              note: the Kerberos keys are not recoverable 
226     """
227
228     # check connection
229     if not connected():
230         raise AccountException("Not connected to LDAP and Kerberos")
231
232     # build principal name from username
233     principal = username + '@' + cfg['realm']
234
235     # get account state 
236     ldap_state = ldap_connection.user_lookup(username)
237     krb_state = krb_connection.get_principal(principal)
238     group_state = ldap_connection.group_lookup(username)
239
240     # don't delete group unless the gid matches the account's uid
241     if not ldap_state or group_state and ldap_state['uidNumber'][0] != group_state['gidNumber'][0]:
242         group_state = None
243
244     # fail if no data is found in either LDAP or Kerberos
245     if not ldap_state and not krb_state:
246         raise NoSuchAccount(username, "LDAP/Kerberos")
247
248     ### User deletion ###
249
250     # delete the LDAP entries
251     if ldap_state:
252         ldap_connection.user_delete(username)
253     if group_state:
254         ldap_connection.group_delete(username)
255
256     # delete the Kerberos principal
257     if krb_state:
258         krb_connection.delete_principal(principal)
259
260     return ldap_state, group_state, krb_state
261
262
263 def status(username):
264     """
265     Checks if an account exists.
266
267     Returns: a boolean 2-tuple (exists, has_password)
268     """
269
270     ldap_state = ldap_connection.account_lookup(username)
271     krb_state = krb_connection.get_principal(username)
272     return (ldap_state is not None, krb_state is not None)
273
274
275 def add_password(username, password):
276     """
277     Creates a principal for an existing, passwordless account.
278
279     Parameters:
280         username - a UNIX account username
281         password - a password for the acccount
282     """
283     check_account_status(username)
284     ldap_state = ldap_connection.user_lookup(username)
285     if int(ldap_state['uidNumber'][0]) < 1000:
286         raise AccountException("Attempted to add password to a system account")
287     krb_connection.add_principal(username, password)
288
289
290 def reset_password(username, newpassword):
291     """
292     Changes a user's password.
293
294     Parameters:
295         username    - a UNIX account username
296         newpassword - a new password for the account
297     """
298     check_account_status(username, require_krb=True)
299     krb_connection.change_password(username, newpassword)
300
301
302 def get_uid(username):
303     """
304     Determine the numeric uid of an account.
305
306     Returns: a uid as an int
307     """
308     check_account_status(username)
309     account_data = ldap_connection.user_lookup(username)
310     return int(account_data['uidNumber'][0])
311
312
313 def get_gid(username):
314     """
315     Determine the numeric gid of an account (default group).
316
317     Returns: a gid as an int
318     """
319     check_account_status(username)
320     account_data = ldap_connection.user_lookup(username)
321     return int(account_data['gidNumber'][0])
322
323
324 def get_gecos(username, account_data=None):
325     """
326     Retrieve GECOS information of a user.
327
328     Returns: raw gecos data as a string, or None
329     """
330     check_account_status(username)
331     if not account_data:
332         account_data = ldap_connection.user_lookup(username)
333     if 'gecos' in account_data:
334         return account_data['gecos'][0]
335     else:
336         return None
337     
338
339 def update_gecos(username, gecos_data):
340     """
341     Set GECOS information for a user. The LDAP 'cn' attribute
342     is also updated with the user's full name.
343
344     See build_gecos() and parse_gecos() for help dealing with
345     the chfn(1) GECOS format.
346
347     Use update_name() to update the name porition, as it will update
348     the LDAP 'cn' atribute as well.
349
350     Parameters:
351         username   - a UNIX account username
352         gecos_data - a raw gecos string
353
354     Example: update_gecos('mspang', build_gecos('Mike Spang'))
355     """
356     check_account_status(username)
357     entry = ldap_connection.user_lookup(username)
358     entry['gecos'] = [ gecos_data ]
359     ldap_connection.user_modify(username, entry)
360
361
362 def get_name(username):
363     """
364     Get the real name of a user. Note that this name is usually stored
365     in both the 'cn' attribute and the 'gecos' attribute, and they
366     may differ. This function will always return the first in the'cn'
367     version. If there are multiple, the first in the list is returned.
368
369     Returns: the common name associated with the account
370     """
371     check_account_status(username)
372     account_data = ldap_connection.user_lookup(username)
373     return account_data['cn'][0]
374
375
376 def update_name(username, name, update_gecos=True):
377     """
378     Set the real name of a user. This name will be updated in both
379     the GECOS field and the common name field. If there are multiple
380     common names, they will *all* be overwritten with the provided name.
381
382     Parameters:
383         username     - the UNIX account usernmae
384         nane         - new real name for the account
385         update_gecos - whether to update gecos field
386     """
387     check_account_status(username)
388     account_data = ldap_connection.user_lookup(username)
389     account_data['cn'] = [ name ]
390     if update_gecos:
391         gecos_dict = parse_gecos(get_gecos(username, account_data))
392         gecos_dict['fullname'] = name
393         account_data['gecos'] = [ build_gecos(**gecos_dict) ]
394     ldap_connection.user_modify(username, account_data)
395
396
397 def get_shell(username):
398     """
399     Retrieve a user's shell.
400
401     Returns: the path to the shell, or None
402     """
403     check_account_status(username)
404     account_data = ldap_connection.user_lookup(username)
405     if 'loginShell' not in account_data or len(account_data['loginShell']) < 1:
406         return None
407     return account_data['loginShell'][0]
408
409
410 def update_shell(username, shell, check=True):
411     """
412     Set a user's shell.
413
414     Parameters:
415         username - the UNIX account username
416         shell    - the new shell for the user
417         check    - whether to check if the shell is in the shells file
418
419     Exceptions:
420         InvalidArgument - on nonexistent shell
421     """
422
423     # reject nonexistent or nonexecutable shells
424     if not os.access(shell, os.X_OK) or not os.path.isfile(shell):
425         raise InvalidArgument("shell", shell, "not an executable file")
426
427     if check:
428         
429         # load shells file
430         shells = open(cfg['shells_file']).read().split("\n")
431         shells = [ x for x in shells if x and x[0] == '/' and '#' not in x ]
432
433         # reject shells that aren't in the shells file (usually /etc/shells)
434         if check and shell not in shells:
435             raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
436     
437     check_account_status(username)
438     account_data = ldap_connection.user_lookup(username)
439     account_data['loginShell'] = [ shell ]
440     ldap_connection.user_modify(username, account_data)
441     
442
443 def get_home(username):
444     """
445     Get the home directory of a user.
446
447     Returns: path to the user's home directory
448     """
449     check_account_status(username)
450     account_data = ldap_connection.user_lookup(username)
451     return account_data['homeDirectory'][0]
452
453
454 def update_home(username, home):
455     """
456     Set the home directory of a user.
457
458     Parameters:
459         username - the UNIX account username
460         home     - new home directory for the user
461     """
462     check_account_status(username)
463     if not home[0] == '/':
464         raise InvalidArgument('home', home, 'relative path')
465     account_data = ldap_connection.user_lookup(username)
466     account_data['homeDirectory'] = [ home ]
467     ldap_connection.user_modify(username, account_data)
468
469
470
471 ### General Group Management ###
472
473 def create_group(groupname, minimum_id=None, maximum_id=None, description=''):
474     """
475     Creates a UNIX group. This involves adding an entry to LDAP.
476
477     The UID/GID namespace may be divided into ranges according to group
478     type or purpose. This function accept such a range to allocate ids from.
479     If none is specified, it will use the default from the configuration file.
480
481     If a group needs directory accounts as members, or if the group will
482     own files on NFS, you must add it to the directory with this function.
483
484     If a group is relevant to only a single system and does not need any
485     directory accounts as members, create it with the addgroup(8) utility
486     for just that system instead.
487
488     If you do not specify description, the default will be used. If no
489     description at all is wanted, set description to None.
490
491     Parameters:
492         groupname   - UNIX group name
493         minimum_id  - the smallest GID to assign
494         maximum_id  - the largest GID to assign
495         description - description LDAP attribute
496
497     Exceptions:
498         NoAvailableIDs - when the ID range is exhausted
499         GroupException - when not connected
500         LDAPException  - on LDAP failure
501
502     Returns: the gid number of the new group
503
504     Example: create_group('ninjas', 10000, 14999)
505     """
506
507     # check connection
508     if not connected():
509         raise AccountException("Not connected to LDAP and Kerberos")
510
511     # check groupname format
512     if not groupname or not re.match(cfg['groupname_regex'], groupname):
513         raise InvalidArgument("groupname", groupname, "expected format %s" % repr(cfg['groupname_regex']))
514
515     # load defaults for unspecified parameters
516     if not minimum_id and maximum_id:
517         minimum_id = cfg['group_min_id']
518         maximum_id = cfg['group_max_id']
519     if description == '':
520         description = cfg['group_desc']
521
522     check_name_usage(groupname)
523
524     # determine the first available groupid
525     groupid = first_available_id(cfg['group_min_id'], cfg['group_max_id'])
526     if not groupid:
527         raise NoAvailableIDs(minimum_id, maximum_id)
528
529     ### Group creation ###
530
531     # create the LDAP entry
532     ldap_connection.group_add(groupname, groupid, description)
533
534     return groupid
535
536
537 def delete_group(groupname):
538     """
539     Deletes a group.     
540
541     Returns: the deleted LDAP information
542     """
543
544     # check connection
545     if not connected():
546         raise AccountException("Not connected to LDAP")
547
548     # get account state 
549     ldap_state = ldap_connection.group_lookup(groupname)
550
551     # fail if no data is found in either LDAP or Kerberos
552     if not ldap_state:
553         raise NoSuchGroup(groupname, "LDAP")
554
555     ### Group deletion ###
556
557     # delete the LDAP entry
558     if ldap_state:
559         ldap_connection.group_delete(groupname)
560
561     return ldap_state
562
563
564 def check_membership(username, groupname):
565     """
566     Determines whether an account is a member of a group
567     by checking the group's member list and the user's
568     default group.
569
570     Returns: True if username is a member of groupname
571     """
572
573     check_account_status(username)
574     check_group_status(groupname)
575
576     group_data = ldap_connection.group_lookup(groupname)
577     user_data = ldap_connection.user_lookup(username)
578
579     group_members = get_members(groupname, group_data)
580     group_id = int(group_data['gidNumber'][0])
581     user_group = int(user_data['gidNumber'][0])
582
583     return username in group_members or group_id == user_group
584     
585
586 def get_members(groupname, group_data=None):
587     """
588     Retrieve a list of members of a group. This list
589     will not include accounts that are members because
590     their gidNumber attribute matches the group's.
591
592     Parameters:
593         group_data - result of a previous LDAP lookup on groupname (internal)
594
595     Returns: a list of usernames
596     """
597
598     check_group_status(groupname)
599
600     if not group_data:
601         group_data = ldap_connection.group_lookup(groupname)
602
603     if 'memberUid' in group_data:
604         group_members = group_data['memberUid']
605     else:
606         group_members = []
607
608     return group_members
609
610     
611 def add_member(username, groupname):
612     """
613     Add an account to the list of group members.
614
615     Returns: False if the user was already a member, else True
616     """
617
618     check_account_status(username)
619     check_group_status(groupname)
620
621     group_data = ldap_connection.group_lookup(groupname)
622     group_members = get_members(groupname, group_data)
623
624     if groupname in group_members:
625         return False
626     
627     group_members.append(username)
628     group_data['memberUid'] = group_members
629     ldap_connection.group_modify(groupname, group_data)
630
631     return True
632
633
634 def remove_member(username, groupname):
635     """
636     Removes an account from the list of group members.
637
638     Returns: True if the user was a member, else False
639     """
640
641     check_account_status(username)
642     check_group_status(groupname)
643
644     group_data = ldap_connection.group_lookup(groupname)
645     group_members = get_members(groupname, group_data)
646
647     if username not in group_members:
648         return False
649
650     while username in group_members:
651         group_members.remove(username)
652
653     group_data['memberUid'] = group_members
654     ldap_connection.group_modify(groupname, group_data)
655
656     return True
657
658
659 ### Account Types ###
660
661 def create_member(username, password, name):
662     """
663     Creates a UNIX user account with options tailored to CSC members.
664
665     Parameters:
666         username - the desired UNIX username
667         password - the desired UNIX password
668         name     - the member's real name
669
670     Exceptions:
671         InvalidArgument - on bad account attributes provided
672
673     Returns: the uid number of the new account
674
675     See: create()
676     """
677
678     # check connection
679     if not connected():
680         raise AccountException("not connected to LDAP and Kerberos")
681
682     # check username format
683     if not username or not re.match(cfg['username_regex'], username):
684         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
685
686     # check password length
687     if not password or len(password) < cfg['min_password_length']:
688         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
689
690     minimum_id = cfg['member_min_id']
691     maximum_id = cfg['member_max_id']
692     home = cfg['member_home'] + '/' + username
693     description = cfg['member_desc']
694     gecos_field = build_gecos(name)
695     shell = cfg['member_shell']
696     group = cfg['member_group']
697
698     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
699
700
701 def create_club(username, name):
702     """
703     Creates a UNIX user account with options tailored to CSC-hosted clubs.
704     
705     Parameters:
706         username - the desired UNIX username
707         name     - the club name
708
709     Exceptions:
710         InvalidArgument - on bad account attributes provided
711
712     Returns: the uid number of the new account
713
714     See: create()
715     """
716
717     # check connection
718     if not connected():
719         raise AccountException("not connected to LDAP and Kerberos")
720
721     # check username format
722     if not username or not re.match(cfg['username_regex'], username):
723         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
724     
725     password = None
726     minimum_id = cfg['club_min_id']
727     maximum_id = cfg['club_max_id']
728     home = cfg['club_home'] + '/' + username
729     description = cfg['club_desc']
730     gecos_field = build_gecos(name)
731     shell = cfg['club_shell']
732     group = cfg['club_group']
733
734     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
735
736
737 def create_adm(username, name):
738     """
739     Creates a UNIX user account with options tailored to long-lived
740     administrative accounts (e.g. vp, www, sysadmin, etc). 
741
742     Parameters:
743         username - the desired UNIX username
744         name     - a descriptive name or purpose
745
746     Exceptions:
747         InvalidArgument - on bad account attributes provided
748
749     Returns: the uid number of the new account
750
751     See: create()
752     """
753
754     # check connection
755     if not connected():
756         raise AccountException("not connected to LDAP and Kerberos")
757
758     # check username format
759     if not username or not re.match(cfg['username_regex'], username):
760         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
761
762     password = None
763     minimum_id = cfg['admin_min_id']
764     maximum_id = cfg['admin_max_id']
765     home = cfg['admin_home'] + '/' + username
766     description = cfg['admin_desc']
767     gecos_field = build_gecos(name)
768     shell = cfg['admin_shell']
769     group = cfg['admin_group']
770
771     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
772
773
774
775 ### Miscellaneous Helpers ###
776
777 def check_name_usage(name):
778     """
779     Helper function: Ensures a user or group name does not exist in either
780     Kerberos, LDAP, or through calls to libc and NSS. This is used prior to
781     creating an accout or group to determine if the name is free.
782
783     Parameters:
784         name - the user or group name to check for
785
786     Exceptions:
787         NameConflict - if the name was found anywhere
788     """
789
790     # see if user exists in LDAP
791     if ldap_connection.account_lookup(name):
792         raise NameConflict(name, "account", "LDAP")
793
794     # see if group exists in LDAP
795     if ldap_connection.group_lookup(name):
796         raise NameConflict(name, "group", "LDAP")
797
798     # see if user exists in Kerberos
799     principal = name + '@' + cfg['realm']
800     if krb_connection.get_principal(principal):
801         raise NameConflict(name, "account", "KRB")
802
803     # see if user exists by getpwnam(3)
804     try:
805         pwd.getpwnam(name)
806         raise NameConflict(name, "account", "NSS")
807     except KeyError:
808         pass
809
810     # see if group exists by getgrnam(3)
811     try:
812         grp.getgrnam(name)
813         raise NameConflict(name, "group", "NSS")
814     except KeyError:
815         pass
816
817
818 def check_account_status(username, require_ldap=True, require_krb=False):
819     """Helper function to verify that an account exists."""
820
821     if not connected():
822         raise AccountException("Not connected to LDAP and Kerberos")
823     if require_ldap and not ldap_connection.account_lookup(username):
824         raise NoSuchAccount(username, "LDAP")
825     if require_krb and not krb_connection.get_principal(username):
826         raise NoSuchAccount(username, "KRB")
827
828
829 def check_group_status(groupname):
830     """Helper function to verify that a group exists."""
831     
832     if not connected(): 
833         raise AccountException("Not connected to LDAP and Kerberos")
834     if not ldap_connection.group_lookup(groupname):
835         raise NoSuchGroup(groupname, "LDAP")
836
837
838 def parse_gecos(gecos_data):
839     """
840     Build a dictionary out of a chfn(1) style GECOS string.
841
842     Parameters:
843         gecos_data - a gecos string formatted by chfn(1)
844
845     Returns: a dictinoary of components
846     
847     Example: parse_gecos('Michael Spang,,,') -> {
848                  'fullname': 'Michael Spang',
849                  'roomnumber': '',
850                  'workphone': '',
851                  'homephone': '',
852                  'other': None
853              }
854     """
855     
856     # silently remove erroneous colons
857     while ':' in gecos_data:
858         index = gecos_data.find(':')
859         gecos_data = gecos_data[:index] + gecos_data[index+1:]
860
861     gecos_vals = gecos_data.split(',', 4)
862     gecos_vals.extend([ None ] * (5-len(gecos_vals)))
863     gecos_keys = ['fullname', 'roomnumber', 'workphone',
864                   'homephone', 'other' ]
865     return dict((gecos_keys[i], gecos_vals[i]) for i in xrange(5))
866
867
868 def build_gecos(fullname=None, roomnumber=None, workphone=None, homephone=None, other=None):
869     """
870     Build a chfn(1)-style GECOS field from its components.
871
872     See: chfn(1)
873     
874     Parameters:
875         fullname   - GECOS full name
876         roomnumber - GECOS room number
877         workphone  - GECOS work phone
878         homephone  - GECOS home phone
879         other      - GECOS other
880
881     Returns: string appropriate for a GECOS field value
882     """
883
884     # check first four params for illegal chars
885     args = (fullname, roomnumber, workphone, homephone)
886     names = ('fullname', 'roomnumber', 'workphone', 'homephone')
887     for index in xrange(4):
888         for badchar in (',', ':', '='):
889             if args[index] and badchar in str(args[index]):
890                 raise InvalidArgument(names[index], args[index], "invalid characters")
891
892     # check other for illegal chars
893     if other and ':' in str(other):
894         raise InvalidArgument('other', other, "invalid characters")
895     
896     # join the fields
897     if fullname is not None:
898         gecos_data = str(fullname)
899     fields = [ fullname, roomnumber, workphone, homephone, other ]
900     for idx in xrange(len(fields), 0, -1):
901         if not fields[idx-1]:
902             fields.pop()
903         else:
904             break
905     while None in fields:
906         fields[fields.index(None)] = ''
907     return ','.join(map(str, fields))
908
909
910 def check_id_nss(ugid):
911     """Helper to ensure there is no account or group with an ID."""
912
913     try:
914         pwd.getpwuid(ugid)
915         return False
916     except KeyError:
917         pass
918
919     try:
920         grp.getgrgid(ugid)
921         return False
922     except KeyError:
923         pass
924
925     return True
926
927
928 def first_available_id(minimum, maximum):
929     """
930     Determines the first available id within a range.
931
932     To be "available", there must be neither a user
933     with the id nor a group with the id.
934
935     Parameters:
936         minimum - smallest id that may be returned
937         maximum - largest id that may be returned
938
939     Returns: the id, or None if there are none available
940
941     Example: first_available_id(20000, 40000) -> 20018
942     """
943
944     # get lists of used uids and gids in LDAP
945     uids = ldap_connection.used_uids(minimum, maximum)
946     gids = ldap_connection.used_gids(minimum, maximum)
947
948     # iterate through the lists and return the first available
949     for ugid in xrange(minimum, maximum+1):
950         if ugid not in uids and ugid not in gids and check_id_nss(ugid):
951             return ugid
952
953     # no id found within the range
954     return None
955
956
957
958 ### Tests ###
959
960 if __name__ == '__main__':
961
962     import random
963     from csc.common.test import *
964
965     def test_exists(name):
966         return ldap_connection.user_lookup(name) is not None, \
967             ldap_connection.group_lookup(name) is not None, \
968             krb_connection.get_principal(name) is not None
969
970     # t=test u=user m=member a=adminv c=club
971     # g=group r=real e=expected n=new
972     tuname = 'testuser'
973     turname = 'Test User'
974     tunrname = 'User Test'
975     tudesc = 'May be deleted'
976     tuhome = '/home/testuser'
977     tunhome = '/users/testuser'
978     tushell = '/bin/false'
979     tunshell = '/bin/true'
980     tugecos = 'Test User,,,'
981     tungecos = 'User Test,,,'
982     tmname = 'testmember'
983     tmrname = 'Test Member'
984     tmmid = 31415
985     tcname = 'testclub'
986     tcrname = 'Test Club'
987     tcmid = 98696
988     taname = 'testadm'
989     tarname = 'Test Adm' 
990     tgname = 'testgroup'
991     tgdesc = 'Test Group'
992     minid = 99999000
993     maxid = 100000000
994     tpw = str(random.randint(10**30, 10**31-1))
995     tgecos = 'a,b,c,d,e'
996     tgecos_args = 'a','b','c','d','e'
997
998     test(connect)
999     connect()
1000     success()
1001
1002     try:
1003         delete(tuname); delete(tmname)
1004         delete(tcname); delete(taname)
1005         delete_group(tgname)
1006     except (NoSuchAccount, NoSuchGroup):
1007         pass
1008
1009     test(create)
1010     create(tuname, turname, minid, maxid, tuhome, tpw, tudesc, tugecos, tushell)
1011     exists = test_exists(tuname)
1012     expected = (True, True, True)
1013     assert_equal(expected, exists)
1014     success()
1015
1016     test(create_member)
1017     create_member(tmname, tpw, tmrname, tmmid)
1018     exists = test_exists(tmname)
1019     expected = (True, False, True)
1020     assert_equal(expected, exists)
1021     success()
1022
1023     test(create_club)
1024     create_club(tcname, tmrname, tmmid)
1025     exists = test_exists(tcname)
1026     expected = (True, False, False)
1027     assert_equal(expected, exists)
1028     success()
1029
1030     test(create_adm)
1031     create_adm(taname, tarname)
1032     exists = test_exists(taname)
1033     expected = (True, False, False)
1034     assert_equal(expected, exists)
1035     success()
1036
1037     test(create_group)
1038     create_group(tgname, minid, maxid, tgdesc)
1039     exists = test_exists(tgname)
1040     expected = (False, True, False)
1041     assert_equal(expected, exists)
1042     success()
1043
1044     test(status)
1045     assert_equal((True, True), status(tmname))
1046     assert_equal((True, False), status(tcname))
1047     success()
1048
1049     test(reset_password)
1050     reset_password(tuname, str(int(tpw)/2))
1051     reset_password(tmname, str(int(tpw)/3))
1052     negative(reset_password, (tcname,str(int(tpw)/4)), NoSuchAccount, "club should not have password")
1053     negative(reset_password, (taname,str(int(tpw)/5)), NoSuchAccount, "club should not have password")
1054     success()
1055
1056     test(get_uid)
1057     tuuid = get_uid(tuname)
1058     assert_equal(True, int(tuuid) >= 0)
1059     success()
1060
1061     test(get_gid)
1062     tugid = get_gid(tuname)
1063     assert_equal(True, int(tugid) >= 0)
1064     success()
1065
1066     test(get_gecos)
1067     ugecos = get_gecos(tuname)
1068     assert_equal(tugecos, ugecos)
1069     success()
1070
1071     test(update_gecos)
1072     update_gecos(tuname, tungecos)
1073     ugecos = get_gecos(tuname)
1074     assert_equal(tungecos, ugecos)
1075     success()
1076
1077     test(get_shell)
1078     ushell = get_shell(tuname)
1079     assert_equal(tushell, ushell)
1080     success()
1081
1082     test(update_shell)
1083     update_shell(tuname, tunshell, False)
1084     ushell = get_shell(tuname)
1085     assert_equal(ushell, tunshell)
1086     success()
1087
1088     test(get_name)
1089     urname = get_name(tuname)
1090     assert_equal(turname, urname)
1091     success()
1092
1093     test(update_name)
1094     update_name(tuname, tunrname)
1095     urname = get_name(tuname)
1096     assert_equal(urname, tunrname)
1097     success()
1098
1099     test(get_home)
1100     uhome = get_home(tuname)
1101     assert_equal(tuhome, uhome)
1102     success()
1103
1104     test(update_home)
1105     update_home(tuname, tunhome)
1106     urhome = get_home(tuname)
1107     assert_equal(urhome, tunhome)
1108     success()
1109
1110     test(get_members)
1111     members = get_members(tgname)
1112     expected = []
1113     assert_equal(expected, members)
1114     success()
1115
1116     test(check_membership)
1117     member = check_membership(tuname, tgname)
1118     assert_equal(False, member)
1119     member = check_membership(tuname, tuname)
1120     assert_equal(True, member)
1121     success()
1122
1123     test(add_member)
1124     add_member(tuname, tgname)
1125     assert_equal(True, check_membership(tuname, tgname))
1126     assert_equal([tuname], get_members(tgname))
1127     success()
1128
1129     test(remove_member)
1130     assert_equal(True, remove_member(tuname, tgname))
1131     assert_equal(False, check_membership(tuname, tgname))
1132     assert_equal(False, remove_member(tuname, tgname))
1133     success()
1134
1135     test(build_gecos)
1136     assert_equal(tgecos, build_gecos(*tgecos_args))
1137     success()
1138
1139     test(parse_gecos)
1140     gecos_dict = parse_gecos(tgecos)
1141     assert_equal(tgecos, build_gecos(**gecos_dict))
1142     success()
1143
1144     test(delete)
1145     delete(tuname)
1146     exists = test_exists(tuname)
1147     expected = (False, False, False)
1148     assert_equal(expected, exists)
1149     delete(tmname)
1150     exists = test_exists(tmname)
1151     assert_equal(expected, exists)
1152     delete(tcname)
1153     exists = test_exists(tcname)
1154     assert_equal(expected, exists)
1155     delete(taname)
1156     exists = test_exists(taname)
1157     assert_equal(expected, exists)
1158     success()
1159
1160     test(delete_group)
1161     delete_group(tgname)
1162     exists = test_exists(tgname)
1163     expected = (False, False, False)
1164     assert_equal(expected, exists)
1165     success()
1166
1167     test(disconnect)
1168     disconnect()
1169     success()