feat(validation): add a strict validate_inputs action for user input
This commit is contained in:
@@ -11,3 +11,4 @@ vars_proxmox.yml
|
||||
.sisyphus/
|
||||
|
||||
.ansible/
|
||||
__pycache__/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[defaults]
|
||||
hash_behaviour = merge
|
||||
roles_path = roles
|
||||
action_plugins = plugins/action
|
||||
interpreter_python = auto_silent
|
||||
deprecation_warnings = False
|
||||
host_key_checking = False
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Strict validation of user-facing variable dicts against role argument specs.
|
||||
|
||||
Each spec file's ``argument_specs.inputs.options`` declares top-level variables. Every one the host
|
||||
defines is checked without Ansible's lenient coercion: a string is never a list, bool or dict. A dict
|
||||
option whose only sub-option is named ``<name>`` is a collection keyed by name; each entry is checked
|
||||
against that sub-option, and a null entry means the entry is not managed. Messages name the key path
|
||||
and the types involved but never a value, so secrets cannot leak into job output.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.parsing.vault import EncryptedString
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
KEYED = "<name>"
|
||||
SPEC_KEYS = {"type", "required", "default", "choices", "elements", "options", "description", "no_log"}
|
||||
UNRESOLVED = object()
|
||||
|
||||
# Numbers pass where a string is declared and digit strings where an int is: the roles read those
|
||||
# through `| string` and `| int` unchanged. A string posing as a list, bool or dict never passes.
|
||||
TYPES = {
|
||||
"str": lambda v: isinstance(v, (str, int, float, EncryptedString)) and not isinstance(v, bool),
|
||||
"int": lambda v: (isinstance(v, int) and not isinstance(v, bool)) or (isinstance(v, str) and v.isdigit()),
|
||||
"float": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
||||
"bool": lambda v: isinstance(v, bool),
|
||||
"list": lambda v: isinstance(v, list),
|
||||
"dict": lambda v: isinstance(v, Mapping),
|
||||
"raw": lambda _: True,
|
||||
}
|
||||
|
||||
|
||||
def _kind(value):
|
||||
if value is None:
|
||||
return "null"
|
||||
# bool before int: bool is an int subclass.
|
||||
for name, cls in (("bool", bool), ("dict", Mapping), ("str", (str, EncryptedString)), ("int", int),
|
||||
("float", float), ("list", list)):
|
||||
if isinstance(value, cls):
|
||||
return name
|
||||
return type(value).__name__
|
||||
|
||||
|
||||
def _check_spec(options, path):
|
||||
for key, spec in options.items():
|
||||
unknown = set(spec) - SPEC_KEYS
|
||||
if unknown:
|
||||
raise AnsibleError(f"{path}.{key}: unsupported spec attributes {', '.join(sorted(unknown))}")
|
||||
if spec.get("type", "str") not in TYPES:
|
||||
raise AnsibleError(f"{path}.{key}: unsupported spec type {spec['type']}")
|
||||
if "options" in spec:
|
||||
_check_spec(spec["options"], f"{path}.{key}")
|
||||
|
||||
|
||||
class _Validator:
|
||||
def __init__(self, templar):
|
||||
self.templar = templar
|
||||
self.errors = []
|
||||
|
||||
def resolve(self, value):
|
||||
# Task vars arrive untemplated. Render one node at a time so a vaulted leaf is never decrypted;
|
||||
# a node that cannot render here (no vault secret, a runtime-only variable) is left unchecked.
|
||||
if isinstance(value, str) and self.templar.is_template(value):
|
||||
try:
|
||||
return self.templar.template(value)
|
||||
except AnsibleError:
|
||||
return UNRESOLVED
|
||||
return value
|
||||
|
||||
def check(self, spec, value, path):
|
||||
value = self.resolve(value)
|
||||
if value is UNRESOLVED:
|
||||
return
|
||||
kind = spec.get("type", "str")
|
||||
if not TYPES[kind](value):
|
||||
self.errors.append(f"{path}: expected {kind}, got {_kind(value)}")
|
||||
return
|
||||
if "choices" in spec and value not in spec["choices"]:
|
||||
shown = "" if spec.get("no_log") else f", got {value!r}"
|
||||
self.errors.append(f"{path}: must be one of {', '.join(map(str, spec['choices']))}{shown}")
|
||||
if kind == "dict" and "options" in spec:
|
||||
self.check_options(spec["options"], value, path)
|
||||
elif kind == "list" and "elements" in spec:
|
||||
element = {"type": spec["elements"]}
|
||||
if "options" in spec:
|
||||
element["options"] = spec["options"]
|
||||
for index, item in enumerate(value):
|
||||
self.check(element, item, f"{path}[{index}]")
|
||||
|
||||
def check_options(self, options, value, path):
|
||||
if set(options) == {KEYED}:
|
||||
for name, entry in value.items():
|
||||
if entry is not None:
|
||||
self.check(options[KEYED], entry, f"{path}.{name}")
|
||||
return
|
||||
for key in value:
|
||||
if key not in options:
|
||||
self.errors.append(f"{path}.{key}: unsupported key, {path} accepts {', '.join(sorted(options))}")
|
||||
for key, spec in options.items():
|
||||
if key in value:
|
||||
self.check(spec, value[key], f"{path}.{key}")
|
||||
elif spec.get("required"):
|
||||
self.errors.append(f"{path}.{key}: required key is missing")
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
_requires_connection = False
|
||||
|
||||
def run(self, tmp=None, task_vars=None):
|
||||
result = super().run(tmp, task_vars)
|
||||
task_vars = task_vars or {}
|
||||
validator = _Validator(self._templar)
|
||||
for path in self._task.args["specs"]:
|
||||
options = self._loader.load_from_file(path)["argument_specs"]["inputs"]["options"]
|
||||
_check_spec(options, path)
|
||||
for name, spec in options.items():
|
||||
if name in task_vars:
|
||||
validator.check(spec, task_vars[name], name)
|
||||
elif spec.get("required"):
|
||||
validator.errors.append(f"{name}: required variable is not defined")
|
||||
if validator.errors:
|
||||
result.update(failed=True, errors=validator.errors,
|
||||
msg="Invalid input variables:\n" + "\n".join(validator.errors))
|
||||
else:
|
||||
result.update(changed=False, msg="All input variables are valid")
|
||||
return result
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: "Validate the fixture ({{ inf.name }})"
|
||||
block:
|
||||
- name: "Run the validator ({{ inf.name }})"
|
||||
validate_inputs:
|
||||
specs:
|
||||
- "{{ playbook_dir }}/fixture_spec.yml"
|
||||
vars:
|
||||
fixture: "{{ inf.value }}"
|
||||
register: inputs_result
|
||||
rescue:
|
||||
- name: "Keep the failed result ({{ inf.name }})"
|
||||
ansible.builtin.set_fact:
|
||||
inputs_result: "{{ ansible_failed_result }}"
|
||||
|
||||
- name: "Assert the reported violations ({{ inf.name }})"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (inputs_result.errors | default([])) | sort == inf.expect | sort
|
||||
- "'topsecret-value' not in (inputs_result | to_json)"
|
||||
fail_msg: "[inputs {{ inf.name }}] expected {{ inf.expect }}, got {{ inputs_result.errors | default([]) }}"
|
||||
success_msg: "[inputs {{ inf.name }}] OK"
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
argument_specs:
|
||||
inputs:
|
||||
options:
|
||||
fixture:
|
||||
type: dict
|
||||
options:
|
||||
name:
|
||||
type: str
|
||||
required: true
|
||||
enabled:
|
||||
type: bool
|
||||
count:
|
||||
type: int
|
||||
servers:
|
||||
type: list
|
||||
elements: str
|
||||
kind:
|
||||
type: str
|
||||
choices: [a, b]
|
||||
secret:
|
||||
type: str
|
||||
no_log: true
|
||||
disks:
|
||||
type: list
|
||||
elements: dict
|
||||
options:
|
||||
size:
|
||||
type: int
|
||||
required: true
|
||||
users:
|
||||
type: dict
|
||||
options:
|
||||
"<name>":
|
||||
type: dict
|
||||
options:
|
||||
password:
|
||||
type: str
|
||||
no_log: true
|
||||
keys:
|
||||
type: list
|
||||
elements: str
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
# Vaulted with a discarded password: the validator must handle a value it cannot decrypt.
|
||||
fixture:
|
||||
name: web01
|
||||
secret: !vault |
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
62363835313830363035333361613236373662303039376639396136336438616331336565393631
|
||||
6637376163346430386663393661386561316537306662350a663563326164376164396230616361
|
||||
33646564346532643735396239626133333162353839666135343030643836313938363466383439
|
||||
3130353636666365310a353064373139323836373163336563353861356333316534386238333664
|
||||
3663
|
||||
servers: !vault |
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
65626131373433633432363261356534613135386461626535393539656631323531346661643233
|
||||
3961366535643630386462303038636231373239343130310a343565336461633532316136643139
|
||||
30306461616232663763633630353661376130323536346637633964373239343664303533396236
|
||||
6165623262633131330a303965643439626335663130376237393362363231346537643334633163
|
||||
3835
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
inputs_fixtures:
|
||||
- name: valid
|
||||
value:
|
||||
name: web01
|
||||
enabled: true
|
||||
count: "3"
|
||||
servers: [10.0.0.1, 10.0.0.2]
|
||||
kind: a
|
||||
disks:
|
||||
- size: 30
|
||||
users:
|
||||
alice:
|
||||
password: pw
|
||||
keys: [ssh-ed25519 AAAA alice]
|
||||
retired: ~
|
||||
expect: []
|
||||
|
||||
- name: list-for-dict
|
||||
value:
|
||||
name: web01
|
||||
users:
|
||||
- name: alice
|
||||
expect: ["fixture.users: expected dict, got list"]
|
||||
|
||||
- name: comma-string-for-list
|
||||
value:
|
||||
name: web01
|
||||
servers: 10.0.0.1,10.0.0.2
|
||||
expect: ["fixture.servers: expected list, got str"]
|
||||
|
||||
- name: yes-string-for-bool
|
||||
value:
|
||||
name: web01
|
||||
enabled: "yes"
|
||||
expect: ["fixture.enabled: expected bool, got str"]
|
||||
|
||||
- name: typo-key
|
||||
value:
|
||||
name: web01
|
||||
userz: {}
|
||||
expect: ["fixture.userz: unsupported key, fixture accepts count, disks, enabled, kind, name, secret, servers, users"]
|
||||
|
||||
- name: wrong-element-type
|
||||
value:
|
||||
name: web01
|
||||
disks:
|
||||
- size: 30
|
||||
- size: big
|
||||
expect: ["fixture.disks[1].size: expected int, got str"]
|
||||
|
||||
- name: missing-required
|
||||
value:
|
||||
enabled: false
|
||||
expect: ["fixture.name: required key is missing"]
|
||||
|
||||
- name: choice-mismatch
|
||||
value:
|
||||
name: web01
|
||||
kind: c
|
||||
expect: ["fixture.kind: must be one of a, b, got 'c'"]
|
||||
|
||||
- name: secrets-never-echoed
|
||||
value:
|
||||
name: web01
|
||||
secret: [topsecret-value]
|
||||
users:
|
||||
alice:
|
||||
password: [topsecret-value]
|
||||
expect:
|
||||
- "fixture.secret: expected str, got list"
|
||||
- "fixture.users.alice.password: expected str, got list"
|
||||
|
||||
- name: keyed-entry-not-a-dict
|
||||
value:
|
||||
name: web01
|
||||
users:
|
||||
alice: pw
|
||||
expect: ["fixture.users.alice: expected dict, got str"]
|
||||
|
||||
- name: two-violations-at-once
|
||||
value:
|
||||
name: web01
|
||||
enabled: "true"
|
||||
userz: {}
|
||||
expect:
|
||||
- "fixture.userz: unsupported key, fixture accepts count, disks, enabled, kind, name, secret, servers, users"
|
||||
- "fixture.enabled: expected bool, got str"
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
# Run: ansible-playbook tests/inputs/test_inputs.yml
|
||||
- name: Strict input validator fixture tests
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
connection: local
|
||||
vars_files:
|
||||
- inputs_fixtures.yml
|
||||
tasks:
|
||||
- name: Run each input fixture
|
||||
ansible.builtin.include_tasks: _assert_inputs.yml
|
||||
loop: "{{ inputs_fixtures }}"
|
||||
loop_control:
|
||||
loop_var: inf
|
||||
label: "{{ inf.name }}"
|
||||
|
||||
- name: Validate a host var holding values that cannot be decrypted
|
||||
block:
|
||||
- name: Run the validator on the vaulted host var
|
||||
validate_inputs:
|
||||
specs:
|
||||
- "{{ playbook_dir }}/fixture_spec.yml"
|
||||
register: inputs_vault_result
|
||||
rescue:
|
||||
- name: Keep the failed vault result
|
||||
ansible.builtin.set_fact:
|
||||
inputs_vault_result: "{{ ansible_failed_result }}"
|
||||
|
||||
- name: Assert a vaulted string passes and a vaulted non-list is reported
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
inputs_vault_result.errors | default([]) == ['fixture.servers: expected list, got str']
|
||||
fail_msg: "[inputs vault] got {{ inputs_vault_result.errors | default([]) }}"
|
||||
success_msg: "[inputs vault] OK"
|
||||
Reference in New Issue
Block a user