Some more hardening to not process raw jinja inside salt/ansible cmd. But, I think this is the end of the road
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 57s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2026-06-22 20:26:06 +10:00
parent c3c3608049
commit d96ad3dc02
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
9 changed files with 508 additions and 12 deletions

View file

@ -18,6 +18,7 @@ from .manifest_safety import (
iter_safe_artifact_files, iter_safe_artifact_files,
prepare_manifest_output_dir, prepare_manifest_output_dir,
) )
from .render_safety import ansible_unsafe_data
from .role_names import avoid_reserved_role_name from .role_names import avoid_reserved_role_name
from .state import inventory_packages_from_state, roles_from_state from .state import inventory_packages_from_state, roles_from_state
from .yamlutil import yaml_dump_mapping, yaml_load_mapping from .yamlutil import yaml_dump_mapping, yaml_load_mapping
@ -688,7 +689,7 @@ def _write_hostvars(site_root: str, fqdn: str, role: str, data: Dict[str, Any])
except Exception: except Exception:
existing_map = {} existing_map = {}
merged = _merge_mappings_overwrite(existing_map, data) merged = _merge_mappings_overwrite(existing_map, ansible_unsafe_data(data))
out = "---\n" + yaml_dump_mapping(merged, sort_keys=True) out = "---\n" + yaml_dump_mapping(merged, sort_keys=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@ -699,7 +700,7 @@ def _write_role_defaults(role_dir: str, mapping: Dict[str, Any]) -> None:
"""Overwrite role defaults/main.yml with the provided mapping.""" """Overwrite role defaults/main.yml with the provided mapping."""
defaults_path = os.path.join(role_dir, "defaults", "main.yml") defaults_path = os.path.join(role_dir, "defaults", "main.yml")
os.makedirs(os.path.dirname(defaults_path), exist_ok=True) os.makedirs(os.path.dirname(defaults_path), exist_ok=True)
out = "---\n" + yaml_dump_mapping(mapping, sort_keys=True) out = "---\n" + yaml_dump_mapping(ansible_unsafe_data(mapping), sort_keys=True)
with open(defaults_path, "w", encoding="utf-8") as f: with open(defaults_path, "w", encoding="utf-8") as f:
f.write(out) f.write(out)

View file

@ -20,6 +20,7 @@ from .manifest_safety import (
prepare_manifest_output_dir, prepare_manifest_output_dir,
safe_artifact_file, safe_artifact_file,
) )
from .render_safety import puppet_hiera_safe_data
from .state import inventory_packages_from_state, roles_from_state from .state import inventory_packages_from_state, roles_from_state
from .jinjaturtle import ( from .jinjaturtle import (
can_jinjify_path, can_jinjify_path,
@ -1586,7 +1587,9 @@ def _render_hiera_yaml() -> str:
def _write_yaml(path: Path, data: Dict[str, Any]) -> None: def _write_yaml(path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text( path.write_text(
yaml.safe_dump(data, sort_keys=True, explicit_start=True), yaml.safe_dump(
puppet_hiera_safe_data(data), sort_keys=True, explicit_start=True
),
encoding="utf-8", encoding="utf-8",
) )

232
enroll/render_safety.py Normal file
View file

@ -0,0 +1,232 @@
from __future__ import annotations
import json
import re
from collections.abc import Mapping, Set as AbstractSet
from typing import Any
ANSIBLE_JINJA_STARTS = ("{{", "{%", "{#")
class AnsibleUnsafeText(str):
"""String subclass dumped as Ansible's ``!unsafe`` YAML scalar.
Ansible templating can recursively evaluate Jinja delimiters that arrive
through variables/defaults. Harvested data is not authored playbook code;
values containing Jinja starts must be tagged as unsafe data before they are
written to Ansible variable files.
"""
def is_ansible_template_like(value: str) -> bool:
"""Return true if *value* contains a Jinja start delimiter."""
return any(marker in value for marker in ANSIBLE_JINJA_STARTS)
def ansible_unsafe_data(value: Any) -> Any:
"""Recursively mark template-looking harvested strings as Ansible data.
Keep ordinary strings untouched so generated output remains readable and so
existing tests/tools that use ``yaml.safe_load`` continue to work for normal
data. Mapping keys are also strings in Ansible data structures, so protect
keys as well as values.
"""
if isinstance(value, AnsibleUnsafeText):
return value
if isinstance(value, str):
return AnsibleUnsafeText(value) if is_ansible_template_like(value) else value
if isinstance(value, Mapping):
return {
ansible_unsafe_data(str(key)): ansible_unsafe_data(inner)
for key, inner in value.items()
}
if isinstance(value, list):
return [ansible_unsafe_data(item) for item in value]
if isinstance(value, tuple):
return [ansible_unsafe_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(ansible_unsafe_data(item) for item in value)
return value
def escape_puppet_hiera_interpolation(value: str) -> str:
"""Preserve literal ``%{`` text in Puppet Hiera data sources.
Hiera treats ``%{...}`` in data values as interpolation. Enroll's Hiera
data is generated from harvested values, not authored Hiera expressions, so
any literal interpolation opener is escaped with Hiera's documented
``literal('%')`` helper.
"""
return str(value).replace("%{", "%{literal('%')}{")
def puppet_hiera_safe_data(value: Any) -> Any:
"""Recursively escape Hiera interpolation openers in harvested data."""
if isinstance(value, Mapping):
return {
escape_puppet_hiera_interpolation(str(key)): puppet_hiera_safe_data(inner)
for key, inner in value.items()
}
if isinstance(value, list):
return [puppet_hiera_safe_data(item) for item in value]
if isinstance(value, tuple):
return [puppet_hiera_safe_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(puppet_hiera_safe_data(item) for item in value)
if isinstance(value, str):
return escape_puppet_hiera_interpolation(value)
return value
def _plain_json_data(value: Any) -> Any:
if isinstance(value, Mapping):
return {str(key): _plain_json_data(inner) for key, inner in value.items()}
if isinstance(value, list):
return [_plain_json_data(item) for item in value]
if isinstance(value, tuple):
return [_plain_json_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(_plain_json_data(item) for item in value)
return value
def _escape_braces_inside_json_strings(text: str) -> str:
"""Replace literal braces only while scanning JSON string tokens."""
out: list[str] = []
in_string = False
escaped = False
for ch in text:
if not in_string:
out.append(ch)
if ch == '"':
in_string = True
continue
if escaped:
out.append(ch)
escaped = False
elif ch == "\\":
out.append(ch)
escaped = True
elif ch == '"':
out.append(ch)
in_string = False
elif ch == "{":
out.append("\\u007b")
elif ch == "}":
out.append("\\u007d")
else:
out.append(ch)
return "".join(out)
def salt_sls_json_quote(value: Any) -> str:
"""Return a double-quoted YAML/JSON scalar safe for Salt's Jinja pass.
Salt state and pillar SLS files normally use the ``jinja|yaml`` renderer
pipeline. YAML/JSON quoting alone does not stop ``{{ ... }}``, ``{% ... %}``
or ``{# ... #}`` inside harvested values from being evaluated before YAML is
parsed. JSON/YAML double-quoted scalars decode ``\u007b`` and ``\u007d``
after Jinja has run, so encode braces inside string tokens as Unicode escapes.
"""
dumped = json.dumps(str(value), ensure_ascii=False)
return _escape_braces_inside_json_strings(dumped)
_PLAIN_YAML_KEY_RE = re.compile(r"^[A-Za-z0-9_./:-]+$")
def _salt_yaml_key(value: Any) -> str:
text = str(value)
if text and _PLAIN_YAML_KEY_RE.match(text) and not text.startswith(("-", "?", ":")):
return text
return salt_sls_json_quote(text)
def _salt_yaml_scalar(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
if isinstance(value, float):
return json.dumps(value, allow_nan=False)
return salt_sls_json_quote(value)
def _salt_yaml_lines(
value: Any, indent: int = 0, *, sort_keys: bool = True
) -> list[str]:
prefix = " " * indent
if isinstance(value, Mapping):
if not value:
return [prefix + "{}"]
keys = sorted(value, key=lambda item: str(item)) if sort_keys else list(value)
lines: list[str] = []
for key in keys:
inner = value[key]
key_text = _salt_yaml_key(key)
if isinstance(inner, Mapping):
if not inner:
lines.append(f"{prefix}{key_text}: {{}}")
else:
lines.append(f"{prefix}{key_text}:")
lines.extend(
_salt_yaml_lines(inner, indent + 2, sort_keys=sort_keys)
)
elif isinstance(inner, (list, tuple, set)):
seq = list(inner) if not isinstance(inner, set) else sorted(inner)
if not seq:
lines.append(f"{prefix}{key_text}: []")
else:
lines.append(f"{prefix}{key_text}:")
lines.extend(_salt_yaml_lines(seq, indent + 2, sort_keys=sort_keys))
else:
lines.append(f"{prefix}{key_text}: {_salt_yaml_scalar(inner)}")
return lines
if isinstance(value, (list, tuple, set)):
seq = list(value) if not isinstance(value, set) else sorted(value)
if not seq:
return [prefix + "[]"]
lines = []
for item in seq:
if isinstance(item, Mapping):
if not item:
lines.append(prefix + "- {}")
else:
lines.append(prefix + "-")
lines.extend(
_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys)
)
elif isinstance(item, (list, tuple, set)):
lines.append(prefix + "-")
lines.extend(_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys))
else:
lines.append(f"{prefix}- {_salt_yaml_scalar(item)}")
return lines
return [prefix + _salt_yaml_scalar(value)]
def salt_sls_yaml_dump(
value: Any,
*,
sort_keys: bool = True,
explicit_start: bool = False,
) -> str:
"""Dump block YAML whose string braces cannot form Salt Jinja delimiters."""
lines = _salt_yaml_lines(_plain_json_data(value), sort_keys=sort_keys)
rendered = "\n".join(lines).rstrip() + "\n"
if explicit_start:
rendered = "---\n" + rendered
return rendered

View file

@ -8,7 +8,6 @@ import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
import yaml
from .cm import ( from .cm import (
CMModule, CMModule,
@ -22,8 +21,9 @@ from .manifest_safety import (
prepare_manifest_output_dir, prepare_manifest_output_dir,
safe_artifact_file, safe_artifact_file,
) )
from .render_safety import salt_sls_json_quote, salt_sls_yaml_dump
from .state import inventory_packages_from_state, roles_from_state from .state import inventory_packages_from_state, roles_from_state
from .yamlutil import yaml_dump_mapping, yaml_load_mapping_file from .yamlutil import yaml_load_mapping_file
class SaltRole(CMModule): class SaltRole(CMModule):
@ -381,7 +381,7 @@ def _active_service_state_ids_by_unit(
def _yaml_quote(value: Any) -> str: def _yaml_quote(value: Any) -> str:
return json.dumps(str(value), ensure_ascii=False) return salt_sls_json_quote(value)
def _yaml_bool(value: Any) -> str: def _yaml_bool(value: Any) -> str:
@ -870,9 +870,7 @@ def _collect_salt_roles(
def _append_yaml_value(lines: List[str], key: str, value: Any, *, indent: int) -> None: def _append_yaml_value(lines: List[str], key: str, value: Any, *, indent: int) -> None:
prefix = " " * indent prefix = " " * indent
if isinstance(value, dict): if isinstance(value, dict):
dumped = yaml.safe_dump( dumped = salt_sls_yaml_dump(_plain_salt_data(value), sort_keys=True).rstrip()
_plain_salt_data(value), sort_keys=True, default_flow_style=False
).rstrip()
if not dumped: if not dumped:
lines.append(f"{prefix}- {key}: {{}}") lines.append(f"{prefix}- {key}: {{}}")
return return
@ -1501,7 +1499,7 @@ def _render_pillar_role(srole: SaltRole) -> str:
def _write_yaml(path: Path, data: Dict[str, Any]) -> None: def _write_yaml(path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text( path.write_text(
yaml_dump_mapping(data, sort_keys=True, explicit_start=True), salt_sls_yaml_dump(data, sort_keys=True, explicit_start=True),
encoding="utf-8", encoding="utf-8",
) )

View file

@ -5,6 +5,21 @@ from typing import Any, Dict, Mapping
import yaml import yaml
from .render_safety import AnsibleUnsafeText
class IndentedSafeLoader(yaml.SafeLoader): # type: ignore[misc]
"""PyYAML loader that understands Ansible's ``!unsafe`` tag."""
def _construct_ansible_unsafe(
loader: yaml.Loader, node: yaml.Node
) -> AnsibleUnsafeText:
return AnsibleUnsafeText(loader.construct_scalar(node))
IndentedSafeLoader.add_constructor("!unsafe", _construct_ansible_unsafe)
class IndentedSafeDumper(yaml.SafeDumper): # type: ignore[misc] class IndentedSafeDumper(yaml.SafeDumper): # type: ignore[misc]
"""PyYAML dumper that indents sequences under mapping keys.""" """PyYAML dumper that indents sequences under mapping keys."""
@ -17,10 +32,17 @@ class IndentedSafeDumper(yaml.SafeDumper): # type: ignore[misc]
def yaml_load_mapping(text: str) -> Dict[str, Any]: def yaml_load_mapping(text: str) -> Dict[str, Any]:
"""Load YAML text and return a mapping, or an empty mapping on failure.""" """Load YAML text and return a mapping, or an empty mapping on failure.
Enroll may re-read Ansible host_vars that contain ``!unsafe`` scalars
written during the same manifest operation, so the loader accepts that tag
while remaining otherwise based on PyYAML's SafeLoader.
"""
try: try:
obj = yaml.safe_load(text) obj = yaml.load(
text, Loader=IndentedSafeLoader
) # nosec B506 - subclasses yaml.SafeLoader; only adds !unsafe scalar support.
except Exception: except Exception:
return {} return {}
return obj if isinstance(obj, dict) else {} return obj if isinstance(obj, dict) else {}
@ -34,6 +56,15 @@ def yaml_load_mapping_file(path: Path) -> Dict[str, Any]:
return yaml_load_mapping(path.read_text(encoding="utf-8")) return yaml_load_mapping(path.read_text(encoding="utf-8"))
def _represent_ansible_unsafe(
dumper: yaml.Dumper, data: AnsibleUnsafeText
) -> yaml.Node:
return dumper.represent_scalar("!unsafe", str(data))
IndentedSafeDumper.add_representer(AnsibleUnsafeText, _represent_ansible_unsafe)
def yaml_dump_mapping( def yaml_dump_mapping(
obj: Mapping[str, Any], obj: Mapping[str, Any],
*, *,

View file

@ -2378,3 +2378,15 @@ def test_manifest_non_fqdn_refuses_existing_output(tmp_path: Path):
with pytest.raises(RuntimeError, match="already exists"): with pytest.raises(RuntimeError, match="already exists"):
manifest.manifest(str(bundle), str(out), no_common_roles=True) manifest.manifest(str(bundle), str(out), no_common_roles=True)
def test_yaml_dump_mapping_emits_ansible_unsafe_tag_for_marked_values():
from enroll.render_safety import ansible_unsafe_data
data = ansible_unsafe_data({"value": "{{ lookup('pipe','id') }}"})
dumped = yaml_helpers.yaml_dump_mapping(data)
assert "value: !unsafe" in dumped
assert "{{ lookup(''pipe'',''id'') }}" in dumped
loaded = yaml_helpers.yaml_load_mapping(dumped)
assert loaded["value"] == "{{ lookup('pipe','id') }}"

View file

@ -89,3 +89,74 @@ def test_ansible_role_normalises_package_snapshot():
assert role.files["/etc/curlrc"]["dest"] == "/etc/curlrc" assert role.files["/etc/curlrc"]["dest"] == "/etc/curlrc"
assert role.services == {} assert role.services == {}
assert role.origin_lines == ["package `curl` from role `curl`"] assert role.origin_lines == ["package `curl` from role `curl`"]
from pathlib import Path
from state_helpers import write_schema_state
from enroll import manifest, yamlutil as yaml_helpers
def _ansible_jinja_payload_state(payload: str) -> dict:
return {
"schema_version": 3,
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"roles": {
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": payload,
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": [],
}
],
"managed_dirs": [],
"managed_files": [],
"managed_links": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
},
}
def test_ansible_static_marks_harvested_jinja_values_unsafe(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="ansible")
defaults = out / "roles" / "users" / "defaults" / "main.yml"
text = defaults.read_text(encoding="utf-8")
assert "gecos: !unsafe" in text
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
loaded = yaml_helpers.yaml_load_mapping(text)
assert loaded["users_users"][0]["gecos"] == payload
def test_ansible_fqdn_marks_harvested_jinja_values_unsafe(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="ansible", fqdn="host.example.test")
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
text = hostvars.read_text(encoding="utf-8")
assert "gecos: !unsafe" in text
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
loaded = yaml_helpers.yaml_load_mapping(text)
assert loaded["users_users"][0]["gecos"] == payload

View file

@ -1408,3 +1408,72 @@ def test_manifest_puppet_user_gecos_with_newline_is_single_line(tmp_path: Path):
assert 'comment => "Real Name\\ntouch /tmp/pwned"' in init_pp assert 'comment => "Real Name\\ntouch /tmp/pwned"' in init_pp
# And there must be no line that is just the injected command. # And there must be no line that is just the injected command.
assert "\ntouch /tmp/pwned\n" not in init_pp assert "\ntouch /tmp/pwned\n" not in init_pp
def _puppet_hiera_payload_state(payload: str) -> dict:
return {
"schema_version": 3,
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"roles": {
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": payload,
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": [],
}
],
"managed_dirs": [],
"managed_files": [],
"managed_links": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
},
}
def test_manifest_puppet_static_quotes_template_like_harvested_values(
tmp_path: Path,
):
bundle = tmp_path / "bundle"
out = tmp_path / "puppet"
payload = "%{lookup('enroll::classes')}"
_write_state(bundle, _puppet_hiera_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="puppet")
init_pp = (out / "modules" / "users" / "manifests" / "init.pp").read_text(
encoding="utf-8"
)
assert "comment => '%{lookup(\\'enroll::classes\\')}'" in init_pp
def test_manifest_puppet_hiera_escapes_harvested_interpolation_tokens(
tmp_path: Path,
):
bundle = tmp_path / "bundle"
out = tmp_path / "puppet"
payload = "%{lookup('enroll::classes')}"
_write_state(bundle, _puppet_hiera_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="puppet", fqdn="node.example")
node_yaml = out / "data" / "nodes" / "node.example.yaml"
text = node_yaml.read_text(encoding="utf-8")
assert payload not in text
assert "%{literal(''%'')}{lookup(''enroll::classes'')}" in text
data = yaml.safe_load(text)
assert (
data["users::users"]["alice"]["comment"]
== "%{literal('%')}{lookup('enroll::classes')}"
)

View file

@ -964,3 +964,82 @@ def test_salt_names_are_sanitised_for_target_reserved_words() -> None:
assert _salt_name("123") == "role_123" assert _salt_name("123") == "role_123"
assert _salt_name("top") == "role_top" assert _salt_name("top") == "role_top"
assert _salt_name("web-app") == "web_app" assert _salt_name("web-app") == "web_app"
def test_manifest_salt_static_escapes_harvested_jinja_delimiters(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "salt"
state = _sample_state()
payload = "{{ salt['cmd.run']('touch /tmp/PWNED_BY_ENROLL_SALT') }}"
state["roles"]["users"]["users"][0]["gecos"] = payload
_write_sample_artifacts(bundle)
_write_state(bundle, state)
manifest.manifest(str(bundle), str(out), target="salt")
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
encoding="utf-8"
)
assert payload not in users_sls
assert "\\u007b\\u007b salt['cmd.run']" in users_sls
calls = []
class FakeCmd:
def run(self, command):
calls.append(command)
return "EXECUTED"
from jinja2 import Template
rendered = Template(users_sls).render(salt={"cmd.run": FakeCmd().run})
rendered_data = yaml.safe_load(rendered)
assert calls == []
user_state = next(
state
for state in rendered_data.values()
if isinstance(state, dict) and "user.present" in state
)
attrs = user_state["user.present"]
fullname = next(item["fullname"] for item in attrs if "fullname" in item)
assert fullname == payload
def test_manifest_salt_fqdn_escapes_harvested_jinja_delimiters_in_pillar(
tmp_path: Path,
):
bundle = tmp_path / "bundle"
out = tmp_path / "salt"
state = _sample_state()
payload = "{{ salt['cmd.run']('touch /tmp/PWNED_BY_ENROLL_SALT') }}"
state["roles"]["users"]["users"][0]["gecos"] = payload
_write_sample_artifacts(bundle)
_write_state(bundle, state)
manifest.manifest(str(bundle), str(out), target="salt", fqdn="node.example")
pillar_top = yaml.safe_load(
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
)
node_sls = pillar_top["base"]["node.example"][0]
pillar_path = out / "pillar" / Path(*node_sls.split("."))
text = pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
assert payload not in text
assert "\\u007b\\u007b salt['cmd.run']" in text
calls = []
class FakeCmd:
def run(self, command):
calls.append(command)
return "EXECUTED"
from jinja2 import Template
rendered = Template(text).render(salt={"cmd.run": FakeCmd().run})
rendered_data = yaml.safe_load(rendered)
assert calls == []
assert (
rendered_data["enroll"]["roles"]["users"]["users"]["alice"]["fullname"]
== payload
)