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