From f1ba54d2ad9bb28074299704d261d15791a7d32f Mon Sep 17 00:00:00 2001 From: Adrien Waksberg Date: Fri, 29 Sep 2023 15:54:34 +0200 Subject: [PATCH] feat: manage users --- CHANGELOG.md | 2 + README.md | 2 + defaults/main.yml | 5 +- library/elasticsearch_init_password.py | 52 ++++++++++++ library/elasticsearch_user.py | 106 +++++++++++++++++++++++++ module_utils/elasticsearch_api.py | 11 +-- molecule/default/converge.yml | 6 ++ molecule/default/molecule.yml | 2 + molecule/default/tests/test_default.py | 41 +++++----- tasks/data.yml | 27 +++++++ 10 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 library/elasticsearch_init_password.py create mode 100644 library/elasticsearch_user.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef05df..185606b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Which is based on [Keep A Changelog](http://keepachangelog.com/) ### Added +- feat: manage user - feat: add variable to set major version - feat: add ilm policy - test: add support debian 12 @@ -17,6 +18,7 @@ Which is based on [Keep A Changelog](http://keepachangelog.com/) ### Changed +- major default version is 8 - replace kitchen to molecule ### Fixed diff --git a/README.md b/README.md index e53638b..2f1897e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Install and configure Elasticsearch * `elasticsearch_major_version` - set the major version (default: `7`) * `elasticsearch_heap_size` - set the heap size (default: `1g`) +* `elasticsearch_api_user` - set the admin user (default: `elastic`) +* `elasticsearch_api_password` - set the password for api * `elasticsearch_config` - hash with the configuration (see [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html)) ``` diff --git a/defaults/main.yml b/defaults/main.yml index deb7bec..84d61f9 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -1,10 +1,13 @@ --- -elasticsearch_major_version: 7 +elasticsearch_major_version: 8 +elasticsearch_api_user: elastic elasticsearch_heap_size: 1g elasticsearch_config: {} elasticsearch_default_config: path.data: /var/lib/elasticsearch path.logs: /var/log/elasticsearch + xpack.security.transport.ssl.enabled: false + xpack.security.http.ssl.enabled: false elasticsearch_full_config: "{{ elasticsearch_default_config | combine(elasticsearch_config) }}" elasticsearch_index_templates: {} elasticsearch_ilm_policies: {} diff --git a/library/elasticsearch_init_password.py b/library/elasticsearch_init_password.py new file mode 100644 index 0000000..0e0fadc --- /dev/null +++ b/library/elasticsearch_init_password.py @@ -0,0 +1,52 @@ +#!/usr/bin/python + +from ansible.module_utils.basic import * +from ansible.module_utils.elasticsearch_api import * +import subprocess + +class ElasticsearchInitPassword: + def __init__(self, api_url, api_user, api_password): + self.api = ElasticsearchApi( + api_url, + api_user, + api_password + ) + self.user = api_user + self.password = api_password + + def is_set(self): + status_code, _ = self.api.get('_cluster/health') + if status_code == 401: + return False + + return True + + def change(self): + subprocess.run( + ['/usr/share/elasticsearch/bin/elasticsearch-reset-password', '-u', self.user, '-b', '-i'], + input='{}\n{}'.format(self.password, self.password).encode() + ) + +def main(): + fields = { + 'api_url': { 'type': 'str', 'default': 'http://127.0.0.1:9200' }, + 'api_user': { 'type': 'str', 'default': None }, + 'api_password': { 'type': 'str', 'default': None, 'no_log': True }, + } + module = AnsibleModule(argument_spec=fields) + changed = False + + init = ElasticsearchInitPassword( + module.params['api_url'], + module.params['api_user'], + module.params['api_password'], + ) + + if not init.is_set(): + init.change() + changed = True + + module.exit_json(changed=changed) + +if __name__ == '__main__': + main() diff --git a/library/elasticsearch_user.py b/library/elasticsearch_user.py new file mode 100644 index 0000000..a4f89f0 --- /dev/null +++ b/library/elasticsearch_user.py @@ -0,0 +1,106 @@ +#!/usr/bin/python + +from ansible.module_utils.basic import * +from ansible.module_utils.elasticsearch_api import * + +class ElasticsearchUser: + def __init__(self, api_url, api_user, api_password, name, password, roles): + self.api = ElasticsearchApi( + api_url, + api_user, + api_password + ) + self.api_url = api_url + self.name = name + self.password = password + self.roles = roles + self.exist = False + self.data = {} + + def get_data(self): + status_code, data = self.api.get('_security/user/{}'.format(self.name)) + if status_code == 200: + self.exist = True + self.data = data[self.name] + + def roles_have_changed(self): + for role in self.roles: + if role not in self.data['roles']: + return True + + for role in self.data['roles']: + if role not in self.roles: + return True + + return False + + def password_has_changed(self): + api = ElasticsearchApi( + self.api_url, + self.name, + self.password + ) + status_code, _ = api.get('_cluster/health') + if status_code == 401: + return True + + return False + + def has_changed(self): + if self.roles_have_changed(): + return True + + if self.password_has_changed(): + return True + + return False + + def create(self): + self.api.put( + '_security/user/{}'.format(self.name), + { + 'password': self.password, + 'roles': self.roles + } + ) + + def delete(self): + self.api.delete('_security/user/{}'.format(self.name)) + + +def main(): + fields = { + 'name': { 'type': 'str', 'required': True }, + 'password': { 'type': 'str', 'required': True, 'no_log': True }, + 'roles': { 'type': 'list', 'default': [] }, + 'api_url': { 'type': 'str', 'default': 'http://127.0.0.1:9200' }, + 'api_user': { 'type': 'str', 'default': None }, + 'api_password': { 'type': 'str', 'default': None, 'no_log': True }, + 'state': { 'type': 'str', 'default': 'present', 'choice': ['present', 'absent'] }, + } + module = AnsibleModule(argument_spec=fields) + changed = False + + user = ElasticsearchUser( + module.params['api_url'], + module.params['api_user'], + module.params['api_password'], + module.params['name'], + module.params['password'], + module.params['roles'], + ) + user.get_data() + + if module.params['state'] == 'present': + if not user.exist or user.has_changed(): + user.create() + changed = True + else: + if user.exist: + user.delete() + changed = True + + module.exit_json(changed=changed) + +if __name__ == '__main__': + main() diff --git a/module_utils/elasticsearch_api.py b/module_utils/elasticsearch_api.py index e8d858c..02a58c4 100644 --- a/module_utils/elasticsearch_api.py +++ b/module_utils/elasticsearch_api.py @@ -5,28 +5,25 @@ import base64 class ElasticsearchApi: def __init__(self, url, user, password): self.url = url - self.headers = {} + self.basic = None if user and password: - token = base64.b64encode('{}:{}',) - self.headers = { 'Authorization': 'Basic ' + base64.b64encode({},) } + self.basic = requests.auth.HTTPBasicAuth(user, password) def get(self, path): r = requests.get( '{}/{}'.format(self.url, path), - headers=self.headers + auth=self.basic ) if r.status_code == 500: raise Exception('Server return 500 error: {}'.format(r.text)) - elif r.status_code == 401: - raise Exception('Authentification has failed') return r.status_code, r.json() def put(self, path, data): r = requests.put( '{}/{}'.format(self.url, path), - headers=self.headers, + auth=self.basic, json=data ) diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 2b3140c..1e806a7 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -4,7 +4,13 @@ roles: - ansible-role-elasticsearch vars: + elasticsearch_password: mysecret elasticsearch_heap_size: 512m + elasticsearch_users: + toto: + password: supers3cret + roles: + - viewer elasticsearch_index_templates: test: index_patterns: diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml index 89bf10a..71a343e 100644 --- a/molecule/default/molecule.yml +++ b/molecule/default/molecule.yml @@ -11,6 +11,8 @@ platforms: command: /bin/systemd capabilities: - SYS_ADMIN + published_ports: + - 127.0.0.1:5601:5601 lint: | set -e yamllint . diff --git a/molecule/default/tests/test_default.py b/molecule/default/tests/test_default.py index c12dc7c..d05f42a 100644 --- a/molecule/default/tests/test_default.py +++ b/molecule/default/tests/test_default.py @@ -2,31 +2,36 @@ import os import testinfra.utils.ansible_runner def test_packages(host): - package = host.package('elasticsearch') - assert package.is_installed + package = host.package('elasticsearch') + assert package.is_installed def test_config_file(host): - config = host.file('/etc/elasticsearch/elasticsearch.yml') - assert config.user == 'root' - assert config.group == 'elasticsearch' - assert config.mode == 0o640 - assert config.contains('path.data: /var/lib/elasticsearch') + config = host.file('/etc/elasticsearch/elasticsearch.yml') + assert config.user == 'root' + assert config.group == 'elasticsearch' + assert config.mode == 0o640 + assert config.contains('path.data: /var/lib/elasticsearch') def test_service(host): - service = host.service('elasticsearch') - assert service.is_running - assert service.is_enabled + service = host.service('elasticsearch') + assert service.is_running + assert service.is_enabled def test_socket(host): - for port in [9200, 9300]: - socket = host.socket('tcp://127.0.0.1:%d' % (port)) - assert socket.is_listening + for port in [9200, 9300]: + socket = host.socket('tcp://127.0.0.1:%d' % (port)) + assert socket.is_listening def test_java_memory(host): - process = host.process.get(user='elasticsearch', comm='java') - assert '-Xms512m' in process.args - assert '-Xmx512m' in process.args + process = host.process.filter(user='elasticsearch', comm='java') + assert '-Xms512m' in process[1].args + assert '-Xmx512m' in process[1].args def test_elasticsearch_template(host): - result = host.check_output('curl -v http://127.0.0.1:9200/_template/test') - assert '"number_of_replicas":"1"' in result + result = host.check_output('curl -v -u elastic:mysecret http://127.0.0.1:9200/_template/test') + assert '"number_of_replicas":"1"' in result + +def test_elasticsearch_user(host): + result = host.check_output('curl -v -u elastic:mysecret http://127.0.0.1:9200/_security/user/toto') + assert '"username":"toto"' in result + assert '"roles":["viewer"]' in result diff --git a/tasks/data.yml b/tasks/data.yml index 1e02071..ddf8481 100644 --- a/tasks/data.yml +++ b/tasks/data.yml @@ -1,8 +1,31 @@ --- +- name: Init elastic password + elasticsearch_init_password: + api_user: "{{ elasticsearch_api_user }}" + api_password: "{{ elasticsearch_password }}" + run_once: true + tags: elasticsearch + +- name: Manage users + elasticsearch_user: + name: "{{ item.key }}" + password: "{{ item.value.password }}" + roles: "{{ item.value.roles | default(omit) }}" + api_user: "{{ elasticsearch_api_user }}" + api_password: "{{ elasticsearch_password }}" + state: "{{ item.value.state | default('present') }}" + loop: "{{ elasticsearch_users | dict2items }}" + loop_control: + label: "{{ item.key }}" + run_once: true + tags: elasticsearch + - name: Copy ilm policies elasticsearch_ilm_policy: name: "{{ item.key }}" phases: "{{ item.value | default({}) }}" + api_user: "{{ elasticsearch_api_user }}" + api_password: "{{ elasticsearch_password }}" loop: "{{ elasticsearch_ilm_policies | dict2items }}" loop_control: label: "{{ item.key }}" @@ -15,6 +38,10 @@ index_patterns: "{{ item.value.index_patterns }}" settings: "{{ item.value.settings | default({}) }}" mappings: "{{ item.value.mappings | default({}) }}" + api_user: "{{ elasticsearch_api_user }}" + api_password: "{{ elasticsearch_password }}" loop: "{{ elasticsearch_index_templates | dict2items }}" + loop_control: + label: "{{ item.key }}" run_once: true tags: elasticsearch