Skip to content

Commit

Permalink
update
Browse files Browse the repository at this point in the history
- Updated Docker base image to debian 12
- Make sure we stay compatible with Vaultwarden v1.32.0+ (Thanks @p4i1ipp)
- First step in making sourcecode less ldap specific
sirtoobii committed Oct 16, 2024

Verified

This commit was signed with the committer’s verified signature. The key has expired.
sirtoobii Tobias Bossert
1 parent d9d33a0 commit 1a38126
Showing 7 changed files with 88 additions and 45 deletions.
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
FROM debian:11-slim
FROM debian:12-slim
ENV DEBIAN_FRONTEND noninteractive

RUN apt update && apt install -y libldap2-dev libsasl2-dev python3-dev python3-pip
RUN apt update && apt install -y libldap2-dev libsasl2-dev python3-dev python3-pip --no-install-suggests


COPY . /src

RUN cd /src && pip install -r requirements.txt
RUN cd /src && pip install -r requirements.txt --break-system-packages


RUN chmod +x /src/.docker/check_health.sh
7 changes: 7 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
@@ -33,7 +33,14 @@ pip install -r requirements.txt

# Run tests
python3 -m unittest discover -s tests/

# Run main script locally
python3 -m scripts.sync --help
```

### Adding another email source

Adding another email source is as simple as subclassing `EmailSource` and implementing the `get_email_list()` method

Contributions and feedback welcome

61 changes: 35 additions & 26 deletions scripts/sync.py
Original file line number Diff line number Diff line change
@@ -51,13 +51,13 @@ def setup_logging(logfile: str, loglevel: str):
level=logging.getLevelName(loglevel))


def collect_change_set(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
def collect_change_set(vwc: VaultwardenConnector, ls: LocalStore, source_email_addresses: list):
"""
Finds changes made in vaultwarden (and ldap) which are not yet reflected in our local state
Finds changes made in vaultwarden (and email source) which are not yet reflected in our local state
:param vwc: Vaultwarden connector instance
:param ls: LocalStore instance
:param ldap_users: List of email addresses resulting from the LDAP query
:param source_email_addresses: List of source email addresses (invite candidates)
:return: Returns a dict with the following structure:
{
'invite': [list of emails to invite],
@@ -77,28 +77,28 @@ def collect_change_set(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: li

# We want to disable users which are:
# Present in our LocalStore (state=ENABLED) and NOT present in LDAP
user_emails_to_disable = set(managed_user_emails_enabled).difference(set(ldap_users))
user_emails_to_disable = set(managed_user_emails_enabled).difference(set(source_email_addresses))

# We want to invite users which are:
# Present in LDAP but not preset in Vaultwarden AND NOT present in LocalStore
known_user_emails = set(vaultwarden_user_emails_all) \
.union(set(managed_users_emails_all_inv)) \
.union(set(managed_users_emails_all_vw))
users_to_invite = set(ldap_users).difference(known_user_emails)
users_to_invite = set(source_email_addresses).difference(known_user_emails)

return {
'invite': users_to_invite,
'disable': [managed_user_emails_to_user_id[user_email] for user_email in user_emails_to_disable]
}


def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
def sync_state(vwc: VaultwardenConnector, ls: LocalStore, source_email_addresses: list):
"""
Finds changes made in vaultwarden (and ldap) which are not yet reflected in our local state
Finds changes made in vaultwarden (and email source) which are not yet reflected in our local state
:param vwc: Vaultwarden connector instance
:param ls: LocalStore instance
:param ldap_users: List of email addresses resulting from the LDAP query
:param source_email_addresses: List of source email addresses (invite candidates)
:return: Returns a dict with the following structure:
{
'vanished': [managed_email_to_user_id[user_email] for user_email in vanished_user_emails],
@@ -118,7 +118,7 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):

# find users which aren't present in LDAP and Vaultwarden (but our local state)
vanished_user_emails = set(managed_user_emails_all).difference(
set(vaultwarden_user_emails_all).union(ldap_users))
set(vaultwarden_user_emails_all).union(source_email_addresses))

# find deleted in vaultwarden
deleted_user_ids = set(ma_all.keys()).difference(vw_all.keys())
@@ -135,19 +135,20 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
if ma_all[user_id]['vw_email'] != vw_all[user_id]:
email_changed[user_id] = {'from': ma_all[user_id]['vw_email'], 'to': vw_all[user_id]}
# temporarily add old email to ldap users to prevent it from appearing in vanished
ldap_users.append(ma_all[user_id]['vw_email'])
source_email_addresses.append(ma_all[user_id]['vw_email'])

# find users which aren't present in LDAP and Vaultwarden (but our local state)
vanished_user_emails = set(managed_user_emails_all).difference(
set(vaultwarden_user_emails_all).union(ldap_users))
set(vaultwarden_user_emails_all).union(source_email_addresses))

return {
'vanished': [managed_email_to_user_id[user_email] for user_email in vanished_user_emails],
'deleted': deleted_user_ids,
'disabled': disabled_user_ids,
'enabled': enabled_user_ids,
'email_changed': email_changed,
'all_managed_users': ma_all
'all_managed_users': ma_all,
'vaultwarden_all_users': vw_all
}


@@ -158,9 +159,8 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
setup_logging(log_file, log_level)
ls = LocalStore(os.getenv('SQLITE_DB'))
vwc = VaultwardenConnector()
ldc = LdapConnector()
ldc = LdapConnector(source_name="LDAP")
safe_guard = int(os.getenv('MAX_USERS_AT_ONCE', args.override_safe_guard))
t = os.getenv('DRYRUN')
is_dry_run = os.getenv('DRYRUN', 0) == '1' or args.dryrun
ldap_emails = ldc.get_email_list()

@@ -169,12 +169,17 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
logging.info('LDAP server: {}'.format(os.getenv('LDAP_SERVER')))
logging.info('Vaultwarden url: {}'.format(os.getenv('VAULTWARDEN_URL')))

log_prefix = ""
if is_dry_run:
log_prefix = "[DRYRUN] "

while True:
try:
# first sync state
state_update = sync_state(vwc, ls, ldap_emails)

# State summary
logging.debug('Found {} user(s) in Vaultwarden'.format(len(state_update['vaultwarden_all_users'])))
logging.debug('Found {} vanished user(s)'.format(len(state_update['vanished'])))
logging.debug('Found {} deleted user(s)'.format(len(state_update['deleted'])))
logging.debug('Found {} disabled user(s)'.format(len(state_update['disabled'])))
@@ -186,42 +191,45 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
if not is_dry_run:
ls.delete_user(user_id)
logging.info(
'Cleanup vanished user: {}'.format(
state_update['all_managed_users'][user_id]['invite_email']))
'{}Cleanup vanished user: {}'.format(log_prefix,
state_update['all_managed_users'][user_id][
'invite_email']))

for user_id in state_update['deleted']:
if not is_dry_run:
ls.set_user_state(user_id, 'DELETED')
logging.info(
'Set state to DELETED for: {}'.format(
state_update['all_managed_users'][user_id]['invite_email']))
'{}Set state to DELETED for: {}'.format(log_prefix,
state_update['all_managed_users'][user_id]['invite_email']))

for user_id in state_update['disabled']:
if not is_dry_run:
ls.set_user_state(user_id, 'DISABLED')
logging.info(
'Set state to DISABLED for: {}'.format(
state_update['all_managed_users'][user_id]['invite_email']))
'{}Set state to DISABLED for: {}'.format(log_prefix,
state_update['all_managed_users'][user_id][
'invite_email']))

for user_id, change_data in state_update['email_changed'].items():
if not is_dry_run:
ls.update_vw_email(user_id, change_data['to'])
logging.info('Changed email from {} to {}'.format(change_data['from'], change_data['to']))
logging.info('{}Changed email from {} to {}'.format(log_prefix, change_data['from'], change_data['to']))

for user_id in state_update['enabled']:
if os.getenv('UNTIE_RE-ENABLED_USERS') == '1':
if not is_dry_run:
ls.delete_user(user_id)
logging.info(
'User {} forcefully enabled by Admin. Permanently untie this user from automatic management'.format(
'{}User {} forcefully enabled by Admin. Permanently untie this user from automatic management'.format(
log_prefix,
state_update['all_managed_users'][user_id]['invite_email']))

# then search for users to invite or delete
invite_or_delete = collect_change_set(vwc, ls, ldap_emails)

# Change summary
logging.debug('Found {} user(s) to invite'.format(len(invite_or_delete['invite'])))
logging.debug('Found {} user(s) to disable'.format(len(invite_or_delete['disable'])))
logging.debug('{}Found {} user(s) to invite'.format(log_prefix, len(invite_or_delete['invite'])))
logging.debug('{}Found {} user(s) to disable'.format(log_prefix, len(invite_or_delete['disable'])))

if len(invite_or_delete['disable']) > safe_guard or len(invite_or_delete['invite']) > safe_guard:
logging.warning(
@@ -232,14 +240,15 @@ def sync_state(vwc: VaultwardenConnector, ls: LocalStore, ldap_users: list):
if not is_dry_run:
user_id = vwc.invite_user(user_email)
ls.register_user(user_email, user_id)
logging.info('Invite user {}'.format(user_email))
logging.info('{}Invite user {}'.format(log_prefix, user_email))

for user_id in invite_or_delete['disable']:
if not is_dry_run:
vwc.disable_user(user_id)
ls.set_user_state(user_id, 'DISABLED')
logging.info(
'Disable user {}'.format(state_update['all_managed_users'][user_id]['invite_email']))
'{}Disable user {}'.format(log_prefix,
state_update['all_managed_users'][user_id]['invite_email']))
if args.runonce:
exit(0)
# Touch heartbeat file
16 changes: 8 additions & 8 deletions tests/test_Changes.py
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ def test_vw_admin_enable_disable(self):

self.vwc.enable_user(self.user_id1)
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=[self.user_email1, self.user_email2])
source_email_addresses=[self.user_email1, self.user_email2])

self.assertEqual(set(), change_set['invite'])
self.assertEqual([], change_set['disable'])
@@ -40,43 +40,43 @@ def test_user_changed_email(self):
self.ls.update_vw_email(self.user_id1, '[email protected]')
_, _, t = self.ls.get_all_users()
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=[self.user_email1, self.user_email2])
source_email_addresses=[self.user_email1, self.user_email2])
self.assertEqual(set(), change_set['invite'])
self.assertEqual([], change_set['disable'])

# and then this user registration address disappears from ldap
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=[self.user_email2])
source_email_addresses=[self.user_email2])

self.assertEqual(set(), change_set['invite'], 'Users to invite')
self.assertEqual([self.user_id1], change_set['disable'], 'Users to disable')

def test_unknown_only_to_us(self):
self.vwc.invite_user('[email protected]')
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=[self.user_email1, self.user_email2, '[email protected]'])
source_email_addresses=[self.user_email1, self.user_email2, '[email protected]'])
self.assertEqual(set(), change_set['invite'])
self.assertEqual([], change_set['disable'])

def test_user_already_known(self):
self.ls.register_user('[email protected]', 'uuuu-iiii-dddd')
self.ls.set_user_state('uuuu-iiii-dddd', 'DISABLED')
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=['[email protected]', self.user_email1, self.user_email2])
source_email_addresses=['[email protected]', self.user_email1, self.user_email2])
self.assertEqual(change_set['invite'], set())

def test_user_appeared_on_ldap(self):
change_set = collect_change_set(self.vwc, self.ls,
ldap_users=[self.user_email1, self.user_email2, '[email protected]'])
source_email_addresses=[self.user_email1, self.user_email2, '[email protected]'])
self.assertEqual(change_set['invite'], {'[email protected]'})

def test_user_disappeared_from_ldap(self):
change_set = collect_change_set(self.vwc, self.ls, ldap_users=[self.user_email1])
change_set = collect_change_set(self.vwc, self.ls, source_email_addresses=[self.user_email1])
self.assertEqual([self.user_id2], change_set['disable'])

def test_nothing_to_do(self):
# We are in sync
change_set = collect_change_set(self.vwc, self.ls, ldap_users=[self.user_email1, self.user_email2])
change_set = collect_change_set(self.vwc, self.ls, source_email_addresses=[self.user_email1, self.user_email2])
self.assertEqual(set(), change_set['invite'])
self.assertEqual([], change_set['disable'])

20 changes: 20 additions & 0 deletions vaultwarden_ldap_sync/EmailSource.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod
from typing import List


class EmailSource(ABC):
"""
Generic Email source base class
"""
source_name: str

def __init__(self, source_name: str):
self.source_name = source_name

@abstractmethod
def get_email_list(self) -> List[str]:
"""
Get email addresses to invite
:return: A (possibly) empty list of email addresses
"""
...
10 changes: 7 additions & 3 deletions vaultwarden_ldap_sync/LdapConnector.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import os
from typing import List

from ldap.ldapobject import SimpleLDAPObject

import ldap
import logging
import contextlib

from vaultwarden_ldap_sync.EmailSource import EmailSource

class LdapConnector:

def __init__(self):
class LdapConnector(EmailSource):

def __init__(self, source_name: str):
super().__init__(source_name)
self.ldap_server = os.getenv('LDAP_SERVER')
self.ldap_scheme = os.getenv('LDAP_SCHEME', 'ldaps')
self.ldap_tls = os.getenv('LDAP_TLS', 'true')
@@ -30,7 +34,7 @@ def connect(self) -> SimpleLDAPObject:
finally:
conn.unbind_s()

def get_email_list(self):
def get_email_list(self) -> List[str]:
"""
Performs a ldap search based on the filter setting in LDAP_SEARCH_FILTER
13 changes: 8 additions & 5 deletions vaultwarden_ldap_sync/VaultwardenConnector.py
Original file line number Diff line number Diff line change
@@ -66,11 +66,13 @@ def get_all_users(self) -> tuple:
result = self.make_authenticated_request('{}/admin/users'.format(self.vaultwarden_url),
expected_return_code=200)
for user_item in result.json():
if user_item['UserEnabled']:
enabled[user_item['Id']] = user_item['Email']
# Starting with v1.32.0, Vaultwarden starts using (proper) CamelCase fields
normalized_user_item = {key.lower(): value for key, value in user_item.items()}
if normalized_user_item['userenabled']:
enabled[normalized_user_item['id']] = normalized_user_item['email']
else:
disabled[user_item['Id']] = user_item['Email']
all_users[user_item['Id']] = user_item['Email']
disabled[normalized_user_item['id']] = normalized_user_item['email']
all_users[normalized_user_item['id']] = normalized_user_item['email']
return enabled, disabled, all_users

def disable_user(self, user_id: str):
@@ -100,7 +102,8 @@ def invite_user(self, user_email: str) -> str:
result = self.make_authenticated_request('{}/admin/invite'.format(self.vaultwarden_url), method='POST',
expected_return_code=200,
payload={'email': user_email})
created_user_id = result.json()['Id']
normalized_user_item = {key.lower(): value for key, value in result.json().items()}
created_user_id = normalized_user_item['id']
logging.info('Successfully invited user {} with ID {}'.format(user_email, created_user_id))
return created_user_id

0 comments on commit 1a38126

Please sign in to comment.