diff --git a/meta/argument_specs.yml b/meta/argument_specs.yml new file mode 100644 index 0000000..652a270 --- /dev/null +++ b/meta/argument_specs.yml @@ -0,0 +1,278 @@ +# SPDX-License-Identifier: MIT +--- +argument_specs: + main: + short_description: The sudo role. + description: > + The sudo role configures sudo by managing sudoers files. + It can manage the main `/etc/sudoers` file as well as + additional files in included directories such as + `/etc/sudoers.d`. The role supports configuring defaults, + user specifications, aliases, default overrides, and + include directives. + options: + sudo_rewrite_default_sudoers_file: + type: bool + default: true + description: > + Whether to overwrite the distribution-supplied + `/etc/sudoers` file with the role configuration. + Set to `false` to deploy only to included directories + without modifying `/etc/sudoers`. + sudo_remove_unauthorized_included_files: + type: bool + default: false + description: > + Whether to remove existing sudoers files in the + configured include directories that are not defined + in `sudo_sudoers_files`. This enforces a desired + state but is potentially dangerous. + sudo_check_if_configured: + type: bool + default: true + description: > + Whether to perform a semantic check of the already + configured sudoers and skip the role if the + configuration matches. + sudo_visudo_path: + type: path + default: /usr/sbin/visudo + description: > + Fully-qualified path to the `visudo` binary used to + validate sudoers configuration changes. + sudo_transactional_update_reboot_ok: + type: raw + default: null + description: > + Whether to allow reboots required by transactional + updates. Set to `true` to allow automatic reboots, + `false` to skip the reboot and allow custom handling, + or `null` to fail when a reboot is needed. + sudo_sudoers_files: + type: list + elements: dict + description: > + List of sudoers file configurations to deploy. Each + item defines the content and target path of a sudoers + file. + options: + path: + type: str + required: true + description: > + Filesystem path where the sudoers configuration + file is deployed, for example `/etc/sudoers` or + `/etc/sudoers.d/custom`. + defaults: + type: list + elements: raw + description: > + List of sudoers `Defaults` entries. Each item is + either a string such as `"!visiblepw"` or + `always_set_home`, or a mapping for options that + take lists of values such as + `secure_path: [/sbin, /bin]` or + `env_keep: [LANG, LC_ALL]`. + user_specifications: + type: list + elements: dict + description: > + List of user specification entries for the + sudoers file. Each entry maps users, hosts, and + commands with optional operators, SELinux + contexts, Solaris privileges, and tags. + options: + users: + type: list + elements: str + description: > + List of users or user aliases the + specification applies to. + hosts: + type: list + elements: str + description: > + List of hosts or host aliases the + specification applies to. + operators: + type: list + elements: str + description: > + List of runas operators or runas aliases + for the specification. + commands: + type: list + elements: str + description: > + List of commands or command aliases the + specification permits. + selinux_role: + type: raw + description: > + SELinux role for the user specification. + Accepts a string value. For historical + reasons a list is also accepted, but only + the first element is used. + selinux_type: + type: raw + description: > + SELinux type for the user specification. + Accepts a string value. For historical + reasons a list is also accepted, but only + the first element is used. + solaris_privs: + type: list + elements: str + description: > + List of Solaris privileges to assign via + the `PRIVS` tag. + solaris_limitprivs: + type: list + elements: str + description: > + List of Solaris limit privileges to assign + via the `LIMITPRIVS` tag. + tags: + type: list + elements: str + description: > + List of tags to apply to the user + specification, such as `NOPASSWD` or + `SETENV`. + default_overrides: + type: list + elements: dict + description: > + List of default override specifications. Each + entry overrides `Defaults` values for a specific + user, runas operator, host, or command scope. + options: + defaults: + type: list + elements: str + description: > + List of defaults to override. + type: + type: str + choices: + - command + - host + - runas + - user + description: > + The scope of the default override. Determines + which sudoers operator is used: `user` maps + to `:`, `runas` to `>`, `host` to `@`, and + `command` to `!`. + commands: + type: list + elements: str + description: > + List of commands for the override. Used when + `type` is `command`. + hosts: + type: list + elements: str + description: > + List of hosts for the override. Used when + `type` is `host`. + operators: + type: list + elements: str + description: > + List of runas operators for the override. + Used when `type` is `runas`. + users: + type: list + elements: str + description: > + List of users for the override. Used when + `type` is `user`. + include_files: + type: list + elements: path + description: > + List of fully-qualified paths to include via the + `#include` directive in the sudoers file. + include_directories: + type: list + elements: path + description: > + List of fully-qualified paths to directories to + include via the `#includedir` directive in the + sudoers file. + aliases: + type: dict + description: > + Dictionary of alias definitions for the sudoers + file. Supports `user_alias`, `runas_alias`, + `host_alias`, and `cmnd_alias` keys. + options: + user_alias: + type: list + elements: dict + description: > + List of `User_Alias` definitions. + options: + name: + type: str + required: true + description: > + The alias name. + users: + type: list + elements: str + required: true + description: > + List of users in the alias. + runas_alias: + type: list + elements: dict + description: > + List of `Runas_Alias` definitions. + options: + name: + type: str + required: true + description: > + The alias name. + users: + type: list + elements: str + required: true + description: > + List of users in the alias. + host_alias: + type: list + elements: dict + description: > + List of `Host_Alias` definitions. + options: + name: + type: str + required: true + description: > + The alias name. + hosts: + type: list + elements: str + required: true + description: > + List of hosts in the alias. + cmnd_alias: + type: list + elements: dict + description: > + List of `Cmnd_Alias` definitions. + options: + name: + type: str + required: true + description: > + The alias name. + commands: + type: list + elements: str + required: true + description: > + List of commands in the alias. diff --git a/tasks/assert_role_vars.yml b/tasks/assert_role_vars.yml new file mode 100644 index 0000000..3765080 --- /dev/null +++ b/tasks/assert_role_vars.yml @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: MIT +--- +- name: Assert sudo_transactional_update_reboot_ok is null or a boolean + ansible.builtin.assert: + that: + - >- + (sudo_transactional_update_reboot_ok is none) + or (sudo_transactional_update_reboot_ok is sameas true) + or (sudo_transactional_update_reboot_ok is sameas false) + fail_msg: >- + sudo_transactional_update_reboot_ok must be null or a boolean, + got {{ sudo_transactional_update_reboot_ok | type_debug }} + +- name: Assert defaults items are strings or mappings + ansible.builtin.assert: + that: + - >- + item is string + or item is mapping + fail_msg: >- + sudo_sudoers_files defaults items must be strings or + mappings, got {{ item | type_debug }} + loop: "{{ sudo_sudoers_files | selectattr('defaults', 'defined') + | map(attribute='defaults') | flatten | list }}" + loop_control: + label: "{{ item if item is string else + (item.keys() | list | first if item is mapping + else item | string) }}" + +- name: Assert selinux_role is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.selinux_role is string + or (item.selinux_role is sequence + and item.selinux_role is not mapping + and item.selinux_role | reject('string') | list + | length == 0) + fail_msg: >- + selinux_role in user_specifications must be a string or + list of strings, got {{ item.selinux_role | type_debug }} + loop: "{{ sudo_sudoers_files + | selectattr('user_specifications', 'defined') + | map(attribute='user_specifications') | flatten | list }}" + loop_control: + label: "{{ item.users | d(['unnamed']) | first }}" + when: item.selinux_role is defined + +- name: Assert selinux_type is a string or list of strings + ansible.builtin.assert: + that: + - >- + item.selinux_type is string + or (item.selinux_type is sequence + and item.selinux_type is not mapping + and item.selinux_type | reject('string') | list + | length == 0) + fail_msg: >- + selinux_type in user_specifications must be a string or + list of strings, got {{ item.selinux_type | type_debug }} + loop: "{{ sudo_sudoers_files + | selectattr('user_specifications', 'defined') + | map(attribute='user_specifications') | flatten | list }}" + loop_control: + label: "{{ item.users | d(['unnamed']) | first }}" + when: item.selinux_type is defined diff --git a/tasks/main.yml b/tasks/main.yml index 98d3207..05e48e4 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -2,6 +2,9 @@ - name: Set version specific variables include_tasks: set_vars.yml +- name: Validate role parameters + ansible.builtin.include_tasks: assert_role_vars.yml + - name: Scan sudoers scan_sudoers: output_parsed_configs: true diff --git a/tests/tests_invalid_input.yml b/tests/tests_invalid_input.yml new file mode 100644 index 0000000..481635f --- /dev/null +++ b/tests/tests_invalid_input.yml @@ -0,0 +1,347 @@ +# SPDX-License-Identifier: MIT +--- +- name: Verify invalid parameters are rejected + hosts: all + tasks: + - name: Back up sudoers configuration + ansible.builtin.include_tasks: tasks/setup.yml + + - name: Run invalid input tests + block: + # ==================================================== + # Section 1: Verify role works with valid defaults + # ==================================================== + - name: Run role with valid defaults + ansible.builtin.include_tasks: tasks/run_role_with_clear_facts.yml + + # ==================================================== + # Section 2: argument_specs validation (Ansible 2.10+) + # ==================================================== + - name: Run argument specs validation tests + when: ansible_version.full is version('2.10', '>=') + block: + # --- Test: non-bool sudo_rewrite_default_sudoers_file --- + - name: Argument specs reject non-bool + sudo_rewrite_default_sudoers_file + block: + - name: Run role with string + sudo_rewrite_default_sudoers_file + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_rewrite_default_sudoers_file: not_a_bool + rescue: + - name: Mark invalid rewrite type rejected + ansible.builtin.set_fact: + __invalid_input_rewrite_type_failed: true + when: >- + 'sudo_rewrite_default_sudoers_file' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid sudo_rewrite_default_sudoers_file type + was rejected + ansible.builtin.assert: + that: + - __invalid_input_rewrite_type_failed | default(false) + fail_msg: >- + argument_specs should reject + sudo_rewrite_default_sudoers_file when not a boolean + + # --- Test: non-list sudo_sudoers_files --- + - name: Argument specs reject non-list sudo_sudoers_files + block: + - name: Run role with string sudo_sudoers_files + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: not_a_list + rescue: + - name: Mark invalid sudoers_files type rejected + ansible.builtin.set_fact: + __invalid_input_sudoers_files_type_failed: true + when: >- + 'sudo_sudoers_files' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid sudo_sudoers_files type was rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_sudoers_files_type_failed + | default(false) + fail_msg: >- + argument_specs should reject sudo_sudoers_files when + not a list + + # --- Test: missing required path in sudo_sudoers_files --- + - name: Argument specs reject missing path in + sudo_sudoers_files + block: + - name: Run role with sudo_sudoers_files missing path + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - defaults: + - env_reset + rescue: + - name: Mark missing path rejected + ansible.builtin.set_fact: + __invalid_input_missing_path_failed: true + when: >- + 'missing required arguments' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert missing path in sudo_sudoers_files was + rejected + ansible.builtin.assert: + that: + - __invalid_input_missing_path_failed | default(false) + fail_msg: >- + argument_specs should reject sudo_sudoers_files + entries without the required path field + + # --- Test: invalid choice for default_overrides type --- + - name: Argument specs reject invalid default_overrides + type choice + block: + - name: Run role with invalid default_overrides type + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - path: /etc/sudoers + default_overrides: + - type: invalid_type + defaults: + - env_reset + users: + - root + rescue: + - name: Mark invalid override type choice rejected + ansible.builtin.set_fact: + __invalid_input_override_type_choice_failed: true + when: >- + 'invalid_type' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert invalid default_overrides type choice was + rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_override_type_choice_failed + | default(false) + fail_msg: >- + argument_specs should reject default_overrides type + value 'invalid_type' + + # --- Test: missing required name in user_alias --- + - name: Argument specs reject missing name in user_alias + block: + - name: Run role with user_alias missing name + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - path: /etc/sudoers + aliases: + user_alias: + - users: + - testuser + rescue: + - name: Mark missing alias name rejected + ansible.builtin.set_fact: + __invalid_input_missing_alias_name_failed: true + when: >- + 'missing required arguments' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert missing name in user_alias was rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_missing_alias_name_failed + | default(false) + fail_msg: >- + argument_specs should reject user_alias entries + without the required name field + + # ==================================================== + # Section 3: assert_role_vars validation (all versions) + # ==================================================== + + # --- Test: sudo_transactional_update_reboot_ok as string --- + - name: Assert rejects transactional_update_reboot_ok as string + block: + - name: Run role with transactional_update_reboot_ok + as string + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_transactional_update_reboot_ok: not_a_bool + rescue: + - name: Mark string transactional_update_reboot_ok rejected + ansible.builtin.set_fact: + __invalid_input_reboot_ok_string_failed: true + when: >- + 'sudo_transactional_update_reboot_ok' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert transactional_update_reboot_ok as string was + rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_reboot_ok_string_failed + | default(false) + fail_msg: >- + assert_role_vars should reject + sudo_transactional_update_reboot_ok when given a string + + # --- Test: sudo_transactional_update_reboot_ok as integer --- + - name: Assert rejects transactional_update_reboot_ok + as integer + block: + - name: Run role with transactional_update_reboot_ok + as integer + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_transactional_update_reboot_ok: 42 + rescue: + - name: Mark integer transactional_update_reboot_ok + rejected + ansible.builtin.set_fact: + __invalid_input_reboot_ok_int_failed: true + when: >- + 'sudo_transactional_update_reboot_ok' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert transactional_update_reboot_ok as integer was + rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_reboot_ok_int_failed + | default(false) + fail_msg: >- + assert_role_vars should reject + sudo_transactional_update_reboot_ok when given + an integer + + # --- Test: defaults item as integer --- + - name: Assert rejects defaults item as integer + block: + - name: Run role with defaults item as integer + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - path: /etc/sudoers + defaults: + - 12345 + rescue: + - name: Mark integer defaults item rejected + ansible.builtin.set_fact: + __invalid_input_defaults_int_failed: true + when: >- + 'defaults items must be' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert defaults item as integer was rejected + ansible.builtin.assert: + that: + - __invalid_input_defaults_int_failed | default(false) + fail_msg: >- + assert_role_vars should reject defaults items that are + not strings or mappings + + # --- Test: selinux_role as integer --- + - name: Assert rejects selinux_role as integer + block: + - name: Run role with selinux_role as integer + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - path: /etc/sudoers + user_specifications: + - users: + - root + hosts: + - ALL + commands: + - ALL + selinux_role: 12345 + rescue: + - name: Mark integer selinux_role rejected + ansible.builtin.set_fact: + __invalid_input_selinux_role_int_failed: true + when: >- + 'selinux_role' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert selinux_role as integer was rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_selinux_role_int_failed + | default(false) + fail_msg: >- + assert_role_vars should reject selinux_role when given + an integer + + # --- Test: selinux_type as boolean --- + - name: Assert rejects selinux_type as boolean + block: + - name: Run role with selinux_type as boolean + ansible.builtin.include_tasks: + tasks/run_role_with_clear_facts.yml + vars: + sudo_sudoers_files: + - path: /etc/sudoers + user_specifications: + - users: + - root + hosts: + - ALL + commands: + - ALL + selinux_type: true + rescue: + - name: Mark boolean selinux_type rejected + ansible.builtin.set_fact: + __invalid_input_selinux_type_bool_failed: true + when: >- + 'selinux_type' in + (ansible_failed_result | default({}) | to_json) + + - name: Assert selinux_type as boolean was rejected + ansible.builtin.assert: + that: + - >- + __invalid_input_selinux_type_bool_failed + | default(false) + fail_msg: >- + assert_role_vars should reject selinux_type when given + a boolean + + always: + - name: Restore sudoers configuration + ansible.builtin.include_tasks: tasks/cleanup.yml + tags: tests::cleanup + + - name: Clear test facts + ansible.builtin.set_fact: + __invalid_input_rewrite_type_failed: + __invalid_input_sudoers_files_type_failed: + __invalid_input_missing_path_failed: + __invalid_input_override_type_choice_failed: + __invalid_input_missing_alias_name_failed: + __invalid_input_reboot_ok_string_failed: + __invalid_input_reboot_ok_int_failed: + __invalid_input_defaults_int_failed: + __invalid_input_selinux_role_int_failed: + __invalid_input_selinux_type_bool_failed: + tags: tests::cleanup