diff --git a/kanidm-formula/README.md b/kanidm-formula/README.md new file mode 100644 index 00000000..53ef75e3 --- /dev/null +++ b/kanidm-formula/README.md @@ -0,0 +1,18 @@ +# Salt states for Kanidm + +This formula provides states for the configuration of [Kanidm](https://kanidm.com/). + +## Available states + +`kanidm.client` + +Installs and configures the [client tools](https://kanidm.github.io/kanidm/stable/client_tools.html). + +`kanidm.unixd` + +Installs and configures the [UNIX daemon](https://kanidm.github.io/kanidm/stable/integrations/pam_and_nsswitch.html) +and configures nsswitch/PAM to make use of it. + +`kanidm.server` + +Installs and configures the server (TODO). diff --git a/kanidm-formula/_modules/kanidm_client.py b/kanidm-formula/_modules/kanidm_client.py new file mode 100644 index 00000000..b4f3743b --- /dev/null +++ b/kanidm-formula/_modules/kanidm_client.py @@ -0,0 +1,131 @@ +from asyncio import run +from salt.exceptions import CommandNotFoundError +from salt.utils.path import which +from logging import getLogger + +log = getLogger(__name__) +CMD = 'kanidm' + +HAVE_KANIDM_CLI = None +HAVE_KANIDM_PY = None +client = None + +if which(CMD) is None: + HAVE_KANIDM_CLI = False +else: + HAVE_KANIDM_CLI = True + +def _py_client(): + # TODO: error handling and factor out path + with open('/etc/kanidm/salt') as fh: + uri, token = fh.readlines() + #__salt__['pillar.get']('kanidm:server:config:origin', __salt__['pillar.get']('kanidm:client:config:uri')) + + return KanidmClient(uri=uri.strip(), token=token.strip()) + + #log.error('The kanidm_client module cannot locate a Kanidm server URI. API functionality will not be available.') + #HAVE_KANIDM_PY = False + #return None + +try: + from kanidm import KanidmClient + HAVE_KANIDM_PY = True + client = _py_client() +except ImportError: + HAVE_KANIDM_PY = False + +def __virtual__(): + if not HAVE_KANIDM_CLI and not HAVE_KANIDM_PY: + return (False, f'The kanidm_client module cannot be loaded: neither the "{CMD}" binary nor pykanidm are available.') + + return True + +def _guard_cli(): + if not HAVE_KANIDM_CLI: + raise CommandNotFoundError(f'Execution not possible: "{CMD}" is not available.') + +def _guard_py(): + if not HAVE_KANIDM_PY: + raise CommandNotFoundError(f'Execution not possible: pykanidm is not available.') + +def _run_kanidm(sub_cmd, expect_out=True, expect_single=True, user=None, env={}): + _guard_cli() + + base_cmd = f'{CMD} -o json' + + if user is not None: + base_cmd = f'{base_cmd} -D {user}' + + cmd = f'{base_cmd} {sub_cmd}' + log.debug(f'Executing: {cmd}') + + return __utils__['kanidm_parse.parse_cli_result'](__salt__['cmd.run_all'](cmd, env=env), expect_out, expect_single) + +def local_login(user, password): + """ + This is used internally for bootstrapping through local_* functions. Do not use it on the command line! + """ + return _run_kanidm('login', expect_out=False, user=user, env={'KANIDM_PASSWORD': password}) + +def local_service_account_create(account_id, display_name, managed_by, groups=[]): + data = _run_kanidm(f'service-account create {account_id} "{display_name}" {managed_by}', expect_out=False) + if data is True: + for group in groups: + _run_kanidm(f'group add-members {group} {account_id}') + + return True + + return False + +def local_service_account_api_token(account_id, label, expiry=None, rw=False): + cmd = f'service-account api-token generate {account_id}' + if expiry is not None: + cmd = f'{cmd} {expiry}' + if rw is True: + cmd = f'{cmd} -w' + + return _run_kanidm(cmd).get('result') + +def service_account_get(name): + _guard_py() + + try: + return run(client.service_account_get(name)).dict() + except ValueError: + return None + +def service_account_list(): + _guard_py() + + # TODO: https://github.com/kanidm/kanidm/issues/4044 + return None + +def person_account_create(name, displayname): + _guard_py() + + r = run(client.person_account_create(name, displayname)) + log.debug(f'person_account_create(): got {r.dict()}') + return r.status_code == 200 + +def person_account_update(name, newname=None, displayname=None, legalname=None, mail=None): + _guard_py() + + r = run(client.person_account_update(name, newname, displayname, legalname, mail)) + log.debug(f'person_account_update(): got {r.dict()}') + return r.status_code == 200 + +def person_account_get(name): + _guard_py() + + try: + return run(_person_account_get(name)) + except ValueError: + return None + +async def _person_account_list(): + return await client.person_account_list() + +def person_account_list(): + _guard_py() + + return __utils__['kanidm_parse.parse_list_result'](run(_person_account_list())) diff --git a/kanidm-formula/_modules/kanidm_server.py b/kanidm-formula/_modules/kanidm_server.py new file mode 100644 index 00000000..09336370 --- /dev/null +++ b/kanidm-formula/_modules/kanidm_server.py @@ -0,0 +1,26 @@ +import salt.utils +from logging import getLogger + +log = getLogger(__name__) +CMD = 'kanidmd' + +def __virtual__(): + if salt.utils.path.which(CMD) is None: + return (False, f'The kanidm_server module cannot be loaded: "{CMD}" is not available.') + + return True + +def _run_kanidmd(sub_cmd, expect_out=True, expect_single=True): + cmd = f'{CMD} -o json {sub_cmd}' + log.debug(f'Executing: {cmd}') + + return __utils__['kanidm_parse.parse_cli_result'](__salt__['cmd.run_all'](cmd), expect_out, expect_single) + +def recover_account(name): + data = _run_kanidmd(f'recover-account {name}') + + # TODO: kanidm should return with > 0 and print something useful + if data == 'error': + return False + + return data.get('password') diff --git a/kanidm-formula/_states/kanidm_data.py b/kanidm-formula/_states/kanidm_data.py new file mode 100644 index 00000000..b2ee8172 --- /dev/null +++ b/kanidm-formula/_states/kanidm_data.py @@ -0,0 +1,92 @@ +BUILTIN_ACCOUNTS = [] + +def person_account_managed(name, domain, account_data): + ret = {'name': name, 'changes': {}, 'result': None, 'comment': ''} + + have_accounts_list = __salt__['kanidm_client.person_account_list']() + # TODO: not efficient to do this on every call + have_accounts = {} + for p in have_accounts_list: + name = p.pop('name') + if name: + have_accounts[name] = p + + if name in have_accounts: + fields = ['displayname', 'legalname', 'mail'] + update_fields = { + field: None + for field in fields + } + for field in fields: + if field in account_data: + if account_data[field] != have_accounts[name].get(field): + if name not in ret['changes']: + ret['changes'][name] = {} + if field not in ret['changes'][name]: + ret['changes'][name][field] = {} + + ret['changes'][name][field]['new'] = account_data[field] + ret['changes'][name][field]['old'] = have_accounts[name][field] + update_fields[field] = account_data[field] + + elif field in have_accounts[name]: + if name not in ret['changes']: + ret['changes'][name] = {} + ret['changes'][name][field] = { + 'new': None, + 'old': have_accounts[name][field], + } + + if name in ret['changes']: + if __opts__['test']: + ret['comment'] = 'Account would be updated.' + ret['result'] = None + else: + ret['result'] = __salt__['kanidm_client.person_account_update'](name, displayname=update_fields['displayname'], legalname=update_fields['legalname'], mail=update_fields['mail']) + if ret['result'] is True: + ret['comment'] = 'Account updated.' + else: + ret['comment'] = 'Account update failed.' + + else: + ret['comment'] = 'Account is up to date.' + ret['result'] = True + + elif __opts__['test']: + ret['comment'] = 'Account would be created.' + ret['result'] = None + + else: + ret['result'] = __salt__['kanidm_client.person_account_create'](name, displayname=account_data.get('displayname')) + if ret['result'] is True: + ret['comment'] = 'Account created.' + else: + ret['comment'] = 'Account creation failed.' + + return ret + + +#def person_accounts_managed(domain, persons_pillar): +# want_persons = __salt__['pillar.get'](persons_pillar) +# have_persons_list = __salt__['kanidm_client.person_account_list') +# reduced_have_persons = [] +# have_names = [] +# +# have_persons = {} +# for p in have_persons_list: +# name = p.pop('name') +# if name: +# have_persons[name] = p +# +# add_persons = [] +# update_persons = [] +# delete_persons = [] +# +# for name, more in want_persons: +# if name in have_persons: +# update_persons.append(name) +# if name not in have_persons: +# add_persons.append(name) +# for name, more in have_persons: +# if name not in want_persons and name not in BUILTIN_ACCOUNTS: +# delete_persons.append(name) diff --git a/kanidm-formula/_utils/kanidm_parse.py b/kanidm-formula/_utils/kanidm_parse.py new file mode 100644 index 00000000..98bd5535 --- /dev/null +++ b/kanidm-formula/_utils/kanidm_parse.py @@ -0,0 +1,56 @@ +from json.decoder import JSONDecodeError +from salt.exceptions import CommandExecutionError +from salt.utils.json import loads +from logging import getLogger + +log = getLogger(__name__) + +def parse_cli_result(out, expect_out=True, expect_single=True): + log.debug(f'Result: {out}') + + # for commands such as "login" which do not produce json at all + if expect_out is False: + if out['retcode'] == 0: + return True + return False + + if out['retcode'] != 0: + raise CommandExecutionError(f'Execution of "kanidmd" failed: {out}') + + if not out['stdout']: + return True + + res = None + + try: + res = loads(out['stdout']) + except JSONDecodeError: + # sometimes the result is on stdout, sometimes on stderr + try: + res = loads(out['stderr']) + except JSONDecodeError: + # https://github.com/kanidm/kanidm/issues/4040 + data = [] + for line in out['stdout'].splitlines() + out['stderr'].splitlines(): + if not ( line[0] == '{' or ( line[0] == '"' and line[-1] == '"' ) ): + continue + data.append(loads(line)) + + ld = len(data) + if ld == 1 and expect_single: + res = data[0] + elif ld > 1 and not expect_single: + res = data + + if res is not None: + return res + + raise CommandExecutionError(f'Execution of "kanidmd" did not yield expected JSON data: {out}') + +def parse_list_result(data): + res = [] + + for obj in data: + res.append(obj.dict()) + + return res diff --git a/kanidm-formula/kanidm/client.sls b/kanidm-formula/kanidm/client.sls new file mode 100644 index 00000000..424a1214 --- /dev/null +++ b/kanidm-formula/kanidm/client.sls @@ -0,0 +1,14 @@ +kanidm_client_packages: + pkg.installed: + - name: kanidm-clients + +kanidm_client_config_header: + file.prepend: + - name: /etc/kanidm/config + - text: {{ pillar.get('managed_by_salt_formula', '# Managed by the Kanidm formula') | yaml_encode }} + +kanidm_client_config: + file.serialize: + - name: /etc/kanidm/config + - dataset_pillar: kanidm:client:config + - serializer: toml diff --git a/kanidm-formula/kanidm/data.sls b/kanidm-formula/kanidm/data.sls new file mode 100644 index 00000000..2f408eb2 --- /dev/null +++ b/kanidm-formula/kanidm/data.sls @@ -0,0 +1,17 @@ +#!py +# vim: ft=python.salt + +def run(): + states = {} + pillar = __salt__['pillar.get']('kanidm:data', {}) + + for name, data in pillar.get('person_accounts', {}).items(): + states[f'kanidm-data-person-account-{name}'] = { + 'kanidm_data.person_account_managed': [ + {'name': name}, + {'domain': pillar.get('domain', '')}, # TODO + {'account_data': data}, + ], + } + + return states diff --git a/kanidm-formula/kanidm/server/defaults.json b/kanidm-formula/kanidm/server/defaults.json new file mode 100644 index 00000000..a2503d2d --- /dev/null +++ b/kanidm-formula/kanidm/server/defaults.json @@ -0,0 +1,13 @@ +{ + "version": "2", + "bindaddress": "[::]:443", + "ldapbindaddress": "[::]:636", + "db_path": "/var/lib/private/kanidm/kanidm.db", + "tls_chain": "/etc/kanidm/chain.pem", + "tls_key": "/var/lib/private/kanidm/key.pem", + "log_level": "info", + "online_backup": { + "path": "/var/lib/private/kanidm/backups/", + "schedule": "00 22 * * *" + } +} diff --git a/kanidm-formula/kanidm/server/init.sls b/kanidm-formula/kanidm/server/init.sls new file mode 100644 index 00000000..3e4b0004 --- /dev/null +++ b/kanidm-formula/kanidm/server/init.sls @@ -0,0 +1,82 @@ +#!py +# vim: ft=python.salt +from json import load + +ETCDIR = '/etc/kanidm' + +def _load_pillar(): + with open(__salt__['cp.cache_file']('salt://kanidm/server/defaults.json')) as fh: + defaults = load(fh) + + return __salt__['pillar.get']( + 'kanidm:server:config', + default=defaults, + merge=True, merge_nested_lists=False + ) + +def _run_clean(): + states = {} + + states['kanidm-server-packages'] = { + 'pkg.removed': [ + {'names': [ + 'kanidm-server', + ]} + ], + } + + states['kanidm-server-config'] = { + 'file.absent': [ + {'name': f'{ETCDIR}/server.toml'}, + ], + } + + return states + +def _run_manage(): + states = {} + pillar = _load_pillar() + + states['kanidm-server-packages'] = { + 'pkg.installed': [ + {'pkgs': [ + 'kanidm-server', + ]} + ], + } + + states['kanidm-server-config'] = { + 'file.serialize': [ + {'name': f'{ETCDIR}/server.toml'}, + {'dataset': pillar}, + {'serializer': 'toml'}, + {'user': 'root'}, + {'group': 'root'}, + {'mode': '0644'}, + ], + } + + states['kanidm-server-service'] = { + 'service.running': [ + {'name': 'kanidmd'}, + {'enable': True}, + {'reload': True}, + {'watch': [ + {'file': 'kanidm-server-config'}, + ]}, + ], + } + + #states['kanidm-server-salt-admin'] = { + # 'cmd.run': [ + # {'name': 'kanidmd recover-account admin + + return states + +def run(): + states = {} + + if 'config' in __salt__['pillar.get']('kanidm:server', {}): + return _run_manage() + else: + return _run_clean() diff --git a/kanidm-formula/pillar.example b/kanidm-formula/pillar.example new file mode 100644 index 00000000..e71225f9 --- /dev/null +++ b/kanidm-formula/pillar.example @@ -0,0 +1,13 @@ +# vim: ft=yaml + +kanidm: + client: + config: + uri: https://idm.example.com + server: + config: + domain: idm.example.com + origin: https://idm.example.com + data: + persons: + testperson1: {} diff --git a/kanidm-formula/tests/conftest.py b/kanidm-formula/tests/conftest.py new file mode 100644 index 00000000..c4b51904 --- /dev/null +++ b/kanidm-formula/tests/conftest.py @@ -0,0 +1,64 @@ +""" +Copyright (C) 2026 Georg Pfuetzenreuter + +This program is free software: you can redminetribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from json import loads + +import pytest + +def idm_admin_password(host): + result = host.run(f'sudo kanidmd -o json recover-account idm_admin') + print(result) + for line in result.stdout.splitlines(): + if line[0] == '{': + return loads(line)['password'] + if line[0] == '"': + return loads(line) + return None + +def login_cmd(host, password=None): + if password is None: + password = idm_admin_password(host) + return f'env KANIDM_PASSWORD={password} kanidm -D idm_admin login' + +@pytest.fixture +def idm_admin(host): + return idm_admin_password(host) + +@pytest.fixture(scope='module') +def people_accounts_only_delete(host): + yield + + print(host.run(f'{login_cmd(host)} && for x in testperson1 testperson2; do echo $x; kanidm -D idm_admin person delete "$x"; done')) + +@pytest.fixture(scope='module') +def service_accounts_only_delete(host): + yield + + # for some reason accounts must be deleted from groups first, otherwise account delete will return 403 (very helpful indeed) + print(host.run(f'{login_cmd(host)} && for a in testsvc1 testsvc2 testsvc3 testsvc4; do echo $x; for g in idm_people_admins idm_service_account_admins; do kanidm -D idm_admin group remove-members $g $a; done; kanidm -D idm_admin service-account delete $a; done')) + +@pytest.fixture +def account(host): + name = 'testperson3' + password = idm_admin_password(host) + login = login_cmd(host, password) + + print(host.run(f'{login} && kanidm -D idm_admin person create {name} "Full name of {name}"')) + + yield (name, password) + + print(host.run(f'{login} && kanidm -D idm_admin person delete {name}')) diff --git a/kanidm-formula/tests/test_kanidm_client_module.py b/kanidm-formula/tests/test_kanidm_client_module.py new file mode 100644 index 00000000..01c03bfe --- /dev/null +++ b/kanidm-formula/tests/test_kanidm_client_module.py @@ -0,0 +1,99 @@ +""" +Copyright (C) 2025-2026 Georg Pfuetzenreuter + +This program is free software: you can redminetribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from json import loads +from utils import salt +import pytest + +@pytest.mark.parametrize( + 'name, success', [ + ('idm_admin', True), + ('boobaz', False), + ], +) +def test_kanidm_client_local_login(host, name, success, idm_admin): + if name == 'idm_admin': + password = idm_admin + else: + password = 'garbage' + out, err, rc = salt(host, f'kanidm_client.local_login {name} {password}') + if success: + assert rc == 0 + else: + assert rc == 1 + assert out == success + +@pytest.mark.parametrize( + 'name, displayname, managed_by, groups, success', [ + ('testsvc1', 'Test Service 1', 'idm_admin', ['idm_service_account_admins@idm.example.com'], True), + ('testsvc1', 'Test Service 2', 'idm_admin', ['idm_service_account_admins'], False), + ('testsvc1', 'Test Service 2', 'idm_admin', [], False), + ('testsvc2', 'Test Service 2', 'idm_admin', ['idm_service_account_admins', 'idm_people_admins'], True), + ('testsvc3', 'Test Service 3', 'boo_not_exist', ['idm_service_account_admins', 'idm_people_admins'], False), + ('testsvc3', 'Test Service 3', 'boo_not_exist', [], False), + ], +) +def test_kanidm_client_local_service_account_create(host, idm_admin, service_accounts_only_delete, name, displayname, managed_by, groups, success): + out, err, rc = salt(host, f'kanidm_client.local_login idm_admin {idm_admin}') + if rc != 0: + pytest.fail('could not authenticate for testing local_service_account_create() test') + + cmd = f'kanidm_client.local_service_account_create {name} "{displayname}" {managed_by}' + if groups: + cmd = f'{cmd} "{groups}"' + out, err, rc = salt(host, cmd) + if success: + assert rc == 0 + else: + assert rc == 1 + assert out == success + +@pytest.mark.parametrize( + 'name, displayname, success', [ + ('testperson1', 'Test Person 1', True), + ('testperson1', 'Test Person 2', False), + ], +) +def test_kanidm_client_person_create(host, idm_admin, people_accounts_only_delete, name, displayname, success): + out, err, rc = salt(host, f'kanidm_client.person_account_create {name} "{displayname}"') + if success: + assert rc == 0 + assert out == True + else: + assert rc == 0 ## TODO + assert out == False + +def test_kanidm_client_person_update(host, account): + # TODO: test update of all supported fields + new_displayname = 'The new displayname' + name, admin_password = account + + out, err, rc = salt(host, f'kanidm_client.person_account_update {name} displayname="{new_displayname}"') + assert rc == 0 + assert out == True + + check = host.run(f'env KANIDM_PASSWORD={admin_password} kanidm -D idm_admin login >/dev/null && kanidm -D idm_admin -o json person get {name}') + if check.rc != 0: + pytest.fail('could not retrieve validation result') + have_displayname = loads(check.stdout).get('attrs', {}).get('displayname') + + # kanidm will return a list for each attribute, but only one value is expected for displayname + if not isinstance(have_displayname, list) or len(have_displayname) != 1: + pytest.fail('unexpected validation result') + have_displayname = have_displayname[0] + + assert have_displayname == new_displayname diff --git a/kanidm-formula/tests/test_kanidm_server_module.py b/kanidm-formula/tests/test_kanidm_server_module.py new file mode 100644 index 00000000..3661953a --- /dev/null +++ b/kanidm-formula/tests/test_kanidm_server_module.py @@ -0,0 +1,36 @@ +""" +Copyright (C) 2025-2026 Georg Pfuetzenreuter + +This program is free software: you can redminetribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +from utils import salt +import pytest + +@pytest.mark.parametrize( + 'name, success', [ + ('idm_admin', True), + ('boobaz', False), + ], +) +def test_kanidm_server_recover_account(host, name, success): + out, err, rc = salt(host, f'kanidm_server.recover_account {name}') + if success: + assert rc == 0 + assert len(out) == 48 + else: + # TODO (comment in module) + #assert rc > 0 + assert rc == 0 + assert out is False diff --git a/kanidm-formula/tests/utils.py b/kanidm-formula/tests/utils.py new file mode 100644 index 00000000..015b7f51 --- /dev/null +++ b/kanidm-formula/tests/utils.py @@ -0,0 +1,8 @@ +from json import loads + +def salt(host, command): + print(command) + result = host.run(f'sudo salt-call --local --out json {command}') + print(result) + output = loads(result.stdout)['local'] + return output, result.stderr, result.rc