Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ldap storage #500

Closed
wants to merge 17 commits into from
5 changes: 2 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,9 @@ release:
python setup.py sdist bdist_wheel upload

install-dev:
set -xe && if [ "$$REMOTESTORAGE_SERVER" != "skip" ]; then \
pip install -e .[ldap]
if [ "$$REMOTESTORAGE_SERVER" != "skip" ]; then \
pip install -e .[remotestorage]; \
else \
pip install -e .; \
fi
set -xe && if [ "$$REQUIREMENTS" = "devel" ]; then \
pip install -U --force-reinstall \
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def run(self):
extras_require={
'remotestorage': ['requests-oauthlib'],
'google': ['requests-oauthlib'],
'ldap': ['ldap3', 'vobject'],
},

# Build dependencies
Expand Down
32 changes: 32 additions & 0 deletions tests/storage/test_ldap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-

import ldap3
import pytest

from vdirsyncer.storage.ldap import LDAPStorage

from . import StorageTests


class TestLDAPStorage(StorageTests):
storage_class = LDAPStorage
supports_collections = False

@pytest.fixture(params=['VCARD'])
def item_type(self, request):
return request.param

@pytest.fixture
def get_storage_args(self):
url = 'ldap://localhost'
server = ldap3.Server('fake')
conn = ldap3.Connection(server, client_strategy=ldap3.MOCK_SYNC)

conn.strategy.add_entry(
'cn=user0,ou=test,o=lab',
{'userPassword': 'test0000', 'sn': 'user0_sn', 'revision': 0}
)

def inner(collection='test'):
return {'url': url, '_conn': conn, 'search_base': 'ou=test,o=lab'}
return inner
3 changes: 2 additions & 1 deletion vdirsyncer/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def __init__(self):
remotestorage_calendars=(
'vdirsyncer.storage.remotestorage.RemoteStorageCalendars'),
google_calendar='vdirsyncer.storage.google.GoogleCalendarStorage',
google_contacts='vdirsyncer.storage.google.GoogleContactsStorage'
google_contacts='vdirsyncer.storage.google.GoogleContactsStorage',
ldap='vdirsyncer.storage.ldap.LDAPStorage'
)

def __getitem__(self, name):
Expand Down
118 changes: 118 additions & 0 deletions vdirsyncer/storage/ldap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
import logging
import ldap3
import vobject

from .base import Item, Storage
from .. import exceptions

ldap_logger = logging.getLogger(__name__)


class LDAPStorage(Storage):
'''
:param url: LDAP URL
:param search_base: search base
:param bind: bind dn
:param password: bind password
:param filter: filter
'''
storage_name = 'ldap'
fileext = '.vcf'
item_mimetype = 'text/vcard'

def __init__(self, url='ldap://localhost', search_base=None, bind=None,
password=None,
filter='(&(objectCategory=person)(objectClass=user)'
'(sn=*)(givenName=*))',
_conn=None, **kwargs):
super(LDAPStorage, self).__init__(**kwargs)
self.search_base = search_base
self.filter = filter
self.conn = _conn
if self.conn is None:
server = ldap3.Server(url, get_info=ldap3.DSA)
if bind:
self.conn = ldap3.Connection(server, user=bind,
password=password)
else:
self.conn = ldap3.Connection(server)
self.conn.bind()
self.conn.start_tls()

ldap_logger.debug('Connected to: {}'.format(self.conn))

if self.search_base is None:
# Fallback to default root entry
self.search_base = server.info.naming_contexts[0]

def list(self):
'''
:returns: list of (href, etag)
'''
ldap_logger.debug('Search on {self.search_base} with filter'
'{self.filter}'.format(self=self))
self.conn.search(self.search_base, self.filter,
attributes=["whenChanged"])
for entry in self.conn.entries:
ldap_logger.debug('Found {}'.format(entry.entry_get_dn()))
href = entry.entry_get_dn()
if getattr(entry, 'whenChanged'):
etag = str(entry.whenChanged)
else:
item = self.get(href)
etag = item.hash

yield href, etag

def get(self, href):
self.conn.search(href, self.filter,
attributes=["whenChanged", "cn", "sn", "givenName",
"displayName", "telephoneNumber",
"mobile", "facsimileTelephoneNumber",
"mail", "title"])

if not self.conn.entries[0]:
raise exceptions.NotFoundError(href)

entry = self.conn.entries[0]
etag = str(entry.whenChanged)
Copy link
Member

@untitaker untitaker Oct 22, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you have no fallback from whenChanged to item.hash like you have in list. I suggest a separate helper function (outside of the storage class) for this.


vcard = vobject.vCard()
vo = vcard.add('fn')
vo.value = str(entry.cn)
vo = vcard.add('n')
vo.value = vobject.vcard.Name(family=str(entry.sn),
given=str(entry.givenName))
if getattr(entry, 'telephoneNumber', None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conversion from entry to item could be its own function (outside of storage class)

vo = vcard.add('tel')
vo.value = str(entry.telephoneNumber)
vo.type_param = 'WORK'
if getattr(entry, 'mobile', None):
vo = vcard.add('tel')
vo.value = str(entry.mobile)
vo.type_param = 'CELL'
if getattr(entry, 'facsimileTelephoneNumber', None):
vo = vcard.add('tel')
vo.value = str(entry.facsimileTelephoneNumber)
vo.type_param = 'FAX'
if getattr(entry, 'mail', None):
vo = vcard.add('email')
vo.value = str(entry.mail)
vo.type_param = 'INTERNET'
if getattr(entry, 'title', None):
vo = vcard.add('title')
vo.value = str(entry.title)

item = Item(vcard.serialize())

return item, etag

def upload(self, item):
vcard = vobject.readOne(item.raw)
self.conn.strategy.add_entry('cn={},ou=test,o=lab'.format(vcard.fn),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upload needs to return the assigned href and etag. It appears that your href is dn, but the etag has to be fetched like in list and get.

Also add_entry returns a boolean that indicates whether the entry has been added, you should check for that.

vcard)

def update(self, href, item, etag):
vcard = vobject.readOne(item.raw)
self.conn.strategy.add_entry(href, vcard)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_entry will do nothing if the item already exists, but if I call update, the item always exists. update is also expected to fail if the passed etag does not match the etag of the item on the server.

I think modify or how it's called is more appropriate than add_entry, but I'm not sure since I couldn't find much docs :(

8 changes: 6 additions & 2 deletions vdirsyncer/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,14 @@ class can take, and ``required`` is the subset of arguments the class
requires.
'''
all, required = set(), set()

def _filter_args(args):
return (a for a in args if not a.startswith('_'))

for spec in get_storage_init_specs(cls, stop_at=stop_at):
all.update(spec.args[1:])
all.update(_filter_args(spec.args[1:]))
last = -len(spec.defaults) if spec.defaults else len(spec.args)
required.update(spec.args[1:last])
required.update(_filter_args(spec.args[1:last]))

return all, required

Expand Down