Skip to content

Commit b7d625c

Browse files
Move inject_credential from awx
1 parent c94b4d1 commit b7d625c

File tree

4 files changed

+511
-29
lines changed

4 files changed

+511
-29
lines changed

.flake8

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,20 @@ per-file-ignores =
110110
# additionally test docstrings don't need param lists (DAR, DCO020):
111111
tests/**.py: DAR, DCO020, S101, S105, S108, S404, S603, WPS202, WPS210, WPS430, WPS436, WPS441, WPS442, WPS450
112112

113+
src/awx_plugins/interfaces/_temporary_private_api.py: ANN001,ANN201,B950,C901,CCR001,D103,E800,LN001,LN002,Q003,WPS110,WPS111,WPS118,WPS125,WPS204,WPS210,WPS211,WPS213,WPS221,WPS226,WPS231,WPS232,WPS319,WPS323,WPS336,WPS337,WPS361,WPS421,WPS429,WPS430,WPS431,WPS436,WPS442,WPS503,WPS507,WPS516
114+
115+
116+
117+
118+
119+
120+
121+
122+
123+
124+
125+
126+
113127
# Count the number of occurrences of each error/warning code and print a report:
114128
statistics = true
115129

dependencies/direct/py.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ covdefaults
44
coverage # accessed directly from tox
55
coverage-enable-subprocess
66
hypothesis
7+
jinja2
78
pytest
89
pytest-cov
910
pytest-mock
1011
pytest-xdist
12+
pyyaml

src/awx_plugins/interfaces/_temporary_private_api.py

Lines changed: 252 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,51 +4,275 @@
44
The hope is that it will be refactored into something more standardized.
55
"""
66

7+
import os
8+
import re
9+
import stat
10+
import tempfile
711
from collections.abc import Callable
12+
from dataclasses import dataclass # noqa: WPS433
813

14+
from jinja2 import sandbox
15+
from yaml import safe_dump as yaml_safe_dump
16+
17+
from ._temporary_private_container_api import get_incontainer_path
918
from ._temporary_private_credential_api import ( # noqa: WPS436
1019
Credential as Credential,
1120
GenericOptionalPrimitiveType,
1221
)
1322

1423

15-
try:
16-
# pylint: disable-next=unused-import
17-
from awx.main.models.credential import ( # noqa: WPS433
18-
ManagedCredentialType as ManagedCredentialType,
24+
HIDDEN_PASSWORD = '*' * 10
25+
SENSITIVE_ENV_VAR_NAMES = 'API|TOKEN|KEY|SECRET|PASS'
26+
27+
HIDDEN_PASSWORD_RE = re.compile(SENSITIVE_ENV_VAR_NAMES, re.I)
28+
HIDDEN_URL_PASSWORD_RE = re.compile('^.*?://[^:]+:(.*?)@.*?$')
29+
30+
ENV_BLOCKLIST = frozenset(
31+
(
32+
'VIRTUAL_ENV',
33+
'PATH',
34+
'PYTHONPATH',
35+
'JOB_ID',
36+
'INVENTORY_ID',
37+
'INVENTORY_SOURCE_ID',
38+
'INVENTORY_UPDATE_ID',
39+
'AD_HOC_COMMAND_ID',
40+
'REST_API_URL',
41+
'REST_API_TOKEN',
42+
'MAX_EVENT_RES',
43+
'CALLBACK_QUEUE',
44+
'CALLBACK_CONNECTION',
45+
'CACHE',
46+
'JOB_CALLBACK_DEBUG',
47+
'INVENTORY_HOSTVARS',
48+
'AWX_HOST',
49+
'PROJECT_REVISION',
50+
'SUPERVISOR_CONFIG_PATH',
1951
)
20-
except ImportError: # FIXME: eventually, this should not exist
21-
from dataclasses import dataclass # noqa: WPS433
52+
)
53+
54+
def build_safe_env(
55+
env: dict[str, GenericOptionalPrimitiveType],
56+
) -> dict[str, GenericOptionalPrimitiveType]:
57+
"""Obscure potentially sensitive env values.
58+
59+
Given a set of environment variables, execute a set of heuristics to
60+
obscure potentially sensitive environment values.
61+
62+
:param env: Existing environment variables
63+
:returns: Sanitized environment variables.
64+
"""
65+
safe_env = dict(env)
66+
for env_k, env_val in safe_env.items():
67+
is_special = (
68+
env_k == 'AWS_ACCESS_KEY_ID'
69+
or (
70+
env_k.startswith('ANSIBLE_')
71+
and not env_k.startswith('ANSIBLE_NET')
72+
and not env_k.startswith('ANSIBLE_GALAXY_SERVER')
73+
)
74+
)
75+
if is_special:
76+
continue
77+
elif HIDDEN_PASSWORD_RE.search(env_k):
78+
safe_env[env_k] = HIDDEN_PASSWORD
79+
elif isinstance(env_val, str) and HIDDEN_URL_PASSWORD_RE.match(env_val):
80+
safe_env[env_k] = HIDDEN_URL_PASSWORD_RE.sub(
81+
HIDDEN_PASSWORD, env_val,
82+
)
83+
return safe_env
84+
85+
86+
@dataclass(frozen=True)
87+
class ManagedCredentialType:
88+
"""Managed credential type stub."""
89+
90+
namespace: str
91+
"""Plugin namespace."""
92+
93+
name: str
94+
"""Plugin name within the namespace."""
95+
96+
kind: str
97+
"""Plugin category."""
98+
99+
inputs: dict[str, list[dict[str, str | bool]]]
100+
"""UI input fields schema."""
101+
102+
injectors: dict[str, dict[str, str]] | None = None
103+
"""Injector hook parameters."""
104+
105+
managed: bool = False
106+
"""Flag for whether this plugin instance is managed."""
107+
108+
custom_injectors: Callable[
109+
[
110+
Credential,
111+
dict[str, GenericOptionalPrimitiveType], str,
112+
], str | None,
113+
] | None = None
114+
"""Function to call as an alternative to the templated injection."""
115+
116+
@property
117+
def secret_fields(self: 'ManagedCredentialType') -> list[str]:
118+
return [
119+
str(field['id'])
120+
for field in self.inputs.get('fields', [])
121+
if field.get('secret', False) is True
122+
]
123+
124+
def inject_credential(
125+
self: 'ManagedCredentialType',
126+
credential: Credential,
127+
env: dict[str, GenericOptionalPrimitiveType],
128+
safe_env: dict[str, GenericOptionalPrimitiveType],
129+
args: list[GenericOptionalPrimitiveType],
130+
private_data_dir: str,
131+
) -> None:
132+
"""Inject credential data.
133+
134+
Inject credential data into the environment variables and
135+
arguments passed to `ansible-playbook`
136+
137+
:param credential: a :class:`awx.main.models.Credential` instance
138+
:param env: a dictionary of environment variables used in
139+
the `ansible-playbook` call. This method adds
140+
additional environment variables based on
141+
custom `env` injectors defined on this
142+
CredentialType.
143+
:param safe_env: a dictionary of environment variables stored
144+
in the database for the job run
145+
(`UnifiedJob.job_env`); secret values should
146+
be stripped
147+
:param args: a list of arguments passed to
148+
`ansible-playbook` in the style of
149+
`subprocess.call(args)`. This method appends
150+
additional arguments based on custom
151+
`extra_vars` injectors defined on this
152+
CredentialType.
153+
:param private_data_dir: a temporary directory to store files generated
154+
by `file` injectors (like config files or key
155+
files)
156+
"""
157+
if not self.injectors:
158+
if self.managed and self.custom_injectors:
159+
injected_env: dict[str, GenericOptionalPrimitiveType] = {}
160+
self.custom_injectors(
161+
credential, injected_env, private_data_dir,
162+
)
163+
env.update(injected_env)
164+
safe_env.update(build_safe_env(injected_env))
165+
return
166+
167+
class TowerNamespace:
168+
"""Dummy class."""
169+
170+
tower_namespace = TowerNamespace()
171+
172+
# maintain a normal namespace for building the ansible-playbook
173+
# arguments (env and args)
174+
namespace: dict[str, TowerNamespace | GenericOptionalPrimitiveType] = {
175+
'tower': tower_namespace,
176+
}
177+
178+
# maintain a sanitized namespace for building the DB-stored arguments
179+
# (safe_env)
180+
safe_namespace: dict[str, TowerNamespace | GenericOptionalPrimitiveType] = {
181+
'tower': tower_namespace, }
182+
183+
# build a normal namespace with secret values decrypted (for
184+
# ansible-playbook) and a safe namespace with secret values hidden (for
185+
# DB storage)
186+
for field_name in credential.get_input_keys():
187+
value = credential.get_input(field_name)
188+
189+
if type(value) is bool:
190+
# boolean values can't be secret/encrypted/external
191+
safe_namespace[field_name] = value
192+
namespace[field_name] = value
193+
continue
194+
195+
if field_name in self.secret_fields:
196+
safe_namespace[field_name] = HIDDEN_PASSWORD
197+
elif value:
198+
safe_namespace[field_name] = value
199+
if value:
200+
namespace[field_name] = value
201+
202+
for field in self.inputs.get('fields', []):
203+
field_id = str(field['id'])
204+
# default missing boolean fields to False
205+
if field['type'] == 'boolean' and field_id not in credential.get_input_keys():
206+
namespace[field_id] = False
207+
safe_namespace[field_id] = False
208+
# make sure private keys end with a \n
209+
if field.get('format') == 'ssh_private_key':
210+
if field_id in namespace and not str(namespace[field_id]).endswith(
211+
'\n',
212+
):
213+
namespace[field_id] = str(namespace[field_id]) + '\n'
22214

23-
@dataclass(frozen=True)
24-
class ManagedCredentialType: # type: ignore[no-redef] # noqa: WPS440
25-
"""Managed credential type stub."""
215+
file_tmpls = self.injectors.get('file', {})
216+
# If any file templates are provided, render the files and update the
217+
# special `tower` template namespace so the filename can be
218+
# referenced in other injectors
26219

27-
namespace: str
28-
"""Plugin namespace."""
220+
sandbox_env = sandbox.ImmutableSandboxedEnvironment() # type: ignore[misc]
29221

30-
name: str
31-
"""Plugin name within the namespace."""
222+
for file_label, file_tmpl in file_tmpls.items():
223+
data: str = sandbox_env.from_string(file_tmpl).render(**namespace) # type: ignore[misc]
224+
env_dir = os.path.join(private_data_dir, 'env')
225+
_, path = tempfile.mkstemp(dir=env_dir)
226+
with open(path, 'w') as f:
227+
f.write(data)
228+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
229+
container_path = get_incontainer_path(path, private_data_dir)
32230

33-
kind: str
34-
"""Plugin category."""
231+
# determine if filename indicates single file or many
232+
if file_label.find('.') == -1:
233+
tower_namespace.filename = container_path
234+
else:
235+
if not hasattr(tower_namespace, 'filename'):
236+
tower_namespace.filename = TowerNamespace()
237+
file_label = file_label.split('.')[1]
238+
setattr(tower_namespace.filename, file_label, container_path)
35239

36-
inputs: dict[str, list[dict[str, bool | str] | str]]
37-
"""UI input fields schema."""
240+
for env_var, tmpl in self.injectors.get('env', {}).items():
241+
if env_var in ENV_BLOCKLIST:
242+
continue
243+
env[env_var] = sandbox_env.from_string(tmpl).render(**namespace)
244+
safe_env[env_var] = sandbox_env.from_string(
245+
tmpl,
246+
).render(**safe_namespace)
38247

39-
injectors: dict[str, dict[str, str]] | None = None
40-
"""Injector hook parameters."""
248+
if 'INVENTORY_UPDATE_ID' not in env:
249+
# awx-manage inventory_update does not support extra_vars via -e
250+
def build_extra_vars(node: dict[str, str | list[str]] | list[str] | str) -> dict[str, str] | list[str] | str:
251+
if isinstance(node, dict):
252+
return {
253+
build_extra_vars(k): build_extra_vars(v) for k,
254+
v in node.items()
255+
}
256+
elif isinstance(node, list):
257+
return [build_extra_vars(x) for x in node]
258+
else:
259+
return sandbox_env.from_string(node).render(**namespace)
41260

42-
managed: bool = False
43-
"""Flag for whether this plugin instance is managed."""
261+
def build_extra_vars_file(vars, private_dir: str) -> str:
262+
handle, path = tempfile.mkstemp(
263+
dir=os.path.join(private_dir, 'env'),
264+
)
265+
f = os.fdopen(handle, 'w')
266+
f.write(yaml_safe_dump(vars))
267+
f.close()
268+
os.chmod(path, stat.S_IRUSR)
269+
return path
44270

45-
custom_injectors: Callable[
46-
[
47-
Credential,
48-
dict[str, GenericOptionalPrimitiveType], str,
49-
], str | None,
50-
] | None = None
51-
"""Function to call as an alternative to the templated injection."""
271+
extra_vars = build_extra_vars(self.injectors.get('extra_vars', {}))
272+
if extra_vars:
273+
path = build_extra_vars_file(extra_vars, private_data_dir)
274+
container_path = get_incontainer_path(path, private_data_dir)
275+
args.extend(['-e', '@%s' % container_path])
52276

53277

54278
__all__ = () # noqa: WPS410

0 commit comments

Comments
 (0)