Coverage for plugins/modules/purefb_user.py: 40%
201 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 17:06 -0400
1#!/usr/bin/python
2# -*- coding: utf-8 -*-
4# (c) 2019, Simon Dodsley (simon@everpuredata.com)
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
7from __future__ import absolute_import, division, print_function
9__metaclass__ = type
11ANSIBLE_METADATA = {
12 "metadata_version": "1.1",
13 "status": ["preview"],
14 "supported_by": "community",
15}
17DOCUMENTATION = r"""
18---
19module: purefb_user
20version_added: '1.0.0'
21short_description: Create, modify or delete FlashBlade user accounts
22description:
23- Modify user on a Pure Stoage FlashBlade.
24author:
25- Everpure Ansible Team (@sdodsley) <pure-ansible-team@everpuredata.com>
26options:
27 state:
28 description:
29 - Create, delete or update local user account
30 default: present
31 type: str
32 choices: [ absent, present ]
33 version_added: "1.21.0"
34 name:
35 description:
36 - The name of the user account
37 type: str
38 required: true
39 role:
40 description:
41 - Sets the local user's access level to the system
42 - A new local user is created with I(readonly) when no role is given.
43 - When omitted for an existing user the current role is left unchanged.
44 type: str
45 choices: [ readonly, ops_admin, storage_admin, array_admin ]
46 version_added: "1.21.0"
47 password:
48 description:
49 - Password for the local user.
50 type: str
51 old_password:
52 description:
53 - If changing an existing password, you must provide the old password for security
54 type: str
55 api:
56 description:
57 - Define whether to create an API token for this user
58 - An existing token is replaced, so this is not idempotent.
59 - Token can be exposed using the I(debug) module
60 type: bool
61 default: false
62 version_added: "1.21.0"
63 timeout:
64 description:
65 - The duration of API token validity.
66 - Valid values are weeks (w), days(d), hours(h), minutes(m) and seconds(s).
67 - The default of C("0") creates a token that does not expire.
68 type: str
69 default: "0"
70 version_added: "1.21.0"
71 public_key:
72 description:
73 - SSH public key for the user in OpenSSH C(authorized_keys) format, for
74 example C(ssh-rsa AAAA... comment) or C(ssh-ed25519 AAAA... comment).
75 - The array accepts at most two keys, one per line.
76 - PEM blocks (I(-----BEGIN PUBLIC KEY-----)) are rejected. That is the
77 key format of M(everpure.flashblade.purefb_apiclient); the array treats
78 every line of a PEM block as a separate SSH key.
79 - Keys are compared by type and key material, so a different comment or
80 whitespace is not a change. When the array masks the stored key
81 (reported as C(****)) only its presence can be checked; clear it with
82 C("") before setting a different key.
83 - An empty string removes the user's public key.
84 type: str
85 version_added: "1.8.0"
86 clear_lock:
87 description:
88 - Clear user lockout flag
89 type: bool
90 default: false
91 version_added: "1.8.0"
92 ad_user:
93 description:
94 - Whether the user is in the AD system
95 - Not required for local users
96 type: bool
97 default: false
98 version_added: "1.21.0"
99extends_documentation_fragment:
100- everpure.flashblade.everpure.fb
101"""
103EXAMPLES = r"""
104- name: Change password for local user (NOT IDEMPOTENT)
105 everpure.flashblade.purefb_user:
106 name: pureuser
107 password: anewpassword
108 old_password: apassword
109 fb_url: 10.10.10.2
110 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6
112- name: Set public key for user
113 everpure.flashblade.purefb_user:
114 name: fred
115 public_key: "{{lookup('file', 'public_pem_file') }}"
116 fb_url: 10.10.10.2
117 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6
119- name: Clear user lockout
120 everpure.flashblade.purefb_user:
121 name: fred
122 clear_lock: true
123 fb_url: 10.10.10.2
124 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6
126- name: Create an API token (TTL of 2 days) and assign a public key to an AD user
127 everpure.flashblade.purefb_user:
128 name: ansible-ad
129 ad_user: true
130 public_key: "{{lookup('file', 'id_rsa.pub') }}"
131 api: true
132 timeout: 2d
133 fb_url: 10.10.10.2
134 api_token: T-9f276a18-50ab-446e-8a0c-666a3529a1b6
135"""
137RETURN = r"""
138"""
140HAS_PYPURECLIENT = True
141try:
142 from pypureclient.flashblade import (
143 AdminPatch,
144 AdminPost,
145 ReferenceWritable,
146 )
147except ImportError:
148 HAS_PYPURECLIENT = False
150from ansible.module_utils.basic import AnsibleModule
151from ansible_collections.everpure.flashblade.plugins.module_utils.time_utils import (
152 time_to_milliseconds,
153 TimeConversionError,
154)
155from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
156 get_system,
157 purefb_argument_spec,
158)
159from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
160 LooseVersion,
161)
162from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
163 get_error_message,
164 get_rest_api_version,
165)
166import re
168MIN_LOCAL_USER = "2.15"
169MIN_EXPOSE_API = "2.18"
170DEFAULT_ROLE = "readonly"
171# Value the array reports for a stored public key when it masks it.
172PUBLIC_KEY_MASK = "****"
173MAX_PUBLIC_KEYS = 2
174# One OpenSSH authorized_keys entry: "<type> <base64 key> [comment]"
175OPENSSH_KEY_LINE = re.compile(r"^(\S+)\s+([A-Za-z0-9+/=]+)(?:\s+.*)?$")
178def parse_public_keys(text):
179 """Return the (type, key) pairs found in an OpenSSH authorized_keys string.
181 Blank lines are ignored. Returns None when any non-empty line is not an
182 OpenSSH public key entry (for example a PEM block).
183 """
184 keys = []
185 for line in (text or "").splitlines():
186 line = line.strip()
187 if not line:
188 continue
189 match = OPENSSH_KEY_LINE.match(line)
190 if not match:
191 return None
192 keys.append((match.group(1), match.group(2)))
193 return keys
196def validate_public_key(module):
197 """Fail early when public_key is not in the format the array accepts.
199 The array wants OpenSSH format (``ssh-rsa AAAA...``) and reads one key
200 per line, so a PEM block is seen as many keys and rejected with the
201 unhelpful "At most 2 public keys can be uploaded for a user".
202 """
203 text = module.params["public_key"]
204 if not text or not text.strip():
205 return
206 keys = parse_public_keys(text)
207 if keys is None:
208 module.fail_json(
209 msg=(
210 "public_key must be in OpenSSH authorized_keys format "
211 "('ssh-rsa AAAA...' or 'ssh-ed25519 AAAA...', one key per "
212 "line). PEM blocks are the purefb_apiclient key format and "
213 "are not accepted for SSH access."
214 )
215 )
216 if len(keys) > MAX_PUBLIC_KEYS:
217 module.fail_json(
218 msg="At most {0} public keys can be set for a user".format(MAX_PUBLIC_KEYS)
219 )
222def public_key_changed(module, user):
223 """Return True when the requested public key differs from the user's.
225 Keys are compared by type and key material only, so comments and
226 whitespace do not count. An empty request clears the key and is a
227 change only while a key is set. A masked value (``****``) means the
228 array does not return stored keys; only presence can be compared then.
229 """
230 desired = module.params["public_key"]
231 if desired is None:
232 return False
233 current = getattr(user, "public_key", None) or ""
234 if not desired.strip():
235 return bool(current.strip())
236 if current.strip() == PUBLIC_KEY_MASK:
237 return False
238 return sorted(parse_public_keys(desired)) != sorted(
239 parse_public_keys(current) or []
240 )
243def set_public_key(module, blade, action="change"):
244 """PATCH the user's public key (an empty string clears it)"""
245 res = blade.patch_admins(
246 names=[module.params["name"]],
247 admin=AdminPatch(public_key=module.params["public_key"].strip()),
248 )
249 if res.status_code != 200:
250 module.fail_json(
251 msg="Failed to {0} SSH key. Error: {1}".format(
252 action, get_error_message(res)
253 )
254 )
257def get_user(module, blade):
258 """Return Local User Account or None"""
259 user = None
260 res = blade.get_admins(names=[module.params["name"]])
261 if res.status_code != 200:
262 return None
263 else:
264 return list(res.items)[0]
267def has_api_token(user):
268 """Return True if the admin currently holds an API token.
270 The token value itself is never returned by GET admins, only its
271 ``created_at``/``expires_at`` timestamps, so test those.
272 """
273 token = getattr(user, "api_token", None)
274 return bool(
275 getattr(token, "created_at", None) or getattr(token, "expires_at", None)
276 )
279def get_token_ttl(module):
280 """Return the API token validity in milliseconds, or None for no expiry"""
281 timeout = module.params["timeout"]
282 if timeout in (None, "", "0", 0):
283 return None
284 try:
285 return time_to_milliseconds(timeout)
286 except TimeConversionError as err:
287 module.fail_json(msg="Invalid timeout {0}: {1}".format(timeout, err))
290def delete_api_token(module, blade):
291 """Delete the admin's API token"""
292 res = blade.delete_admins_api_tokens(admin_names=[module.params["name"]])
293 if res.status_code != 200:
294 module.fail_json(
295 msg="Failed to delete original API token. Error: {0}".format(
296 get_error_message(res)
297 )
298 )
301def create_api_token(module, blade):
302 """Create an API token for the admin and return it"""
303 res = blade.post_admins_api_tokens(
304 admin_names=[module.params["name"]], timeout=get_token_ttl(module)
305 )
306 if res.status_code != 200:
307 module.fail_json(
308 msg="Failed to create API token. Error: {0}".format(get_error_message(res))
309 )
310 return list(res.items)[0].api_token.token
313def create_local_user(module, blade, user):
314 """Create or Update Local User Account"""
315 changed = key_changed = api_changed = role_changed = passwd_changed = False
316 lock_changed = False
317 role = module.params["role"] or DEFAULT_ROLE
318 api_token = "No API token created"
319 if not user:
320 changed = True
321 if not module.check_mode:
322 res = blade.post_admins(
323 names=[module.params["name"]],
324 admin=AdminPost(
325 role=ReferenceWritable(name=role),
326 password=module.params["password"],
327 ),
328 )
329 if res.status_code != 200:
330 module.fail_json(
331 msg="Failed to create user {0}. Error: {1}".format(
332 module.params["name"], get_error_message(res)
333 )
334 )
335 if module.params["api"]:
336 api_token = create_api_token(module, blade)
337 if module.params["public_key"] and module.params["public_key"].strip():
338 set_public_key(module, blade, action="add")
339 else:
340 if module.params["password"] and not module.params["old_password"]:
341 module.exit_json(changed=changed)
342 if (
343 module.params["password"]
344 and module.params["old_password"]
345 and module.params["password"] != module.params["old_password"]
346 ):
347 passwd_changed = True
348 if not module.check_mode:
349 res = blade.patch_admins(
350 names=[module.params["name"]],
351 admin=AdminPatch(
352 password=module.params["password"],
353 old_password=module.params["old_password"],
354 ),
355 )
356 if res.status_code != 200:
357 module.fail_json(
358 msg="Local User {0} password reset failed. Error: {1}"
359 "Check old password.".format(
360 module.params["name"], get_error_message(res)
361 )
362 )
363 if module.params["api"]:
364 api_changed = True
365 if not module.check_mode:
366 if has_api_token(user):
367 delete_api_token(module, blade)
368 api_token = create_api_token(module, blade)
369 if (
370 module.params["role"]
371 and module.params["role"] != getattr(user.role, "name", None)
372 and user.is_local
373 ):
374 if module.params["name"] != "pureuser":
375 role_changed = True
376 if not module.check_mode:
377 res = blade.patch_admins(
378 names=[module.params["name"]],
379 admin=AdminPatch(
380 role=ReferenceWritable(name=module.params["role"])
381 ),
382 )
383 if res.status_code != 200:
384 module.fail_json(
385 msg="Local User {0} role changed failed. Error: {1}".format(
386 module.params["name"], get_error_message(res)
387 )
388 )
389 else:
390 module.warn("Role for 'pureuser' cannot be modified.")
391 if public_key_changed(module, user):
392 key_changed = True
393 if not module.check_mode:
394 set_public_key(module, blade)
395 if module.params["clear_lock"] and getattr(user, "locked", False):
396 lock_changed = True
397 if not module.check_mode:
398 res = blade.patch_admins(
399 names=[module.params["name"]],
400 admin=AdminPatch(locked=False),
401 )
402 if res.status_code != 200:
403 module.fail_json(
404 msg="Failed to unlock user {0}. Error: {1}".format(
405 module.params["name"], get_error_message(res)
406 )
407 )
408 changed = bool(
409 passwd_changed or role_changed or api_changed or key_changed or lock_changed
410 )
411 module.exit_json(changed=changed, user_info=api_token)
414def update_ad_user(module, blade, user):
415 """Update AD user API token and/or SSH key"""
416 api_token = "No API token created"
417 api_changed = ssh_changed = False
418 if module.params["api"]:
419 api_changed = True
420 if not module.check_mode:
421 if user and has_api_token(user):
422 delete_api_token(module, blade)
423 api_token = create_api_token(module, blade)
424 if module.params["public_key"] is not None:
425 if user is None:
426 # An AD user with no array-side state yet: nothing to compare
427 # against, and nothing to clear.
428 ssh_changed = bool(module.params["public_key"].strip())
429 else:
430 ssh_changed = public_key_changed(module, user)
431 if ssh_changed and not module.check_mode:
432 set_public_key(module, blade, action="add")
433 changed = bool(api_changed or ssh_changed)
434 module.exit_json(changed=changed, user_info=api_token)
437def delete_ad_user(module, blade, user):
438 """Delete AD User Account references"""
439 changed = False
440 if not module.check_mode:
441 if user:
442 changed = True
443 if has_api_token(user):
444 res = blade.delete_admins_api_tokens(
445 admin_names=[module.params["name"]]
446 )
447 if res.status_code != 200:
448 module.fail_json(
449 msg="AD Account {0} API token deletion failed. Error: {1}".format(
450 module.params["name"], get_error_message(res)
451 )
452 )
453 if getattr(user, "public_key", None):
454 res = blade.patch_admins(
455 names=[module.params["name"]],
456 admin=AdminPatch(public_key=""),
457 )
458 if res.status_code != 200:
459 module.fail_json(
460 msg="AD Account {0} public key deletion failed. Error: {1}".format(
461 module.params["name"], get_error_message(res)
462 )
463 )
464 res = blade.delete_admins_cache(names=[module.params["name"]])
465 if res.status_code != 200:
466 module.fail_json(
467 msg="Admin Cache deleteion failed for AD user {0}. Error: {1}".format(
468 module.params["name"], get_error_message(res)
469 )
470 )
471 module.exit_json(changed=changed)
474def delete_local_user(module, blade):
475 """Delete Local User Account"""
476 changed = True
477 if not module.check_mode:
478 res = blade.delete_admins(names=[module.params["name"]])
479 if res.status_code != 200:
480 module.fail_json(
481 msg="User Account {0} deletion failed. Error: {1}".format(
482 module.params["name"], get_error_message(res)
483 )
484 )
485 module.exit_json(changed=changed)
488def main():
489 argument_spec = purefb_argument_spec()
490 argument_spec.update(
491 dict(
492 name=dict(type="str", required=True),
493 public_key=dict(type="str", no_log=True),
494 password=dict(type="str", no_log=True),
495 old_password=dict(type="str", no_log=True),
496 clear_lock=dict(type="bool", default=False),
497 role=dict(
498 type="str",
499 choices=["readonly", "ops_admin", "storage_admin", "array_admin"],
500 ),
501 state=dict(type="str", default="present", choices=["absent", "present"]),
502 api=dict(type="bool", default=False),
503 ad_user=dict(type="bool", default=False),
504 timeout=dict(type="str", default="0"),
505 )
506 )
508 module = AnsibleModule(
509 argument_spec,
510 supports_check_mode=True,
511 )
513 validate_public_key(module)
514 blade = get_system(module)
515 api_version = get_rest_api_version(blade)
516 if LooseVersion(MIN_LOCAL_USER) > LooseVersion(api_version):
517 module.fail_json(
518 msg="Purity//FB must be upgraded to support managing local users."
519 )
521 state = module.params["state"]
522 pattern = re.compile("^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$")
523 user = get_user(module, blade)
524 local_user = getattr(user, "is_local", False)
525 if not pattern.match(module.params["name"]):
526 module.fail_json(
527 msg="name must contain a minimum of 1 and a maximum of 32 characters "
528 "(alphanumeric or `-`). All letters must be lowercase."
529 )
531 if state == "present" and not local_user and module.params["ad_user"]:
532 update_ad_user(module, blade, user)
533 if state == "absent" and local_user:
534 delete_local_user(module, blade)
535 if state == "absent" and not local_user:
536 if user is None and not module.params["ad_user"]:
537 # Nothing to delete: the local user does not exist
538 module.exit_json(changed=False)
539 delete_ad_user(module, blade, user)
540 elif state == "present" and not module.params["ad_user"]:
541 create_local_user(module, blade, user)
542 else:
543 module.exit_json(changed=False)
546if __name__ == "__main__":
547 main()