changeset 118:cd5e4c45265b

First draft of LDAP export / population
author Sylvain Beucler <beuc@beuc.net>
date Sun, 02 Aug 2009 17:03:29 +0200
parents 16016b4fe187
children a34e97e27050
files TODO sbin/sv sbin/sv-populate-ldap src/savane/backend/__init__.py src/savane/backend/auth_ldif_export.py src/savane/svmain/models.py
diffstat 6 files changed, 264 insertions(+), 1 deletions(-) [+]
line wrap: on
line diff
--- a/TODO
+++ b/TODO
@@ -1,6 +1,14 @@
+- models
+
+  - add DB indexes (db_model=True - but is that possible for
+    auth_user.username? :/)
+
 - now we need the screens for users to modify them
+
 - work on the web design
+
 - export to LDAP and reimplement .ssh replication and VCS creation
+
 - implement mod_rewrite URL migration list
 
 
new file mode 100755
--- /dev/null
+++ b/sbin/sv
@@ -0,0 +1,31 @@
+#!/usr/bin/python
+# Wrapper to locate and execute Savane backend commands
+# Copyright (C) 2009  Sylvain Beucler
+#
+# This file is part of Savane.
+# 
+# Savane is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# Savane is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# (Inspiration from django.core.management.execute_manager)
+
+# Prepare environment to locate settings.py and models.py
+import os, sys
+if not os.environ.has_key('DJANGO_SETTINGS_MODULE'):
+    os.environ['DJANGO_SETTINGS_MODULE'] = 'settings';
+
+if os.path.exists('../src'): # debug
+    sys.path.insert(0, '../src')
+
+import savane.backend
+savane.backend.wrapper()
new file mode 100755
--- /dev/null
+++ b/sbin/sv-populate-ldap
@@ -0,0 +1,7 @@
+#!/bin/bash
+/etc/init.d/slapd stop
+rm -f /var/lib/ldap/*
+# '-q' disables integrity checks and is nearly 30x faster
+./sv auth_ldif_export | slapadd -q
+chown -R openldap: /var/lib/ldap/*
+/etc/init.d/slapd start
new file mode 100644
--- /dev/null
+++ b/src/savane/backend/__init__.py
@@ -0,0 +1,29 @@
+# Run a subcommand specified on the command line
+# Copyright (C) 2009  Sylvain Beucler
+#
+# This file is part of Savane.
+# 
+# Savane is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# Savane is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os, sys
+import imp
+
+def wrapper():
+    """
+    Load python savane.backend submodule specified on the first
+    argument of the command line
+    """
+    command_name = sys.argv[1]
+    (f, path, descr) = imp.find_module(command_name, __path__)
+    imp.load_module(command_name, f, path, descr)
new file mode 100644
--- /dev/null
+++ b/src/savane/backend/auth_ldif_export.py
@@ -0,0 +1,181 @@
+# Replicate users and groups to an OpenLDAP directory
+# Copyright (C) 2009  Sylvain Beucler
+#
+# This file is part of Savane.
+# 
+# Savane is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# Savane is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# Recommended indexes:
+# index		uid,sn,uidNumber,gidNumber,memberUid,shadowExpire eq
+
+# TODO: most settings are hard-coded and need to be made configurable
+# - base: dc=savannah,dc=gnu,dc=org
+# - users ou: "users"
+# - groups ou: "groups"
+# - create 'organization' and 'organizationalUnit' objects?
+# - min uid: 1000
+# - min gid: 1000
+# - default group: cn=svusers / gid=1000
+
+import sys
+import codecs
+import base64, binascii
+import savane.svmain.models as svmain_models
+
+# Convert stdout to UTF-8 - if the stdout is redirected to a file
+# sys.stdout.encoding is autodetected as 'None' and you get the
+# obnoxious UnicodeEncodeError python error.
+sys.stdout = codecs.getwriter('UTF-8')(sys.stdout)
+
+print """dn: dc=savannah,dc=gnu,dc=org
+objectClass: top
+objectClass: dcObject
+objectClass: organization
+o: GNU
+dc: savannah
+structuralObjectClass: organization
+
+dn: ou=users,dc=savannah,dc=gnu,dc=org
+ou: users
+objectClass: organizationalUnit
+objectClass: top
+structuralObjectClass: organizationalUnit
+
+dn: ou=groups,dc=savannah,dc=gnu,dc=org
+ou: groups
+objectClass: organizationalUnit
+objectClass: top
+structuralObjectClass: organizationalUnit
+"""
+
+# Add user admin/admin
+# (REMOVE WHEN TESTING IS DONE!)
+print """
+dn: cn=admin,dc=savannah,dc=gnu,dc=org
+objectClass: simpleSecurityObject
+objectClass: organizationalRole
+cn: admin
+description: LDAP administrator
+userPassword:: e2NyeXB0fWt0YVZ1TFNDaEg0Wi4=
+structuralObjectClass: organizationalRole
+"""
+
+#count = svmain_models.ExtendedUser.objects.count()
+#print str(count) + " users in the database."
+
+uidNumber=1000
+for user in svmain_models.ExtendedUser.objects.only('username', 'first_name', 'last_name', 'email',
+                                                    'password', 'uidNumber', 'gidNumber'):
+    uidNumber=uidNumber+1
+    ##if uidNumber == 0: # either non-assigned, or mistakenly assigned to root
+    #if uidNumber < 1000: # either non-assigned, or mistakenly assigned to privileged user
+    #    uidn = UidNumber()
+    #    uidn.save()
+    #    user.uidNumber = uidn
+    #    user.save()
+
+    cleanup = [user.first_name, user.last_name, user.email]
+    for i in range(0, len(cleanup)):
+        cleanup[i] = cleanup[i].replace('\n', ' ')
+        cleanup[i] = cleanup[i].replace('\r', ' ')
+        cleanup[i] = cleanup[i].strip()
+    (first_name, last_name, email) = cleanup
+
+    ldap_password = '{CRYPT}!'  # default = unusable password
+    if user.password.startswith('sha1$'):
+        # Django-specific algorithm: it sums 5-char-salt+pass instead
+        # of SSHA's pass+4-bytes-salt, so we can't store it in LDAP -
+        # /me curses django devs
+        pass
+    elif user.password.startswith('md5$$'):
+        # MD5 without salt
+        algo, empty, hash_hex = user.password.split('$')
+        if (len(hash_hex) == 32): # filter out empty or disabled passwords
+            ldap_password = "{MD5}" + base64.b64encode(binascii.a2b_hex(hash_hex))
+    elif user.password.startswith('md5$'):
+        # md5$salt$ vs. {SMD5} is similar to sha1$salt$ vs. {SSHA} -
+        # cf. above
+        pass
+    elif user.password.startswith('crypt$'):
+        # glibc crypt has improved algorithms, but where salt contains
+        # three '$'s, which Django doesn't support (since '$' is
+        # already the salt field separator). So this is only weak,
+        # passwd-style (not shadow-style) crypt.
+        algo, salt_hex, hash_hex = user.password.split('$')
+        # salt_hex is 2-chars long and already prepended to hash_hex
+        ldap_password = "{CRYPT}" + base64.b64encode(binascii.a2b_hex(hash_hex))
+    elif '$' not in user.password:
+        # MD5 without salt, alternate Django syntax
+        hash_hex = user.password
+        if (len(hash_hex) == 32): # filter out empty or disabled passwords
+            ldap_password = "{MD5}" + base64.b64encode(binascii.a2b_hex(hash_hex))
+
+    # Object classes:
+    # - posixAccount: base class for libnss-ldap/pam-ldap support
+    # - shadowAccount: for shadowExpire
+    # - inetOrgPerson: for mail and givenName, and structural class
+    print u"""
+dn: uidNumber=%(uidNumber)d,ou=users,dc=savannah,dc=gnu,dc=org
+uid: %(username)s
+cn:: %(full_name)s
+sn:: %(last_name)s
+mail: %(email)s
+userPassword: %(ldap_password)s
+uidNumber: %(uidNumber)d
+gidNumber: %(gidNumber)d
+homeDirectory: %(homedir)s
+objectClass: shadowAccount
+objectClass: posixAccount
+objectClass: inetOrgPerson
+objectClass: top
+structuralObjectClass: inetOrgPerson""" % {
+        'username' : user.username,
+        'full_name' : base64.b64encode((first_name + u' ' + last_name).encode('UTF-8')),
+        'last_name' : base64.b64encode((last_name or u'-').encode('UTF-8')),
+        'email' : email,
+        'ldap_password' : ldap_password,
+        'uidNumber' : uidNumber,
+        'gidNumber' : 1000,
+        'homedir' : u'/home/' + user.username[:1] + u'/' + user.username[:2] + u'/' + user.username,
+        }
+    # non-mandatory fields - slapadd doesn't accept empty fields apparently
+    if len(first_name) > 0:
+        print "givenName::" + base64.b64encode(first_name.encode('UTF-8'))
+    # disallow login for users that are not part of any group
+    #if user.extendedgroup_set.count() == 0:
+    #    print "shadowExpire: 10" # timestamp - avoid 0 as it may be
+    #                             # interpreted at 'no expiration'
+
+print u"""
+dn: cn=svusers,ou=groups,dc=savannah,dc=gnu,dc=org
+cn: svusers
+gidNumber: 1000
+objectClass: posixGroup
+objectClass: top
+structuralObjectClass: posixGroup"""
+i=1000
+for group in svmain_models.ExtendedGroup.objects.only('name'):
+    i=i+1
+    print u"""
+dn: cn=%(name)s,ou=groups,dc=savannah,dc=gnu,dc=org
+cn: %(name)s
+gidNumber: %(gidNumber)s
+objectClass: posixGroup
+objectClass: top
+structuralObjectClass: posixGroup""" % {
+     'name' : group.name,
+     'gidNumber' : i,
+     }
+    for user in group.extendeduser_set.only('username'):
+        print "memberUid: " + user.username
--- a/src/savane/svmain/models.py
+++ b/src/savane/svmain/models.py
@@ -58,10 +58,11 @@
 from django.db import models
 from django.contrib.auth import models as auth_models
 
+
 class ExtendedUser(auth_models.User):
     """Django base User class + extra Savane fields"""
 
-    # Migrated to 'firstname' in auth.User
+    # Migrated to 'first_name' and 'last_name' in auth.User
     #realname = models.CharField(max_length=96)
 
     # Old Savane can be Active/Deleted/Pending/Suspended/SQuaD
@@ -74,6 +75,10 @@
         )
     status = models.CharField(max_length=3, choices=status_CHOICES)
 
+    # Unix mapping, used when populating a LDAP directory
+    uidNumber = models.IntegerField(default=0)
+    gidNumber = models.IntegerField(default=0)
+
     # Used by trackers only but it could be used more widely
     spamscore = models.IntegerField(null=True, blank=True)
     # Previously used for e-mail changes and password recovery, Django
@@ -355,6 +360,8 @@
         ('I', 'Incomplete (failure during registration)'),
         )
     status = models.CharField(max_length=1, choices=status_CHOICES, default='A')
+    gidNumber = models.IntegerField(default=0)
+
     short_description = models.CharField(max_length=255, blank=True)
     long_description = models.TextField(blank=True)
     license = models.ForeignKey(License, blank=True, null=True)