1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
|
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2019, Paul Arthur <paul.arthur@flowerysong.com>
# Copyright: (c) 2019, XLAB Steampunk <steampunk@xlab.si>
#
# 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
ANSIBLE_METADATA = {
"metadata_version": "1.1",
"status": ["stableinterface"],
"supported_by": "certified",
}
DOCUMENTATION = '''
module: user
author:
- Paul Arthur (@flowerysong)
- Aljaz Kosir (@aljazkosir)
- Tadej Borovsak (@tadeboro)
short_description: Manage Sensu users
description:
- Create, update, activate or deactivate Sensu user.
- For more information, refer to the Sensu documentation at
U(https://docs.sensu.io/sensu-go/latest/reference/rbac/#users).
version_added: 1.0.0
extends_documentation_fragment:
- sensu.sensu_go.requirements
- sensu.sensu_go.auth
- sensu.sensu_go.name
requirements:
- bcrypt (when managing Sensu Go 5.21.0 or newer)
seealso:
- module: sensu.sensu_go.user_info
options:
state:
description:
- Desired state of the user.
- Users cannot actually be deleted, only deactivated.
type: str
choices: [ enabled, disabled ]
default: enabled
password:
description:
- Password for the user.
- Required if user with a desired name does not exist yet on the backend
and I(password_hash) is not set.
- If both I(password) and I(password_hash) are set, I(password_hash) is
ignored and calculated from the I(password) if required.
type: str
password_hash:
description:
- Bcrypt password hash for the user.
- Use C(sensuctl user hash-password PASSWORD) to generate a hash.
- Required if user with a desired name does not exist yet on the backend
and I(password) is not set.
- If both I(password) and I(password_hash) are set, I(password_hash) is
ignored and calculated from the I(password) if required.
- Sensu Go < 5.21.0 does not support creating/updating users using
hashed passwords. Use I(password) parameter if you need to manage such
Sensu Go installations.
- At the moment, change detection does not work properly when using
password hashes because the Sensu Go backend does not expose enough
information via its API.
type: str
version_added: 1.8.0
groups:
description:
- List of groups user belongs to.
type: list
elements: str
'''
EXAMPLES = '''
- name: Create a user
sensu.sensu_go.user:
auth:
url: http://localhost:8080
name: awesome_username
password: hidden_password?
groups:
- dev
- prod
- name: Use pre-hashed password
sensu.sensu_go.user:
auth:
url: http://localhost:8080
name: awesome_username
password_hash: $5f$14$.brXRviMZpbaleSq9kjoUuwm67V/s4IziOLGHjEqxJbzPsreQAyNm
- name: Deactivate a user
sensu.sensu_go.user:
name: awesome_username
state: disabled
'''
RETURN = '''
object:
description: Object representing Sensu user.
returned: success
type: dict
sample:
disabled: false
groups:
- ops
- dev
password: USER_PASSWORD
password_hash: $5f$14$.brXRviMZpbaleSq9kjoUuwm67V/s4IziOLGHjEqxJbzPsreQAyNm
username: alice
'''
import traceback
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ..module_utils import arguments, errors, utils
try:
import bcrypt
HAS_BCRYPT = True
BCRYPT_IMPORT_ERROR = None
except ImportError:
HAS_BCRYPT = False
BCRYPT_IMPORT_ERROR = traceback.format_exc()
def _simulate_backend_response(payload):
# Backend does not return back any password-related information for now.
masked_keys = ('password', 'password_hash')
return dict(
(k, v) for k, v in payload.items() if k not in masked_keys
)
def update_password(client, path, username, password, check_mode):
# Hit the auth testing API and try to validate the credentials. If the API
# says they are invalid, we need to update them.
if client.validate_auth_data(username, password):
return False
if not check_mode:
if client.version < "5.21.0":
utils.put(client, path + '/password', dict(
username=username, password=password,
))
else:
# Raise exception if BCRYPT library is not present on host.
if HAS_BCRYPT:
hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
else:
raise errors.RequirementsError(missing_required_lib('bcrypt'))
utils.put(client, path + '/reset_password', dict(
username=username, password_hash=hash.decode('ascii'),
))
return True
def update_password_hash(client, path, username, password_hash, check_mode):
# Some older Sensu Go versions do not have support for password hashes.
if client.version < "5.21.0":
raise errors.SensuError(
"Sensu Go < 5.21.0 does not support password hashes"
)
# Insert change detection here once we can receive password hash from the
# backend. Up until then, we always update passwords.
if not check_mode:
utils.put(client, path + '/reset_password', dict(
username=username, password_hash=password_hash,
))
return True
def update_groups(client, path, old_groups, new_groups, check_mode):
to_delete = set(old_groups).difference(new_groups)
to_add = set(new_groups).difference(old_groups)
if not check_mode:
# Next few lines are far from atomic, which means that we can leave a
# user in any of the intermediate states, but this is the best we can
# do given the API limitations.
for g in to_add:
utils.put(client, path + '/groups/' + g, None)
for g in to_delete:
utils.delete(client, path + '/groups/' + g)
return len(to_delete) + len(to_add) > 0
def update_state(client, path, old_disabled, new_disabled, check_mode):
changed = old_disabled != new_disabled
if not check_mode and changed:
if new_disabled: # `state: disabled` input parameter
utils.delete(client, path)
else: # `state: enabled` input parameter
utils.put(client, path + '/reinstate', None)
return changed
def sync(remote_object, client, path, payload, check_mode):
# Create new user (either enabled or disabled)
if remote_object is None:
if check_mode:
return True, _simulate_backend_response(payload)
utils.put(client, path, payload)
return True, utils.get(client, path)
# Update existing user. We do this on a field-by-field basis because the
# upsteam API for updating users requires a password field to be set. Of
# course, we do not want to force users to specify an existing password
# just for the sake of updating the group membership, so this is why we
# use field-specific API endpoints to update the user data.
changed = False
# We only use password hash if we do not have a password. In practice,
# this means that users should not set both password and password_hash. We
# do not enforce this by making those two parameters mutually exclusive
# because in the future (2.0.0 version of collection), we intend to move
# password hashing into action plugin and supply both the password and its
# hash. Why? Because installing bcrypt on control node is way friendlier
# compared to installing bcrypt on every host that runs our user module.
#
# It is true that most of the time, control node == target node in our
# cases, but not always.
if 'password' in payload:
changed = update_password(
client, path, payload['username'], payload['password'],
check_mode,
) or changed
elif 'password_hash' in payload:
changed = update_password_hash(
client, path, payload['username'], payload['password_hash'],
check_mode,
) or changed
if 'groups' in payload:
changed = update_groups(
client, path, remote_object.get('groups') or [],
payload['groups'], check_mode,
) or changed
if 'disabled' in payload:
changed = update_state(
client, path, remote_object['disabled'], payload['disabled'],
check_mode,
) or changed
if check_mode:
# Backend does not return back passwords, so we should follow the
# example set by the backend API.
return changed, dict(
remote_object, **_simulate_backend_response(payload)
)
return changed, utils.get(client, path)
def main():
module = AnsibleModule(
supports_check_mode=True,
argument_spec=dict(
arguments.get_spec("auth", "name"),
state=dict(
default='enabled',
choices=['enabled', 'disabled'],
),
password=dict(
no_log=True
),
password_hash=dict(
no_log=False, # Showing hashes is perfectly OK
),
groups=dict(
type='list', elements='str',
)
),
)
client = arguments.get_sensu_client(module.params['auth'])
path = utils.build_core_v2_path(None, 'users', module.params['name'])
try:
remote_object = utils.get(client, path)
except errors.Error as e:
module.fail_json(msg=str(e))
if (
remote_object is None
and module.params['password'] is None
and module.params['password_hash'] is None
):
module.fail_json(
msg='Cannot create new user without a password or a hash'
)
payload = arguments.get_spec_payload(
module.params, 'password', 'password_hash', 'groups',
)
payload['username'] = module.params['name']
payload['disabled'] = module.params['state'] == 'disabled'
try:
changed, user = sync(
remote_object, client, path, payload, module.check_mode
)
module.exit_json(changed=changed, object=user)
except errors.Error as e:
module.fail_json(msg=str(e))
if __name__ == '__main__':
main()
|