Coverage for plugins/modules/purefb_local_user.py: 88%
191 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) 2026, 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_local_user
20version_added: '1.28.0'
21short_description: Manage FlashBlade Local Directory Service users
22description:
23- Create, update, rename or delete a local user in a FlashBlade Local
24 Directory Service (LDS). These are the SMB/NFS local identities that live
25 inside an LDS attached to a FlashBlade server. They are not the array
26 management admins - use M(everpure.flashblade.purefb_user) or
27 M(everpure.flashblade.purefb_admin) for those.
28- Requires FlashBlade REST API version 2.24 or later (Purity//FB 4.6.8+).
29- Group memberships can be managed inline via the I(groups) option. If you
30 also drive membership from the group side (via
31 M(everpure.flashblade.purefb_local_group)), only manage a given
32 (user,group) edge from one side in a single playbook run.
33author:
34- Pure Storage Ansible Team (@avk) <pure-ansible-team@everpuredata.com>
35options:
36 name:
37 description:
38 - Name of the local user inside the LDS.
39 type: str
40 required: true
41 state:
42 description:
43 - Whether the local user should exist or not.
44 type: str
45 default: present
46 choices: [ absent, present ]
47 local_directory_service:
48 description:
49 - Name of the Local Directory Service that owns this user.
50 - Attach LDSes to servers via M(everpure.flashblade.purefb_server).
51 type: str
52 required: true
53 new_name:
54 description:
55 - New name for the user. Uses the C(LocalUserPatch.name) field.
56 - Rejected for built-in users.
57 type: str
58 uid:
59 description:
60 - Numeric UID for the user. Auto-assigned by the array if omitted at
61 create time. Patchable.
62 type: int
63 email:
64 description:
65 - Email address for the user.
66 type: str
67 enabled:
68 description:
69 - Whether the user account is enabled.
70 - At create time, if unset the user is created enabled.
71 - On update, this field is only applied when explicitly set. This lets
72 you idempotently manage other attributes of a disabled user without
73 accidentally re-enabling the account.
74 type: bool
75 password:
76 description:
77 - Password for the user.
78 - Required at create time when I(enabled=true).
79 - Required on the transition of an existing user from
80 I(enabled=false) to I(enabled=true).
81 - Otherwise ignored unless I(force_password_reset=true).
82 type: str
83 force_password_reset:
84 description:
85 - Force the password to be reset on an existing user, even if unchanged.
86 - There is no way to compare the current password on the array, so
87 password change is inherently non-idempotent. Set this true to
88 explicitly ask for a reset.
89 type: bool
90 default: false
91 primary_group:
92 description:
93 - Name of the local group that is the user's primary group.
94 - Required when I(state=present) - the array will not create a user
95 without one.
96 - The primary group must already exist in the same LDS.
97 type: str
98 groups:
99 description:
100 - List of non-primary local group names the user should belong to.
101 - When set, the module reconciles the user's group memberships against
102 this list via C(/directory-services/local/users/members).
103 - The primary group (either the current one or a newly requested one) is
104 never removed via this list - the API disallows removing the primary
105 group edge.
106 - After a primary-group change the former primary remains a regular
107 member; if it is not listed here, a second run removes it.
108 type: list
109 elements: str
110 context:
111 description:
112 - Name of fleet member on which to perform the operation.
113 - This requires the array receiving the request is a member of a fleet
114 and the context name to be a member of the same fleet.
115 type: str
116 default: ""
117extends_documentation_fragment:
118- everpure.flashblade.everpure.fb
119"""
121EXAMPLES = r"""
122- name: Create local user 'alice'
123 everpure.flashblade.purefb_local_user:
124 name: alice
125 local_directory_service: myserver_local
126 uid: 5001
127 password: "s3cret!"
128 primary_group: alice
129 email: alice@example.com
130 fb_url: 10.10.10.2
131 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
133- name: Ensure 'alice' also belongs to secondary groups
134 everpure.flashblade.purefb_local_user:
135 name: alice
136 local_directory_service: myserver_local
137 primary_group: alice
138 groups:
139 - developers
140 - infra
141 fb_url: 10.10.10.2
142 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
144- name: Disable local user
145 everpure.flashblade.purefb_local_user:
146 name: alice
147 local_directory_service: myserver_local
148 primary_group: alice
149 enabled: false
150 fb_url: 10.10.10.2
151 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
153- name: Rename local user
154 everpure.flashblade.purefb_local_user:
155 name: alice
156 new_name: alice2
157 local_directory_service: myserver_local
158 primary_group: alice
159 fb_url: 10.10.10.2
160 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
162- name: Delete local user
163 everpure.flashblade.purefb_local_user:
164 name: alice
165 local_directory_service: myserver_local
166 state: absent
167 fb_url: 10.10.10.2
168 api_token: T-55a68eb5-c785-4720-a2ca-8b03903bf641
169"""
171RETURN = r"""
172"""
175HAS_PYPURECLIENT = True
176try:
177 from pypureclient.flashblade import (
178 LocalUserPost,
179 LocalUserPatch,
180 LocalUserMembershipPost,
181 LocalUserMembershipPostGroups,
182 ReferenceWritable,
183 )
184except ImportError:
185 HAS_PYPURECLIENT = False
188from ansible.module_utils.basic import AnsibleModule
189from ansible_collections.everpure.flashblade.plugins.module_utils.purefb import (
190 get_system,
191 purefb_argument_spec,
192)
193from ansible_collections.everpure.flashblade.plugins.module_utils.common import (
194 get_error_message,
195 get_rest_api_version,
196)
197from ansible_collections.everpure.flashblade.plugins.module_utils.version import (
198 LooseVersion,
199)
201MIN_REQUIRED_API_VERSION = "2.24"
204def _context_kwargs(module):
205 """Return context_names kwargs when a context is set.
207 Local users require REST 2.24, which is well above the 2.17 fleet-context
208 floor, so context_names is always accepted here.
209 """
210 if module.params["context"]:
211 return {"context_names": [module.params["context"]]}
212 return {}
215def _lds_filter(local_ds_name, extra=None):
216 """Build a filter expression that scopes to the given LDS.
218 The FB GET endpoints for local users / members do not accept
219 ``local_directory_service_names``; scoping must go through ``filter``.
220 """
221 # Per Pure's REST filter grammar, string literals are single-quoted and
222 # the backslash is the escape character: escape backslashes first, then
223 # single quotes.
224 escaped = local_ds_name.replace("\\", "\\\\").replace("'", "\\'")
226 expr = "local_directory_service.name='{0}'".format(escaped)
227 if extra:
228 expr = "({0}) and ({1})".format(expr, extra)
229 return expr
232def _get_user(module, blade, local_ds_name):
233 ctx = _context_kwargs(module)
234 res = blade.get_directory_services_local_users(
235 names=[module.params["name"]],
236 filter=_lds_filter(local_ds_name),
237 **ctx,
238 )
239 if res.status_code == 200:
240 items = list(res.items)
241 if items:
242 return items[0]
243 return None
246def _get_current_groups(module, blade, local_ds_name):
247 """Return the set of current non-primary group names for this user."""
248 ctx = _context_kwargs(module)
249 res = blade.get_directory_services_local_users_members(
250 member_names=[module.params["name"]],
251 filter=_lds_filter(local_ds_name),
252 **ctx,
253 )
254 if res.status_code != 200:
255 module.fail_json(
256 msg="Failed to fetch memberships for user {0}. Error: {1}".format(
257 module.params["name"], get_error_message(res)
258 )
259 )
260 result = set()
261 for edge in list(res.items):
262 group = getattr(edge, "group", None)
263 if group is not None:
264 gname = getattr(group, "name", None)
265 if gname:
266 result.add(gname)
267 return result
270def _add_memberships(module, blade, local_ds_name, groups):
271 ctx = _context_kwargs(module)
272 body = LocalUserMembershipPost(
273 groups=[
274 LocalUserMembershipPostGroups(group=ReferenceWritable(name=g))
275 for g in groups
276 ]
277 )
278 res = blade.post_directory_services_local_users_members(
279 local_membership=body,
280 member_names=[module.params["name"]],
281 local_directory_service_names=[local_ds_name],
282 **ctx,
283 )
284 if res.status_code != 200:
285 module.fail_json(
286 msg="Failed to add group memberships {0} for user {1}. Error: {2}".format(
287 sorted(groups),
288 module.params["name"],
289 get_error_message(res),
290 )
291 )
294def _remove_memberships(module, blade, local_ds_name, groups):
295 ctx = _context_kwargs(module)
296 for g in groups:
297 res = blade.delete_directory_services_local_users_members(
298 member_names=[module.params["name"]],
299 group_names=[g],
300 local_directory_service_names=[local_ds_name],
301 **ctx,
302 )
303 if res.status_code != 200:
304 module.fail_json(
305 msg=("Failed to remove user {0} from group {1}. Error: {2}").format(
306 module.params["name"], g, get_error_message(res)
307 )
308 )
311def _reconcile_memberships(
312 module, blade, local_ds_name, desired_primary_group, current_primary_group=None
313):
314 """Reconcile the user's non-primary group set against module.params['groups'].
316 Both the desired and the current primary group are excluded from removal:
317 the API disallows deleting the primary-group edge, and until the PATCH has
318 run the current primary is still in force.
319 """
320 desired = set(module.params["groups"] or [])
321 current = _get_current_groups(module, blade, local_ds_name)
323 exclude = set()
324 if desired_primary_group:
325 exclude.add(desired_primary_group)
326 if current_primary_group:
327 exclude.add(current_primary_group)
328 desired = desired - exclude
329 current_diff = current - exclude
331 add = desired - current_diff
332 remove = current_diff - desired
333 changed = bool(add or remove)
334 if changed and not module.check_mode:
335 if add:
336 _add_memberships(module, blade, local_ds_name, add)
337 if remove:
338 _remove_memberships(module, blade, local_ds_name, remove)
339 return changed
342def delete_user(module, blade, local_ds_name, user):
343 if getattr(user, "built_in", False):
344 module.fail_json(
345 msg="Refusing to delete built-in local user {0}".format(
346 module.params["name"]
347 )
348 )
349 changed = True
350 if not module.check_mode:
351 ctx = _context_kwargs(module)
352 res = blade.delete_directory_services_local_users(
353 names=[module.params["name"]],
354 local_directory_service_names=[local_ds_name],
355 **ctx,
356 )
357 if res.status_code != 200:
358 module.fail_json(
359 msg="Failed to delete local user {0}. Error: {1}".format(
360 module.params["name"], get_error_message(res)
361 )
362 )
363 module.exit_json(changed=changed)
366def create_user(module, blade, local_ds_name):
367 # enabled is not defaulted in the argument spec so update paths stay
368 # idempotent; at create time an unset value means "enabled".
369 enabled = module.params["enabled"] if module.params["enabled"] is not None else True
370 if enabled and not module.params["password"]:
371 module.fail_json(
372 msg="'password' is required when creating an enabled local user"
373 )
374 changed = True
375 if not module.check_mode:
376 body_kwargs = {}
377 if module.params["email"] is not None:
378 body_kwargs["email"] = module.params["email"]
379 body_kwargs["enabled"] = enabled
380 if module.params["password"]:
381 body_kwargs["password"] = module.params["password"]
382 body_kwargs["primary_group"] = ReferenceWritable(
383 name=module.params["primary_group"]
384 )
385 if module.params["uid"] is not None:
386 body_kwargs["uid"] = module.params["uid"]
388 ctx = _context_kwargs(module)
389 res = blade.post_directory_services_local_users(
390 names=[module.params["name"]],
391 local_directory_service_names=[local_ds_name],
392 local_user=LocalUserPost(**body_kwargs),
393 **ctx,
394 )
395 if res.status_code != 200:
396 module.fail_json(
397 msg="Failed to create local user {0}. Error: {1}".format(
398 module.params["name"], get_error_message(res)
399 )
400 )
402 if module.params["groups"]:
403 _reconcile_memberships(
404 module,
405 blade,
406 local_ds_name,
407 module.params["primary_group"],
408 )
409 module.exit_json(changed=changed)
412def update_user(module, blade, local_ds_name, user):
413 ctx = _context_kwargs(module)
414 built_in = bool(getattr(user, "built_in", False))
415 current_pg = getattr(user, "primary_group", None)
416 current_pg_name = getattr(current_pg, "name", None) if current_pg else None
418 patch_kwargs = {}
419 identity_changes = []
421 if module.params["new_name"] and module.params["new_name"] != module.params["name"]:
422 if built_in:
423 module.fail_json(
424 msg="Refusing to rename built-in local user {0}".format(
425 module.params["name"]
426 )
427 )
428 # Refuse if target name already exists in this LDS.
429 name_conflict_check = blade.get_directory_services_local_users(
430 names=[module.params["new_name"]],
431 filter=_lds_filter(local_ds_name),
432 **ctx,
433 )
434 if name_conflict_check.status_code == 200 and list(name_conflict_check.items):
435 module.fail_json(
436 msg=(
437 "Cannot rename local user {0} to {1}: target name already exists"
438 ).format(module.params["name"], module.params["new_name"])
439 )
440 patch_kwargs["name"] = module.params["new_name"]
441 identity_changes.append("name")
443 if module.params["email"] is not None and module.params["email"] != getattr(
444 user, "email", None
445 ):
446 patch_kwargs["email"] = module.params["email"]
447 identity_changes.append("email")
449 if module.params["enabled"] is not None and module.params["enabled"] != getattr(
450 user, "enabled", None
451 ):
452 # false -> true transition needs a password.
453 if module.params["enabled"] and not getattr(user, "enabled", False):
454 if not module.params["password"]:
455 module.fail_json(
456 msg=(
457 "'password' is required to enable local user {0} "
458 "(re-enabling a disabled account)"
459 ).format(module.params["name"])
460 )
461 patch_kwargs["enabled"] = module.params["enabled"]
462 identity_changes.append("enabled")
464 if module.params["uid"] is not None and module.params["uid"] != getattr(
465 user, "uid", None
466 ):
467 patch_kwargs["uid"] = module.params["uid"]
468 identity_changes.append("uid")
470 desired_pg_name = module.params["primary_group"]
471 pg_changed = False
472 if desired_pg_name != current_pg_name:
473 patch_kwargs["primary_group"] = ReferenceWritable(name=desired_pg_name)
474 identity_changes.append("primary_group")
475 pg_changed = True
477 if module.params["force_password_reset"]:
478 if not module.params["password"]:
479 module.fail_json(
480 msg=("'password' is required when 'force_password_reset' is true")
481 )
482 patch_kwargs["password"] = module.params["password"]
483 identity_changes.append("password")
484 elif (
485 "password" not in patch_kwargs
486 and patch_kwargs.get("enabled") is True
487 and not getattr(user, "enabled", False)
488 ):
489 # Re-enable transition: include the password once we know it exists.
490 patch_kwargs["password"] = module.params["password"]
492 if identity_changes and built_in:
493 # Rename is already blocked above. Block other identity mutations too;
494 # let the array reject anything we didn't foresee, but stop obvious
495 # mutations up-front with a clear message.
496 module.fail_json(
497 msg="Refusing to modify built-in local user {0}".format(
498 module.params["name"]
499 )
500 )
502 membership_changed = False
503 # Order-of-ops guard: reconcile memberships BEFORE PATCHing primary_group
504 # to dodge the known array-side regression where PATCHing primary_group
505 # with stale membership edges present has returned 500 on some releases.
506 if module.params["groups"] is not None and pg_changed:
507 membership_changed = _reconcile_memberships(
508 module, blade, local_ds_name, desired_pg_name, current_pg_name
509 )
511 patch_changed = bool(patch_kwargs)
512 if patch_changed and not module.check_mode:
513 res = blade.patch_directory_services_local_users(
514 names=[module.params["name"]],
515 local_directory_service_names=[local_ds_name],
516 local_user=LocalUserPatch(**patch_kwargs),
517 **ctx,
518 )
519 if res.status_code != 200:
520 module.fail_json(
521 msg="Failed to update local user {0}. Error: {1}".format(
522 module.params["name"], get_error_message(res)
523 )
524 )
526 # If we didn't reconcile earlier (no primary-group change), reconcile now.
527 if module.params["groups"] is not None and not pg_changed:
528 # After a rename the user is known by new_name, and reconciliation
529 # uses module.params['name'] - but in check mode the rename was
530 # skipped, so the user still answers to the current name.
531 if patch_kwargs.get("name") and not module.check_mode:
532 module.params["name"] = patch_kwargs["name"]
533 membership_changed = _reconcile_memberships(
534 module, blade, local_ds_name, desired_pg_name, current_pg_name
535 )
537 module.exit_json(changed=patch_changed or membership_changed)
540def main():
541 argument_spec = purefb_argument_spec()
542 argument_spec.update(
543 dict(
544 name=dict(type="str", required=True),
545 state=dict(type="str", default="present", choices=["absent", "present"]),
546 local_directory_service=dict(type="str", required=True),
547 new_name=dict(type="str"),
548 uid=dict(type="int"),
549 email=dict(type="str"),
550 enabled=dict(type="bool"),
551 password=dict(type="str", no_log=True),
552 force_password_reset=dict(type="bool", default=False, no_log=False),
553 primary_group=dict(type="str"),
554 groups=dict(type="list", elements="str"),
555 context=dict(type="str", default=""),
556 )
557 )
559 module = AnsibleModule(
560 argument_spec,
561 required_if=[("state", "present", ["primary_group"])],
562 supports_check_mode=True,
563 )
565 if not HAS_PYPURECLIENT:
566 module.fail_json(msg="py-pure-client sdk is required for this module")
568 blade = get_system(module)
569 api_version = get_rest_api_version(blade)
570 if LooseVersion(MIN_REQUIRED_API_VERSION) > LooseVersion(api_version):
571 module.fail_json(
572 msg=(
573 "FlashBlade REST version not supported. "
574 "Minimum version required: {0} (Purity//FB 4.6.8+)"
575 ).format(MIN_REQUIRED_API_VERSION)
576 )
578 if not module.params["context"]:
579 # If no context is provided set the context to the local array name
580 fleet_res = blade.get_fleets()
581 if fleet_res.status_code == 200 and list(fleet_res.items):
582 module.params["context"] = list(blade.get_arrays().items)[0].name
584 local_ds_name = module.params["local_directory_service"]
585 user = _get_user(module, blade, local_ds_name)
586 state = module.params["state"]
588 if state == "absent" and user:
589 delete_user(module, blade, local_ds_name, user)
590 elif state == "absent":
591 module.exit_json(changed=False)
592 elif state == "present" and not user:
593 create_user(module, blade, local_ds_name)
594 else:
595 update_user(module, blade, local_ds_name, user)
598if __name__ == "__main__":
599 main()