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