diff --git a/README.md b/README.md index 1b744677..c9858b79 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ These modules allow you to manage installed packages: * [pfsense_haproxy_backend](https://github.com/pfsensible/core/wiki/pfsense_haproxy_backend) for HAProxy backends * [pfsense_haproxy_backend_server](https://github.com/pfsensible/core/wiki/pfsense_haproxy_backend_server) for HAProxy backends servers +* [pfsense_saml](https://github.com/pfsensible/core/wiki/pfsense_saml) for SSO over SAML ## [Change Log](https://github.com/pfsensible/core/blob/master/CHANGELOG.rst) diff --git a/plugins/modules/pfsense_saml.py b/plugins/modules/pfsense_saml.py new file mode 100644 index 00000000..2d5b3d8b --- /dev/null +++ b/plugins/modules/pfsense_saml.py @@ -0,0 +1,248 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Kevin Brooks +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +INSTALLATION = """ +- name: Set pfSense-pkg-saml2-auth url for pfSense version 2.8 + tags: + - setup + set_fact: + pfSense_saml2_pkg: https://github.com/pfrest/pfSense-pkg-saml2-auth/releases/latest/download/pfSense-2.8-pkg-saml2-auth.pkg + +- name: Add plugin pfSense-pkg-saml2-auth to repo + tags: + - setup + command: pkg add {{ pfSense_saml2_pkg }} + register: pkg_command + changed_when: not pkg_command.stdout is search("is already installed") +""" + +DOCUMENTATION = """ +--- +module: pfsense_saml +version_added: 0.8.0 +short_description: Manage pfSense SAML configuration +description: + - Manage pfSense-pkg-saml2-auth configuration +author: Kevin Brooks (@KevinB-rocks) +notes: +options: + enable: + description: State of authentication through SAML + default: true + type: bool + strip_username: + description: State of removal of @domain.example from emails in NameID + default: false + type: bool + debug_mode: + description: State of debug mode + default: false + type: bool + idp_metadata_url: + description: Metadata URL to IdP for automatic settings + default: "" + type: str + idp_entity_id: + description: Entity ID of the upstream IdP. + default: "" + type: str + idp_sign_on_url: + description: Sign-on ID of the upstream IdP. + default: "" + type: str + idp_groups_attribute: + description: Name of groups attribute returned in the SAML assertion for groups based privilege mapping. + default: "" + type: str + idp_x509_cert: + description: x509 cert provided by the IdP. + default: "" + type: str + sp_base_url: + description: Base URL of pfSense. + required: true + type: str + custom_conf: + description: JSON-config extending the explicitly defined fields. Must comply with OneLogin PHP-SAML format. Use at your own risk. + default: "" + type: str +""" + +EXAMPLES = """ +- name: Modify SAML config + pfsense_saml: + enable: true + idp_metadata_url: https://keycloak.local/realms/master/protocol/saml/descriptor + sp_base_url: https://pfSense.local +""" + +RETURN = """ + +""" + +import re +import json +from urllib.parse import urlparse + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.pfsensible.core.plugins.module_utils.module_base import PFSenseModuleBase + +SAML_ARGUMENT_SPEC = dict( + enable=dict(default=True, type="bool"), + strip_username=dict(default=False, type="bool"), + debug_mode=dict(default=False, type="bool"), + idp_metadata_url=dict(default="", type="str"), + idp_entity_id=dict(default="", type="str"), + idp_sign_on_url=dict(default="", type="str"), + idp_groups_attribute=dict(default="", type="str"), + idp_x509_cert=dict(default="", type="str"), + sp_base_url=dict(required=True, type="str"), + custom_conf=dict(default="", type="str"), +) + +IDP_ENTITY_REGEX = r"[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]+" + +class PFSenseSAMLModule(PFSenseModuleBase): + """ module managing saml config """ + + ############################## + # unit tests + # + # Must be class method for unit test usage + @staticmethod + def get_argument_spec(): + """return argument spec""" + return SAML_ARGUMENT_SPEC + + def __init__(self, module, pfsense=None): + super(PFSenseSAMLModule, self).__init__(module, pfsense, key="sp_base_url") + + self.name = "saml2-auth" + self.root_elt = self._find_target() + self.obj = dict() + + ############################## + # params processing + # + def _validate_set_if_idp_metadata_unset(self, params, key): + if params[key] == "": + if params["idp_metadata_url"] == "": + self.module.fail_json(msg="{0} is required when idp_metadata_url is unset".format(key)) + + def _validate_url(self, params, key): + try: + url = urlparse(params[key]) + if not all([url.scheme, url.netloc]): + raise Exception + except Exception: + self.module.fail_json(msg="{0} is not a valid URL".format(key)) + + def _validate_params(self): + """do some extra checks on input parameters""" + + params = self.params + + self._validate_url(params, "sp_base_url") + + if params["idp_metadata_url"] != "": + self._validate_url(params, "idp_metadata_url") + + self._validate_set_if_idp_metadata_unset(params, "idp_entity_id") + if params["idp_entity_id"] != "": + if len(params["idp_entity_id"]) > 1024: + self.module.fail_json(msg="idp_entity_id must be less than 1025 characters long") + if not re.fullmatch(IDP_ENTITY_REGEX, params["idp_entity_id"]): + self.module.fail_json(msg="idp_entity_id contains invalid characters") + + self._validate_set_if_idp_metadata_unset(params, "idp_sign_on_url") + if params["idp_sign_on_url"] != "": + self._validate_url(params, "idp_sign_on_url") + + self._validate_set_if_idp_metadata_unset(params, "idp_x509_cert") + if params["idp_x509_cert"] != "": + if not (params["idp_x509_cert"].startswith("-----BEGIN CERTIFICATE-----") and params["idp_x509_cert"].endswith("-----END CERTIFICATE-----")): + self.module.fail_json(msg="idp_x509_cert is missing BEGIN and/or END tags") + + if params["custom_conf"] != "": + try: + json.loads(params["custom_conf"]) + except json.decoder.JSONDecodeError: + self.module.fail_json(msg="custom_conf is not valid JSON") + + ############################## + # XML processing + # + def _find_target(self): + installed_pkgs_elt = self.pfsense.get_element("installedpackages") + pkgs_elts = installed_pkgs_elt.findall("package") if installed_pkgs_elt is not None else None + + for elt in pkgs_elts: + pkg_name = elt.find("internal_name") + if pkg_name is not None and pkg_name.text == self.name: + conf_elt = elt.find("conf") + if conf_elt is not None: + return conf_elt + + return self.module.fail_json(msg="Unable to find XML configuration entry. Are you sure SAML2 package is installed?") + + def _copy_and_update_target(self): + """ update the XML target_elt """ + + self.diff["before"] = self.pfsense.element_to_dict(self.target_elt) + self.diff["after"] = self.pfsense.element_to_dict(self.target_elt) + + changed = self.pfsense.copy_dict_to_element(self.obj, self.target_elt) + + return (self.diff["before"], changed) + + ############################## + # logging + # + def _log_fields(self, before=None): + """ generate pseudo-CLI command fields parameters to create an obj """ + values = "" + + if before is None: + values += self.format_cli_field(self.obj, "enable", fvalue=self.fvalue_bool, none_value='') + values += self.format_cli_field(self.obj, "strip_username", fvalue=self.fvalue_bool, none_value='') + values += self.format_cli_field(self.obj, "debug_mode", fvalue=self.fvalue_bool, none_value='') + values += self.format_cli_field(self.obj, "idp_metadata_url") + values += self.format_cli_field(self.obj, "idp_entity_id") + values += self.format_cli_field(self.obj, "idp_sign_on_url") + values += self.format_cli_field(self.obj, "idp_groups_attribute") + values += self.format_cli_field(self.obj, "idp_x509_cert") + values += self.format_cli_field(self.obj, "sp_base_url") + values += self.format_cli_field(self.obj, "custom_config") + else: + values += self.format_updated_cli_field(self.obj, before, "enable", add_comma=(values), fvalue=self.fvalue_bool, none_value='') + values += self.format_updated_cli_field(self.obj, before, "strip_username", add_comma=(values), fvalue=self.fvalue_bool, none_value='') + values += self.format_updated_cli_field(self.obj, before, "debug_mode", add_comma=(values), fvalue=self.fvalue_bool, none_value='') + values += self.format_updated_cli_field(self.obj, before, "idp_metadata_url", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "idp_entity_id", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "idp_sign_on_url", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "idp_groups_attribute", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "idp_x509_cert", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "sp_base_url", add_comma=(values)) + values += self.format_updated_cli_field(self.obj, before, "custom_config", add_comma=(values)) + return values + +def main(): + module = AnsibleModule( + argument_spec=SAML_ARGUMENT_SPEC, + supports_check_mode=True, + ) + + pfmodule = PFSenseSAMLModule(module) + pfmodule.run(module.params) + pfmodule.commit_changes() + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/modules/fixtures/pfsense_saml_config.xml b/tests/unit/plugins/modules/fixtures/pfsense_saml_config.xml new file mode 100644 index 00000000..0beeedd7 --- /dev/null +++ b/tests/unit/plugins/modules/fixtures/pfsense_saml_config.xml @@ -0,0 +1,360 @@ + + + 19.1 + + + normal + pfSense + localdomain + + + + all + + system + 1998 + 0 + + + admins + + system + 1999 + 0 + page-all + + + admin + + system + admins + $2b$10$13u6qwCOwODv34GyCMgdWub6oQF3RX0rG7c3d3X4JvzuEmAXLYDd2 + 0 + user-shell-access + + + testdel + + all + 2000 + user + $2b$12$D2jkq4Iut3ODUBN0BCrDk.bV3J5N.MrY5YEnGvTXwxeNBkyxjbbtW + page-dashboard-all + + 2001 + 2000 + 0.pfsense.pool.ntp.org + + https + + 5cf09a99bf5fe + + yes + + + + 400000 + hadp + hadp + hadp + + monthly + + + + enabled + + + + + + re0 + + dhcp + dhcp6 + + + + + + + + + 0 + + + + re1 + 192.168.100.2 + 24 + + + + + wan + 0 + + + + + + + + + 192.168.1.100 + 192.168.1.199 + + + + + + + ::1000 + ::2000 + + assist + medium + + + + + + public + + + + + + + + 1 + + + + automatic + + + + + pass + inet + + lan + 0100000101 + + lan + + + + + + + pass + inet6 + + lan + 0100000102 + + lan + + + + + + + + + + + + + 1,31 + 0-5 + * + * + * + root + /usr/bin/nice -n20 adjkerntz -a + + + 1 + 3 + 1 + * + * + root + /usr/bin/nice -n20 /etc/rc.update_bogons.sh + + + 1 + 1 + * + * + * + root + /usr/bin/nice -n20 /etc/rc.dyndns.update + + + */60 + * + * + * + * + root + /usr/bin/nice -n20 /usr/local/sbin/expiretable -v -t 3600 virusprot + + + 30 + 12 + * + * + * + root + /usr/bin/nice -n20 /etc/rc.update_urltables + + + 1 + 0 + * + * + * + root + /usr/bin/nice -n20 /etc/rc.update_pkg_metadata + + + + + + + + + ICMP + icmp + + + + + TCP + tcp + + + + + HTTP + http + + + / + + 200 + + + + HTTPS + https + + + / + + 200 + + + + SMTP + send + + + + 220 * + + + + + system_information:col1:show,netgate_services_and_support:col2:show,interfaces:col2:show + 10 + + + + + + + + + + + + + + + + + (system) + + + 5cf09a99bf5fe + + server + LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVlakNDQTJLZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREJhTVRnd05nWURWUVFLRXk5d1psTmwKYm5ObElIZGxZa052Ym1acFozVnlZWFJ2Y2lCVFpXeG1MVk5wWjI1bFpDQkRaWEowYVdacFkyRjBaVEVlTUJ3RwpBMVVFQXhNVmNHWlRaVzV6WlMwMVkyWXdPV0U1T1dKbU5XWmxNQjRYRFRFNU1EVXpNVEF6TURnd09Wb1hEVEkwCk1URXlNREF6TURnd09Wb3dXakU0TURZR0ExVUVDaE12Y0daVFpXNXpaU0IzWldKRGIyNW1hV2QxY21GMGIzSWcKVTJWc1ppMVRhV2R1WldRZ1EyVnlkR2xtYVdOaGRHVXhIakFjQmdOVkJBTVRGWEJtVTJWdWMyVXROV05tTURsaApPVGxpWmpWbVpUQ0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUt6QW91N1I0TW5VCjlWUlhQYkZyK2NRVXhtdGNKdDNpbjAvbFppRGh6ZjNVV0hnTHVRdHBoL2NMMUZIdlFwdzZzazlFRzY5RjREdTUKL2IweElnS1B2YlVFcVpJeit0aEkvZ09LZ29GRTJ1dERxcW8xSnh6SzliVzdVdHVnZWlxUnEyT1hLaGh5ZUNKcQo5OEEwL3l4VitqNkJDbmpNeVVGS2krQjJpcVNnSG1JQVkySHVRcklsb3ZwMGZuTUpkeWRjOGNzVHh0aUJUODh4Cko3K0locDJrSGNDcUtIRGg0bjVYOEdUek54YmxreTJERXBkYkdsUWcyRE1JQm9hTmRnMXpVdUx1cnV2LzVoWkIKQ2JyWjBRNXpRaWVGdU13ZnMrNEdPVW9kS201ZFJJM3YwT0NRd05JUnZVZVpDTUFhTFJVNVdvT0hzMUUzb3RjaAorNlZ3RnVaNndrOENBd0VBQWFPQ0FVa3dnZ0ZGTUFrR0ExVWRFd1FDTUFBd0VRWUpZSVpJQVliNFFnRUJCQVFECkFnWkFNQXNHQTFVZER3UUVBd0lGb0RBekJnbGdoa2dCaHZoQ0FRMEVKaFlrVDNCbGJsTlRUQ0JIWlc1bGNtRjAKWldRZ1UyVnlkbVZ5SUVObGNuUnBabWxqWVhSbE1CMEdBMVVkRGdRV0JCUUd2bSs0NWxFUW5oSDVOaHhFRk9YWQpxWVZ1ZnpDQmdnWURWUjBqQkhzd2VZQVVCcjV2dU9aUkVKNFIrVFljUkJUbDJLbUZibitoWHFSY01Gb3hPREEyCkJnTlZCQW9UTDNCbVUyVnVjMlVnZDJWaVEyOXVabWxuZFhKaGRHOXlJRk5sYkdZdFUybG5ibVZrSUVObGNuUnAKWm1sallYUmxNUjR3SEFZRFZRUURFeFZ3WmxObGJuTmxMVFZqWmpBNVlUazVZbVkxWm1XQ0FRQXdIUVlEVlIwbApCQll3RkFZSUt3WUJCUVVIQXdFR0NDc0dBUVVGQ0FJQ01DQUdBMVVkRVFRWk1CZUNGWEJtVTJWdWMyVXROV05tCk1EbGhPVGxpWmpWbVpUQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFiTjJPL2hwcjU5bC9XclJIQlhvTjJDSFYKQi9ra0VwUVVuRXhrSzVNUkJGYTV5ODVLSk5Eb09HL0R5UlVNRFR4VENkVnQrdlpQS0ZveUJXRFdDdGt3YTJScgo1Z1BDb21TUk5mUWhyV1ZtYkhSd0VNTyt6OXkxS1AzU3JhZ0tIeVdTM3lFY25wU1NRY1B1M3hhaERDNERtclJzCjMvNytBMTk2SEJxRjlBc25oUjFDZWpBcm4rSFR5dUdOQk5udGtqUktPN01NcDVxaHlZbkV3SVk5SVQ0TzFIeHYKOEtFTTdBUGRmRS93VUFvZGxET0F5RFRIRFpid2Z1eCtoOURRTGNBRE1sRi9IdzF0Y2UvcHJLek9LT3h6YmdlQQpWZ2pQVmxNbVJqQ2lJa2JNL3BPeWNObENDTzAxczQzbFJzcU5VRzFaLzBjakFzeEw0c0ltUlErQkI2dXdDUT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2d0lCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktrd2dnU2xBZ0VBQW9JQkFRQ3N3S0x1MGVESjFQVlUKVnoyeGEvbkVGTVpyWENiZDRwOVA1V1lnNGMzOTFGaDRDN2tMYVlmM0M5UlI3MEtjT3JKUFJCdXZSZUE3dWYyOQpNU0lDajcyMUJLbVNNL3JZU1A0RGlvS0JSTnJyUTZxcU5TY2N5dlcxdTFMYm9Ib3FrYXRqbHlvWWNuZ2lhdmZBCk5QOHNWZm8rZ1FwNHpNbEJTb3ZnZG9xa29CNWlBR05oN2tLeUphTDZkSDV6Q1hjblhQSExFOGJZZ1UvUE1TZS8KaUlhZHBCM0FxaWh3NGVKK1YvQms4emNXNVpNdGd4S1hXeHBVSU5nekNBYUdqWFlOYzFMaTdxN3IvK1lXUVFtNgoyZEVPYzBJbmhiak1IN1B1QmpsS0hTcHVYVVNONzlEZ2tNRFNFYjFIbVFqQUdpMFZPVnFEaDdOUk42TFhJZnVsCmNCYm1lc0pQQWdNQkFBRUNnZ0VCQUtzVzFjY0VZVVpDL1AyY3NXTG45eU4xRjlYNEhCNGdkWHRoVERaQXJBdzUKbzZ5d240Rm44TnFCQXJScTYyTmkxbm1la0hTVUZiSFJVRFZ4VmFlSHlIQmd2N2dtZHNhQjgrQjU2eW92a1VqegphVERORjRGeW1NcDFUV2hxbE5OWUZZKzZoRnhWOGhqVUs2NVdUbW9RZEpnMm9MSm16dU0wK3pkQTc2cC9VZGZuCnZkSzZVbDFWSi9WcEhFaEhSS1duSk9uTE1nRU1KS1oxT0NjRTFXOExHU08zY3QvM0VkWi9IRWU2TkZqOXArZTgKTmplTWNpSVduaTE1aTM3ai9LL1FBcFJQcUh5cDBhdUUzR0ZQckhPWjdsUHhlYlZjTExwSEFINWNuc0VyWkJXTApnSWlQcGkxTytrL01wb0h0RklHK25aSXpVdjFzeldrVjg4TjhtcHMrd2FFQ2dZRUExLzZXYVJlVk1GbnFPd0YvCm9UbUJNVTl0TnQ5U1NacTF2RlpNcFVkZk5wMmY0c0ZZNFE2U1gzeFptUEFHQkMyMDBWQWoxbmVlNlBOUTBuclgKSVIvSFJjelNpQXA3R3ZqakRjcGdQaWc2SHdpZHVVM1lvelhKSFdzaHFIVVMrTlo4b0VmUFBld0tvK3hTMmwxdwo2NG5ZR09RcHc0ZTFibURqbEJYOC9CcTBlcGtDZ1lFQXpMKzVDN1EwQmVKZExzOWZOQmFvWFhVRUlHNXhjZmNTCmJHbDFjbzNDN3h2WCtWQXNZYkVWV0hiYk40TVdmVWpSMGhuYStRM2dyaVZvdmZ5K0FqNWovMkFMM2dIY0U1UmMKU2Q0VVA4aG5YdmhST3M5cTNMblZUYVFaZktKY09KTm82VGtMeXZacVJjdmNTWHJDK21tQXc5a0c4SUNFeWZ4VApBV2lmTHk0VzNTY0NnWUVBbWtGRGdnSkpsYUpoV1lxVWI3djF2QldSVmVMZmpabGp1UUdZODJDcGF3UGZMNzROCmo0MHNrK3ptd0FhTEJXanUvWjFTT3RSck5NcXdLZUY0eWpzN3dXbXA0V1k2ek9SNm8xcW9xVHRwWnNoc2UrNVEKalI3WVpwNGdCNEswN2VtZ1Q0ZDVSaXZRM1lqbEV2WXdzc1piQWt2UVY4Z1BscWl6WHdybEJkYThsZUVDZ1lBbgpSVmFlc2crUVdWeDZEL2c0cTJmYmxRZ1htRmRWL29lZ0Y1SVpTS3RzNVRCRmQyVXJ6NlZDZEhtVGFpYzBISFZ5CkVOZDVFWHBZcklBc2dIK0pPcUkvWnhLZm9FZXYwYkxwMEJpZUt6ZjRkVFJQVFYwM3ZNVDJ3VlRLSFBJSFArN04KWE0yd1BoY2dEL3ZPZENkVmxFcklSYVlaRnUxaE9HNUxSTi9UVXNtNzNRS0JnUUNVeFFmY2RyZnZwL0ZSanlkUQpPaUJWWm1JOGFsVXg5SklhZlBjaU5WWlZBZFJnamVEVlVFUTljTklxcDlnd0F4cVVVQ1U3OW9wNWtwbzJoMkZaClBIaFRZaHlpanRxQ09hNWxHdnZTYjg4aWE1Z2xleE1zL2M1bmc0d3RlWHVpSWtqd0lTUmhMWGxCOTU3a2hvSEkKczFnbEp2YTM4MEM0Yk0rcFJhbWdqVlY4blE9PQotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg== + + + + + sudo + https://docs.netgate.com/pfsense/en/latest/packages/sudo.html + + http://www.sudo.ws/ + 0.3_10 + sudo.xml + /usr/local/pkg/sudo.inc + + + SAML2 + saml2-auth + + https://github.com/pfrest/pfSense-pkg-saml2-auth + System + %%PKGVERSION%% + saml2.xml + jaredhendrickson13@gmail.com + + yes + + + https://keycloak.local/realms/master/protocol/saml/descriptor + + + + + https://pfSense.local + + + + SAML2 + saml2 + saml2.log + + + + + + + user:root + ALL + + none + + + + sudo +
System
+ /pkg_edit.php?xml=sudo.xml +
+ + SAML2 + A SAML2 authentication extension for the pfSense UI +
System
+ /system_saml2_settings.php +
+
+
diff --git a/tests/unit/plugins/modules/test_pfsense_saml.py b/tests/unit/plugins/modules/test_pfsense_saml.py new file mode 100644 index 00000000..e3866bd3 --- /dev/null +++ b/tests/unit/plugins/modules/test_pfsense_saml.py @@ -0,0 +1,126 @@ +# Copyright: (c) 2026, Kevin Brooks +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import sys + +if sys.version_info < (2, 7): + pytestmark = pytest.mark.skip("pfSense Ansible modules require Python >= 2.7") + +from ansible_collections.pfsensible.core.plugins.modules import pfsense_saml +from .pfsense_module import TestPFSenseModule + +CURRENT_CONFIG = dict( + enable=True, + sp_base_url="https://pfSense.local", + idp_metadata_url="https://keycloak.local/realms/master/protocol/saml/descriptor", +) + +class TestPFSenseSAMLModule(TestPFSenseModule): + + module = pfsense_saml + + def __init__(self, *args, **kwargs): + super(TestPFSenseSAMLModule, self).__init__(*args, **kwargs) + self.config_file = 'pfsense_saml_config.xml' + self.pfmodule = pfsense_saml.PFSenseSAMLModule + + @staticmethod + def runTest(): + """ dummy function needed to instantiate this test module from another in python 2.7 """ + pass + + def get_target_elt(self, obj, absent=False, module_result=None): + """ return target elt from XML """ + installed_pkgs_elt = self.assert_find_xml_elt(self.xml_result, 'installedpackages') + pkgs_elts = installed_pkgs_elt.findall('package') if installed_pkgs_elt is not None else None + + for elt in pkgs_elts: + pkg_name = elt.find('internal_name') + if pkg_name is not None and pkg_name.text == "saml2-auth": + conf_elt = elt.find('conf') + if conf_elt is not None: + return conf_elt + + return None + + def check_target_elt(self, obj, target_elt): + """ check XML definition of target elt """ + + self.check_param_bool(obj, target_elt, 'enable', default=True, value_true="yes") + self.check_param_bool(obj, target_elt, 'strip_username', value_true="yes") + self.check_param_bool(obj, target_elt, 'debug_mode', value_true="yes") + + self.check_param_equal(obj, target_elt, 'idp_metadata_url') + self.check_param_equal(obj, target_elt, 'idp_entity_id') + self.check_param_equal(obj, target_elt, 'idp_sign_on_url') + self.check_param_equal(obj, target_elt, 'idp_groups_attribute') + self.check_param_equal(obj, target_elt, 'idp_x509_cert') + + self.check_param_equal(obj, target_elt, 'sp_base_url') + + self.check_param_equal(obj, target_elt, 'custom_conf') + + ############## + # test validation + # + def test_entity_id_required_if_metadata_unset(self): + """ test not applying if not composite requirement fulfilled """ + obj = dict(enable=True, sp_base_url="https://pfSense.local") + self.do_module_test(obj, state=None, failed=True, msg="idp_entity_id is required when idp_metadata_url is unset") + + def test_sign_on_url_required_if_metadata_unset(self): + """ test not applying if not composite requirement fulfilled """ + obj = dict(enable=True, sp_base_url="https://pfSense.local", idp_entity_id="https://keycloak.local/realms/master") + self.do_module_test(obj, state=None, failed=True, msg="idp_sign_on_url is required when idp_metadata_url is unset") + + def test_x509_cert_required_if_metadata_unset(self): + """ test not applying if not composite requirement fulfilled """ + obj = dict(enable=True, sp_base_url="https://pfSense.local", idp_entity_id="https://keycloak.local/realms/master", idp_sign_on_url="https://keycloak.local/realms/master/protocol/saml") + self.do_module_test(obj, state=None, failed=True, msg="idp_x509_cert is required when idp_metadata_url is unset") + + def test_composite_requirement_fulfilled_when_metadata_unset(self): + """ test not applying if not composite requirement fulfilled """ + obj = dict(enable=True, sp_base_url="https://pfSense.local", idp_entity_id="https://keycloak.local/realms/master", idp_sign_on_url="https://keycloak.local/realms/master/protocol/saml", idp_x509_cert="-----BEGIN CERTIFICATE-----\nSOME_CERT\n-----END CERTIFICATE-----") + self.do_module_test(obj, state=None, changed=True, command="update saml2-auth 'https://pfSense.local' set idp_metadata_url='', idp_entity_id='https://keycloak.local/realms/master', idp_sign_on_url='https://keycloak.local/realms/master/protocol/saml', idp_x509_cert='-----BEGIN CERTIFICATE-----\nSOME_CERT\n-----END CERTIFICATE-----'") + + def test_entity_id_update_noop(self): + """ test not applying entity id with invalid characters """ + obj = CURRENT_CONFIG | dict(idp_entity_id="£") + self.do_module_test(obj, state=None, failed=True, msg="idp_entity_id contains invalid characters") + + def test_entity_id_update_noop(self): + """ test not applying too long entity id """ + obj = CURRENT_CONFIG | dict(idp_entity_id="x" * 1025) + self.do_module_test(obj, state=None, failed=True, msg="idp_entity_id must be less than 1025 characters long") + + def test_x509_cert_update_noop(self): + """ test not applying invalid x509 cert """ + obj = CURRENT_CONFIG | dict(idp_x509_cert="NOT_A_VALID_CERT") + self.do_module_test(obj, state=None, failed=True, msg="idp_x509_cert is missing BEGIN and/or END tags") + + def test_invalid_url_update_noop(self): + """ test not appling an invalid url """ + obj = CURRENT_CONFIG | dict(sp_base_url="test.com") + self.do_module_test(obj, state=None, failed=True, msg="sp_base_url is not a valid URL") + + def test_invalid_json_update_noop(self): + """ test not appling an invalid url """ + obj = CURRENT_CONFIG | dict(custom_conf="invalid_json") + self.do_module_test(obj, state=None, failed=True, msg="custom_conf is not valid JSON") + + ############## + # test apply + # + def test_updated_settings(self): + """ test updating config """ + obj = CURRENT_CONFIG | dict(idp_groups_attribute="memberOf") + self.do_module_test(obj, state=None, changed=True, command="update saml2-auth 'https://pfSense.local' set idp_groups_attribute='memberOf'") + + def test_nochange(self): + """ test applying same config as set """ + obj = CURRENT_CONFIG + self.do_module_test(obj, state=None, changed=False)