Accept 'j' and 'k' for 'down' and 'up' in CEO menus.
[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) GEOCS 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, memberid):
659     """
660     Creates a UNIX user account with options tailored to CSC members.
661
662     Note: The 'other' section of the GECOS field is filled with the CSC
663           memberid. This section cannot be changed by the user via chfn(1).
664
665     Parameters:
666         username - the desired UNIX username
667         password - the desired UNIX password
668         name     - the member's real name
669         memberid - the CSC member id number
670
671     Exceptions:
672         InvalidArgument - on bad account attributes provided
673
674     Returns: the uid number of the new account
675
676     See: create()
677     """
678
679     # check connection
680     if not connected():
681         raise AccountException("not connected to LDAP and Kerberos")
682
683     # check username format
684     if not username or not re.match(cfg['username_regex'], username):
685         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
686
687     # check password length
688     if not password or len(password) < cfg['min_password_length']:
689         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
690
691     minimum_id = cfg['member_min_id']
692     maximum_id = cfg['member_max_id']
693     home = cfg['member_home'] + '/' + username
694     description = cfg['member_desc']
695     gecos_field = build_gecos(name, other=memberid)
696     shell = cfg['member_shell']
697     group = cfg['member_group']
698
699     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
700
701
702 def create_club(username, name, memberid):
703     """
704     Creates a UNIX user account with options tailored to CSC-hosted clubs.
705     
706     Note: The 'other' section of the GECOS field is filled with the CSC
707           memberid. This section cannot be changed by the user via chfn(1).
708
709     Parameters:
710         username - the desired UNIX username
711         name     - the club name
712         memberid - the CSC member id number
713
714     Exceptions:
715         InvalidArgument - on bad account attributes provided
716
717     Returns: the uid number of the new account
718
719     See: create()
720     """
721
722     # check connection
723     if not connected():
724         raise AccountException("not connected to LDAP and Kerberos")
725
726     # check username format
727     if not username or not re.match(cfg['username_regex'], username):
728         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
729     
730     password = None
731     minimum_id = cfg['club_min_id']
732     maximum_id = cfg['club_max_id']
733     home = cfg['club_home'] + '/' + username
734     description = cfg['club_desc']
735     gecos_field = build_gecos(name, other=memberid)
736     shell = cfg['club_shell']
737     group = cfg['club_group']
738
739     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
740
741
742 def create_adm(username, name):
743     """
744     Creates a UNIX user account with options tailored to long-lived
745     administrative accounts (e.g. vp, www, sysadmin, etc). 
746
747     Parameters:
748         username - the desired UNIX username
749         name     - a descriptive name or purpose
750
751     Exceptions:
752         InvalidArgument - on bad account attributes provided
753
754     Returns: the uid number of the new account
755
756     See: create()
757     """
758
759     # check connection
760     if not connected():
761         raise AccountException("not connected to LDAP and Kerberos")
762
763     # check username format
764     if not username or not re.match(cfg['username_regex'], username):
765         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
766
767     password = None
768     minimum_id = cfg['admin_min_id']
769     maximum_id = cfg['admin_max_id']
770     home = cfg['admin_home'] + '/' + username
771     description = cfg['admin_desc']
772     gecos_field = build_gecos(name)
773     shell = cfg['admin_shell']
774     group = cfg['admin_group']
775
776     return create(username, name, minimum_id, maximum_id, home, password, description, gecos_field, shell, group)
777
778
779
780 ### Miscellaneous Helpers ###
781
782 def check_name_usage(name):
783     """
784     Helper function: Ensures a user or group name does not exist in either
785     Kerberos, LDAP, or through calls to libc and NSS. This is used prior to
786     creating an accout or group to determine if the name is free.
787
788     Parameters:
789         name - the user or group name to check for
790
791     Exceptions:
792         NameConflict - if the name was found anywhere
793     """
794
795     # see if user exists in LDAP
796     if ldap_connection.account_lookup(name):
797         raise NameConflict(name, "account", "LDAP")
798
799     # see if group exists in LDAP
800     if ldap_connection.group_lookup(name):
801         raise NameConflict(name, "group", "LDAP")
802
803     # see if user exists in Kerberos
804     principal = name + '@' + cfg['realm']
805     if krb_connection.get_principal(principal):
806         raise NameConflict(name, "account", "KRB")
807
808     # see if user exists by getpwnam(3)
809     try:
810         pwd.getpwnam(name)
811         raise NameConflict(name, "account", "NSS")
812     except KeyError:
813         pass
814
815     # see if group exists by getgrnam(3)
816     try:
817         grp.getgrnam(name)
818         raise NameConflict(name, "group", "NSS")
819     except KeyError:
820         pass
821
822
823 def check_account_status(username, require_ldap=True, require_krb=False):
824     """Helper function to verify that an account exists."""
825
826     if not connected():
827         raise AccountException("Not connected to LDAP and Kerberos")
828     if require_ldap and not ldap_connection.account_lookup(username):
829         raise NoSuchAccount(username, "LDAP")
830     if require_krb and not krb_connection.get_principal(username):
831         raise NoSuchAccount(username, "KRB")
832
833
834 def check_group_status(groupname):
835     """Helper function to verify that a group exists."""
836     
837     if not connected(): 
838         raise AccountException("Not connected to LDAP and Kerberos")
839     if not ldap_connection.group_lookup(groupname):
840         raise NoSuchGroup(groupname, "LDAP")
841
842
843 def parse_gecos(gecos_data):
844     """
845     Build a dictionary out of a chfn(1) style GECOS string.
846
847     Parameters:
848         gecos_data - a gecos string formatted by chfn(1)
849
850     Returns: a dictinoary of components
851     
852     Example: parse_gecos('Michael Spang,,,') -> {
853                  'fullname': 'Michael Spang',
854                  'roomnumber': '',
855                  'workphone': '',
856                  'homephone': '',
857                  'other': None
858              }
859     """
860     
861     # silently remove erroneous colons
862     while ':' in gecos_data:
863         index = gecos_data.find(':')
864         gecos_data = gecos_data[:index] + gecos_data[index+1:]
865
866     gecos_vals = gecos_data.split(',', 4)
867     gecos_vals.extend([ None ] * (5-len(gecos_vals)))
868     gecos_keys = ['fullname', 'roomnumber', 'workphone',
869                   'homephone', 'other' ]
870     return dict((gecos_keys[i], gecos_vals[i]) for i in xrange(5))
871
872
873 def build_gecos(fullname=None, roomnumber=None, workphone=None, homephone=None, other=None):
874     """
875     Build a chfn(1)-style GECOS field from its components.
876
877     See: chfn(1)
878     
879     Parameters:
880         fullname   - GECOS full name
881         roomnumber - GECOS room number
882         workphone  - GECOS work phone
883         homephone  - GECOS home phone
884         other      - GECOS other
885
886     Returns: string appropriate for a GECOS field value
887     """
888
889     # check first four params for illegal chars
890     args = (fullname, roomnumber, workphone, homephone)
891     names = ('fullname', 'roomnumber', 'workphone', 'homephone')
892     for index in xrange(4):
893         for badchar in (',', ':', '='):
894             if args[index] and badchar in str(args[index]):
895                 raise InvalidArgument(names[index], args[index], "invalid characters")
896
897     # check other for illegal chars
898     if other and ':' in str(other):
899         raise InvalidArgument('other', other, "invalid characters")
900     
901     # join the fields
902     if fullname is not None:
903         gecos_data = str(fullname)
904     fields = [ fullname, roomnumber, workphone, homephone, other ]
905     for idx in xrange(len(fields), 0, -1):
906         if not fields[idx-1]:
907             fields.pop()
908         else:
909             break
910     while None in fields:
911         fields[fields.index(None)] = ''
912     return ','.join(map(str, fields))
913
914
915 def check_id_nss(ugid):
916     """Helper to ensure there is no account or group with an ID."""
917
918     try:
919         pwd.getpwuid(ugid)
920         return False
921     except KeyError:
922         pass
923
924     try:
925         grp.getgrgid(ugid)
926         return False
927     except KeyError:
928         pass
929
930     return True
931
932
933 def first_available_id(minimum, maximum):
934     """
935     Determines the first available id within a range.
936
937     To be "available", there must be neither a user
938     with the id nor a group with the id.
939
940     Parameters:
941         minimum - smallest id that may be returned
942         maximum - largest id that may be returned
943
944     Returns: the id, or None if there are none available
945
946     Example: first_available_id(20000, 40000) -> 20018
947     """
948
949     # get lists of used uids and gids in LDAP
950     uids = ldap_connection.used_uids(minimum, maximum)
951     gids = ldap_connection.used_gids(minimum, maximum)
952
953     # iterate through the lists and return the first available
954     for ugid in xrange(minimum, maximum+1):
955         if ugid not in uids and ugid not in gids and check_id_nss(ugid):
956             return ugid
957
958     # no id found within the range
959     return None
960
961
962
963 ### Tests ###
964
965 if __name__ == '__main__':
966
967     import random
968     from csc.common.test import *
969
970     def test_exists(name):
971         return ldap_connection.user_lookup(name) is not None, \
972             ldap_connection.group_lookup(name) is not None, \
973             krb_connection.get_principal(name) is not None
974
975     # t=test u=user m=member a=adminv c=club
976     # g=group r=real e=expected n=new
977     tuname = 'testuser'
978     turname = 'Test User'
979     tunrname = 'User Test'
980     tudesc = 'May be deleted'
981     tuhome = '/home/testuser'
982     tunhome = '/users/testuser'
983     tushell = '/bin/false'
984     tunshell = '/bin/true'
985     tugecos = 'Test User,,,'
986     tungecos = 'User Test,,,'
987     tmname = 'testmember'
988     tmrname = 'Test Member'
989     tmmid = 31415
990     tcname = 'testclub'
991     tcrname = 'Test Club'
992     tcmid = 98696
993     taname = 'testadm'
994     tarname = 'Test Adm' 
995     tgname = 'testgroup'
996     tgdesc = 'Test Group'
997     minid = 99999000
998     maxid = 100000000
999     tpw = str(random.randint(10**30, 10**31-1))
1000     tgecos = 'a,b,c,d,e'
1001     tgecos_args = 'a','b','c','d','e'
1002
1003     test(connect)
1004     connect()
1005     success()
1006
1007     try:
1008         delete(tuname); delete(tmname)
1009         delete(tcname); delete(taname)
1010         delete_group(tgname)
1011     except (NoSuchAccount, NoSuchGroup):
1012         pass
1013
1014     test(create)
1015     create(tuname, turname, minid, maxid, tuhome, tpw, tudesc, tugecos, tushell)
1016     exists = test_exists(tuname)
1017     expected = (True, True, True)
1018     assert_equal(expected, exists)
1019     success()
1020
1021     test(create_member)
1022     create_member(tmname, tpw, tmrname, tmmid)
1023     exists = test_exists(tmname)
1024     expected = (True, False, True)
1025     assert_equal(expected, exists)
1026     success()
1027
1028     test(create_club)
1029     create_club(tcname, tmrname, tmmid)
1030     exists = test_exists(tcname)
1031     expected = (True, False, False)
1032     assert_equal(expected, exists)
1033     success()
1034
1035     test(create_adm)
1036     create_adm(taname, tarname)
1037     exists = test_exists(taname)
1038     expected = (True, False, False)
1039     assert_equal(expected, exists)
1040     success()
1041
1042     test(create_group)
1043     create_group(tgname, minid, maxid, tgdesc)
1044     exists = test_exists(tgname)
1045     expected = (False, True, False)
1046     assert_equal(expected, exists)
1047     success()
1048
1049     test(status)
1050     assert_equal((True, True), status(tmname))
1051     assert_equal((True, False), status(tcname))
1052     success()
1053
1054     test(reset_password)
1055     reset_password(tuname, str(int(tpw)/2))
1056     reset_password(tmname, str(int(tpw)/3))
1057     negative(reset_password, (tcname,str(int(tpw)/4)), NoSuchAccount, "club should not have password")
1058     negative(reset_password, (taname,str(int(tpw)/5)), NoSuchAccount, "club should not have password")
1059     success()
1060
1061     test(get_uid)
1062     tuuid = get_uid(tuname)
1063     assert_equal(True, int(tuuid) >= 0)
1064     success()
1065
1066     test(get_gid)
1067     tugid = get_gid(tuname)
1068     assert_equal(True, int(tugid) >= 0)
1069     success()
1070
1071     test(get_gecos)
1072     ugecos = get_gecos(tuname)
1073     assert_equal(tugecos, ugecos)
1074     success()
1075
1076     test(update_gecos)
1077     update_gecos(tuname, tungecos)
1078     ugecos = get_gecos(tuname)
1079     assert_equal(tungecos, ugecos)
1080     success()
1081
1082     test(get_shell)
1083     ushell = get_shell(tuname)
1084     assert_equal(tushell, ushell)
1085     success()
1086
1087     test(update_shell)
1088     update_shell(tuname, tunshell, False)
1089     ushell = get_shell(tuname)
1090     assert_equal(ushell, tunshell)
1091     success()
1092
1093     test(get_name)
1094     urname = get_name(tuname)
1095     assert_equal(turname, urname)
1096     success()
1097
1098     test(update_name)
1099     update_name(tuname, tunrname)
1100     urname = get_name(tuname)
1101     assert_equal(urname, tunrname)
1102     success()
1103
1104     test(get_home)
1105     uhome = get_home(tuname)
1106     assert_equal(tuhome, uhome)
1107     success()
1108
1109     test(update_home)
1110     update_home(tuname, tunhome)
1111     urhome = get_home(tuname)
1112     assert_equal(urhome, tunhome)
1113     success()
1114
1115     test(get_members)
1116     members = get_members(tgname)
1117     expected = []
1118     assert_equal(expected, members)
1119     success()
1120
1121     test(check_membership)
1122     member = check_membership(tuname, tgname)
1123     assert_equal(False, member)
1124     member = check_membership(tuname, tuname)
1125     assert_equal(True, member)
1126     success()
1127
1128     test(add_member)
1129     add_member(tuname, tgname)
1130     assert_equal(True, check_membership(tuname, tgname))
1131     assert_equal([tuname], get_members(tgname))
1132     success()
1133
1134     test(remove_member)
1135     assert_equal(True, remove_member(tuname, tgname))
1136     assert_equal(False, check_membership(tuname, tgname))
1137     assert_equal(False, remove_member(tuname, tgname))
1138     success()
1139
1140     test(build_gecos)
1141     assert_equal(tgecos, build_gecos(*tgecos_args))
1142     success()
1143
1144     test(parse_gecos)
1145     gecos_dict = parse_gecos(tgecos)
1146     assert_equal(tgecos, build_gecos(**gecos_dict))
1147     success()
1148
1149     test(delete)
1150     delete(tuname)
1151     exists = test_exists(tuname)
1152     expected = (False, False, False)
1153     assert_equal(expected, exists)
1154     delete(tmname)
1155     exists = test_exists(tmname)
1156     assert_equal(expected, exists)
1157     delete(tcname)
1158     exists = test_exists(tcname)
1159     assert_equal(expected, exists)
1160     delete(taname)
1161     exists = test_exists(taname)
1162     assert_equal(expected, exists)
1163     success()
1164
1165     test(delete_group)
1166     delete_group(tgname)
1167     exists = test_exists(tgname)
1168     expected = (False, False, False)
1169     assert_equal(expected, exists)
1170     success()
1171
1172     test(disconnect)
1173     disconnect()
1174     success()