diff --git a/src/ansibleguy-webui/aw/api_endpoints/credentials.py b/src/ansibleguy-webui/aw/api_endpoints/credentials.py index da4f4dd..9685f2b 100644 --- a/src/ansibleguy-webui/aw/api_endpoints/credentials.py +++ b/src/ansibleguy-webui/aw/api_endpoints/credentials.py @@ -10,6 +10,7 @@ from aw.model.permission import CHOICE_PERMISSION_READ, CHOICE_PERMISSION_WRITE, CHOICE_PERMISSION_DELETE from aw.api_endpoints.base import API_PERMISSION, get_api_user, GenericResponse, BaseResponse from aw.utils.permission import has_credentials_permission, has_manager_privileges +from aw.config.hardcoded import SECRET_HIDDEN from aw.utils.util import is_null from aw.base import USERS @@ -186,7 +187,7 @@ def post(self, request): for field in BaseJobCredentials.SECRET_ATTRS: value = serializer.validated_data[field] if field in BaseJobCredentials.SECRET_ATTRS: - if is_null(value) or value == BaseJobCredentials.SECRET_HIDDEN: + if is_null(value) or value == SECRET_HIDDEN: serializer.validated_data[field] = None elif field == 'ssh_key': @@ -376,9 +377,9 @@ def put(self, request, credentials_id: int): try: # not working with password properties: 'Job.objects.filter(id=job_id).update(**serializer.data)' - for field, value in serializer.data.items(): + for field, value in serializer.validated_data.items(): if field in BaseJobCredentials.SECRET_ATTRS: - if is_null(value) or value == BaseJobCredentials.SECRET_HIDDEN: + if is_null(value) or value == SECRET_HIDDEN: value = getattr(credentials, field) elif field == 'ssh_key': diff --git a/src/ansibleguy-webui/aw/api_endpoints/job.py b/src/ansibleguy-webui/aw/api_endpoints/job.py index 1881e2b..b791a57 100644 --- a/src/ansibleguy-webui/aw/api_endpoints/job.py +++ b/src/ansibleguy-webui/aw/api_endpoints/job.py @@ -270,7 +270,7 @@ def put(self, request, job_id: int): ) try: - Job.objects.filter(id=job.id).update(**serializer.data) + Job.objects.filter(id=job.id).update(**serializer.validated_data) except IntegrityError as err: return Response( diff --git a/src/ansibleguy-webui/aw/api_endpoints/permission.py b/src/ansibleguy-webui/aw/api_endpoints/permission.py index 35864b7..8550513 100644 --- a/src/ansibleguy-webui/aw/api_endpoints/permission.py +++ b/src/ansibleguy-webui/aw/api_endpoints/permission.py @@ -272,7 +272,7 @@ def post(self, request): status=400, ) - return Response({'msg': f"Permission '{serializer.data['name']}' created successfully"}) + return Response({'msg': f"Permission '{serializer.validated_data['name']}' created successfully"}) class APIPermissionItem(GenericAPIView): diff --git a/src/ansibleguy-webui/aw/api_endpoints/repository.py b/src/ansibleguy-webui/aw/api_endpoints/repository.py index 67ddbaa..e17bbfd 100644 --- a/src/ansibleguy-webui/aw/api_endpoints/repository.py +++ b/src/ansibleguy-webui/aw/api_endpoints/repository.py @@ -124,7 +124,7 @@ def post(self, request): except IntegrityError as err: return Response(data={'msg': f"Provided repository data is not valid: '{err}'"}, status=400) - return Response({'msg': f"Repository '{serializer.data['name']}' created successfully"}) + return Response({'msg': f"Repository '{serializer.validated_data['name']}' created successfully"}) class APIRepositoryItem(GenericAPIView): @@ -204,7 +204,7 @@ def put(self, request, repo_id: int): ) try: - Repository.objects.filter(id=repo_id).update(**serializer.data) + Repository.objects.filter(id=repo_id).update(**serializer.validated_data) return Response(data={'msg': f"Repository '{repository.name}' updated"}, status=200) except IntegrityError as err: diff --git a/src/ansibleguy-webui/aw/api_endpoints/system.py b/src/ansibleguy-webui/aw/api_endpoints/system.py index 39e58d8..f2ff605 100644 --- a/src/ansibleguy-webui/aw/api_endpoints/system.py +++ b/src/ansibleguy-webui/aw/api_endpoints/system.py @@ -7,9 +7,10 @@ from aw.config.main import config from aw.model.system import SystemConfig, get_config_from_db from aw.api_endpoints.base import API_PERMISSION, get_api_user, GenericResponse, BaseResponse -from aw.utils.util_no_config import is_set +from aw.utils.util_no_config import is_set, is_null from aw.utils.debug import log from aw.utils.permission import has_manager_privileges +from aw.config.hardcoded import SECRET_HIDDEN class SystemConfigReadResponse(BaseResponse): @@ -34,6 +35,9 @@ class SystemConfigReadResponse(BaseResponse): logo_url = serializers.CharField() ara_server = serializers.CharField() global_environment_vars = serializers.CharField() + mail_server = serializers.CharField() + mail_transport = serializers.IntegerField() + mail_user = serializers.CharField() class SystemConfigWriteRequest(serializers.ModelSerializer): @@ -41,6 +45,8 @@ class Meta: model = SystemConfig fields = SystemConfig.api_fields_write + mail_pass = serializers.CharField(max_length=100, required=False, default=None, allow_blank=True) + class APISystemConfig(APIView): http_method_names = ['put', 'get'] @@ -95,12 +101,16 @@ def put(self, request): try: changed = False for setting, value in serializer.validated_data.items(): + if setting in SystemConfig.SECRET_ATTRS: + if is_null(value) or value == SECRET_HIDDEN: + value = getattr(config_db, setting) + if is_set(value) and str(config[setting]) != str(value): setattr(config_db, setting, value) changed = True if changed: - log(msg='System config change - updating', level=5) + log(msg='System config changed - updating', level=5) config_db.save() return Response(data={'msg': "System config updated"}, status=200) diff --git a/src/ansibleguy-webui/aw/config/form_metadata.py b/src/ansibleguy-webui/aw/config/form_metadata.py index 74ef535..b104945 100644 --- a/src/ansibleguy-webui/aw/config/form_metadata.py +++ b/src/ansibleguy-webui/aw/config/form_metadata.py @@ -85,6 +85,10 @@ 'port': 'Listen Port', 'ssl_file_crt': 'SSL Certificate', 'ssl_file_key': 'SSL Private-Key', + 'mail_server': 'Mail Server', + 'mail_transport': 'Mail Transport', + 'mail_user': 'Mail Login Username', + 'mail_pass': 'Mail Login Password', } } } @@ -197,6 +201,7 @@ 'Documentation - Integrations', 'global_environment_vars': 'Set environmental variables that will be added to every job execution. ' 'Comma-separated list of key-value pairs. (VAR1=TEST1,VAR2=0)', + 'mail_server': 'Mail Server to use for Alert Mails', } } } diff --git a/src/ansibleguy-webui/aw/config/hardcoded.py b/src/ansibleguy-webui/aw/config/hardcoded.py index 279d946..679aa8b 100644 --- a/src/ansibleguy-webui/aw/config/hardcoded.py +++ b/src/ansibleguy-webui/aw/config/hardcoded.py @@ -21,3 +21,4 @@ REPO_CLONE_TIMEOUT = 300 ENV_KEY_CONFIG = 'AW_CONFIG' ENV_KEY_SAML = 'AW_SAML' +SECRET_HIDDEN = '⬤' * 15 diff --git a/src/ansibleguy-webui/aw/model/alert.py b/src/ansibleguy-webui/aw/model/alert.py new file mode 100644 index 0000000..69c9d09 --- /dev/null +++ b/src/ansibleguy-webui/aw/model/alert.py @@ -0,0 +1,83 @@ +from django.db import models + +from aw.model.base import BaseModel, BareModel, CHOICES_BOOL +from aw.model.job import Job + +from aw.base import USERS, GROUPS + +ALERT_TYPE_EMAIL = 0 +ALERT_TYPE_PLUGIN = 1 + +ALERT_TYPE_CHOICES = [ + (ALERT_TYPE_EMAIL, 'E-Mail'), + (ALERT_TYPE_PLUGIN, 'Plugin'), +] + +ALERT_CONDITION_FAIL = 0 +ALERT_CONDITION_SUCCESS = 1 +ALERT_CONDITION_ALWAYS = 2 + +ALERT_CONDITION_CHOICES = [ + (ALERT_CONDITION_FAIL, 'Failure'), + (ALERT_CONDITION_SUCCESS, 'Success'), + (ALERT_CONDITION_ALWAYS, 'Always'), +] + + +class AlertPlugin(BaseModel): + name = models.CharField(max_length=100) + executable = models.CharField(max_length=300) + + +class BaseAlert(BaseModel): + name = models.CharField(max_length=100) + alert_type = models.PositiveSmallIntegerField(choices=ALERT_TYPE_CHOICES, default=ALERT_TYPE_EMAIL) + plugin = models.ForeignKey( + AlertPlugin, on_delete=models.CASCADE, null=True, related_name='alert_fk_plugin', + ) + jobs_all = models.BooleanField(choices=CHOICES_BOOL, default=False) + jobs = models.ManyToManyField( + Job, + through='AlertJobMapping', + through_fields=('alert', 'job'), + ) + condition = models.PositiveSmallIntegerField(choices=ALERT_CONDITION_CHOICES, default=ALERT_CONDITION_FAIL) + + class Meta: + abstract = True + + +class AlertGlobal(BaseAlert): + def __str__(self) -> str: + return f"Global Alert '{self.name}'" + + +class AlertGroup(BaseAlert): + group = models.ForeignKey( + GROUPS, on_delete=models.SET_NULL, null=True, related_name='alert_fk_group', + ) + + def __str__(self) -> str: + return f"Alert '{self.name}' of group '{self.group.name}'" + + +class AlertUser(BaseAlert): + user = models.ForeignKey( + USERS, on_delete=models.SET_NULL, null=True, related_name='alert_fk_user', editable=False, + ) + + def __str__(self) -> str: + return f"Alert '{self.name}' of user '{self.user.username}'" + + +class AlertUserJobMapping(BareModel): + job = models.ForeignKey(Job, on_delete=models.CASCADE) + alert = models.ForeignKey(AlertUser, on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"{self.alert} linked to job '{self.job.name}'" + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['job', 'alert'], name='alertjobmmap_unique') + ] diff --git a/src/ansibleguy-webui/aw/model/job_credential.py b/src/ansibleguy-webui/aw/model/job_credential.py index 56a6600..2b019c8 100644 --- a/src/ansibleguy-webui/aw/model/job_credential.py +++ b/src/ansibleguy-webui/aw/model/job_credential.py @@ -20,7 +20,6 @@ class BaseJobCredentials(BaseModel): 'vault_file': '--vault-password-file', 'vault_id': '--vault-id', } - SECRET_HIDDEN = '⬤' * 15 name = models.CharField(max_length=100, null=False, blank=False) connect_user = models.CharField(max_length=100, **DEFAULT_NONE) diff --git a/src/ansibleguy-webui/aw/model/system.py b/src/ansibleguy-webui/aw/model/system.py index 6d0a8bf..ec9393b 100644 --- a/src/ansibleguy-webui/aw/model/system.py +++ b/src/ansibleguy-webui/aw/model/system.py @@ -5,16 +5,32 @@ from aw.config.defaults import CONFIG_DEFAULTS from aw.config.environment import check_aw_env_var_is_set from aw.config.main import VERSION +from aw.utils.util import is_null +from aw.utils.crypto import decrypt, encrypt + +MAIL_TRANSPORT_TYPE_PLAIN = 0 +MAIL_TRANSPORT_TYPE_SSL = 1 +MAIL_TRANSPORT_TYPE_STARTTLS = 2 + +MAIL_TRANSPORT_TYPE_CHOICES = [ + (MAIL_TRANSPORT_TYPE_PLAIN, 'Unencrypted'), + (MAIL_TRANSPORT_TYPE_SSL, 'SSL'), + (MAIL_TRANSPORT_TYPE_STARTTLS, 'StartTLS'), +] # NOTE: add default-values to config.defaults.CONFIG_DEFAULTS class SystemConfig(BaseModel): + SECRET_ATTRS = ['mail_pass'] form_fields = [ 'path_run', 'path_play', 'path_log', 'timezone', 'run_timeout', 'session_timeout', 'path_ansible_config', 'path_ssh_known_hosts', 'debug', 'logo_url', 'ara_server', 'global_environment_vars', + 'mail_server', 'mail_transport', 'mail_user', ] + # NOTE: 'AW_DB' is needed to get this config from DB and 'AW_SECRET' cannot be saved because of security breach - api_fields_write = form_fields + api_fields_write = form_fields.copy() + api_fields_write.extend(SECRET_ATTRS) api_fields_read_only = ['db', 'db_migrate', 'serve_static', 'deployment', 'version'] path_run = models.CharField(max_length=500, default='/tmp/ansible-webui') @@ -29,12 +45,33 @@ class SystemConfig(BaseModel): logo_url = models.CharField(max_length=500, **DEFAULT_NONE) ara_server = models.CharField(max_length=300, **DEFAULT_NONE) global_environment_vars = models.CharField(max_length=1000, **DEFAULT_NONE) + mail_server = models.CharField(max_length=300, default='127.0.0.1:25', blank=True, null=True) + mail_transport = models.PositiveSmallIntegerField( + choices=MAIL_TRANSPORT_TYPE_CHOICES, default=MAIL_TRANSPORT_TYPE_PLAIN, + ) + mail_user = models.CharField(max_length=300, **DEFAULT_NONE) + _enc_mail_pass = models.CharField(max_length=500, **DEFAULT_NONE) @classmethod def get_set_env_vars(cls) -> list: # grey-out settings in web-ui return [field for field in cls.form_fields if check_aw_env_var_is_set(field)] + @property + def mail_pass(self) -> str: + if is_null(self._enc_mail_pass): + return '' + + return decrypt(self._enc_mail_pass) + + @mail_pass.setter + def mail_pass(self, value: str): + if is_null(value): + self._enc_mail_pass = None + return + + self._enc_mail_pass = encrypt(value) + def __str__(self) -> str: return 'Ansible-WebUI System Config' diff --git a/src/ansibleguy-webui/aw/templatetags/form_util.py b/src/ansibleguy-webui/aw/templatetags/form_util.py index 00f22a2..c72584b 100644 --- a/src/ansibleguy-webui/aw/templatetags/form_util.py +++ b/src/ansibleguy-webui/aw/templatetags/form_util.py @@ -5,11 +5,16 @@ from django.core.validators import RegexValidator from aw.model.job_credential import BaseJobCredentials +from aw.model.system import SystemConfig from aw.utils.util import is_set from aw.views.validation import AW_VALIDATIONS +from aw.config.hardcoded import SECRET_HIDDEN register = template.Library() +FORM_SECRET_FIELDS = BaseJobCredentials.SECRET_ATTRS.copy() +FORM_SECRET_FIELDS.extend(SystemConfig.SECRET_ATTRS) + @register.filter def get_form_field_attributes(bf: BoundField) -> str: @@ -47,10 +52,10 @@ def get_form_required(bf: BoundField) -> str: def get_form_field_value(bf: BoundField, existing: dict) -> (str, None): # SECRET_ATTRS are not exposed here - if bf.name not in existing and bf.name not in BaseJobCredentials.SECRET_ATTRS: + if bf.name not in existing and bf.name not in FORM_SECRET_FIELDS: return None - if bf.name in BaseJobCredentials.SECRET_ATTRS: + if bf.name in FORM_SECRET_FIELDS: enc_field = '_enc_' + bf.name if enc_field in existing and not is_set(existing[enc_field]): return None @@ -59,7 +64,7 @@ def get_form_field_value(bf: BoundField, existing: dict) -> (str, None): value = None else: - value = BaseJobCredentials.SECRET_HIDDEN + value = SECRET_HIDDEN else: if existing[bf.name] is None: diff --git a/src/ansibleguy-webui/aw/utils/alert.py b/src/ansibleguy-webui/aw/utils/alert.py new file mode 100644 index 0000000..544029b --- /dev/null +++ b/src/ansibleguy-webui/aw/utils/alert.py @@ -0,0 +1,11 @@ +# from aw.model.alert import AlertUser + +# check if there is any alert for the current job (matching the condition) +# get all users privileged to view the current job +# filter list to those that should be notified +# execute all alerts (global/group/user) + +# implement email +# https://realpython.com/python-send-email/ + +# implement plugin interface diff --git a/src/ansibleguy-webui/aw/views/forms/system.py b/src/ansibleguy-webui/aw/views/forms/system.py index 089c1ee..ce46b49 100644 --- a/src/ansibleguy-webui/aw/views/forms/system.py +++ b/src/ansibleguy-webui/aw/views/forms/system.py @@ -37,6 +37,9 @@ class Meta: label=FORM_LABEL['system']['config']['timezone'], ) debug = forms.BooleanField(initial=CONFIG_DEFAULTS['debug'] or deployment_dev()) + mail_pass = forms.CharField( + max_length=100, required=False, label=Meta.labels['mail_pass'], + ) @login_required diff --git a/test/integration/api/main.py b/test/integration/api/main.py index a3df8bb..9ce710a 100644 --- a/test/integration/api/main.py +++ b/test/integration/api/main.py @@ -138,7 +138,7 @@ def test_modify(): 'repository': 2, }}, - # perms + # perms; todo: fix {'l': 'permission/1', 'd': {'name': 'perm1'}}, ])