Skip to content

User Management API

Full user lifecycle management across organizations. Requires SYNC_TOKEN.

Bases: BaseAPIClient

API client for Silo user management operations.

This class provides methods for managing users in the Authentic8 Silo platform, including creating, retrieving, updating, and deleting user accounts.

The user management API allows you to: - List all users in an organization - Get detailed information about specific users - Add new users to organizations - Delete users from organizations - Suspend and unsuspend user accounts - Reset user PINs and generate temporary passwords

Example

from silo_sdk import UserManagementAPI, load_config config = load_config() users = UserManagementAPI(config)

List all users in an organization

user_list = users.list_users("my_org") print(f"Found {len(user_list)} users")

Add a new user

result = users.add_user( ... org="my_org", ... username="john.doe@company.com", ... email="john.doe@company.com", ... given_name="John", ... surname="Doe" ... )

Source code in silo_sdk/management/user_api.py
 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
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
class UserManagementAPI(BaseAPIClient):
    """API client for Silo user management operations.

    This class provides methods for managing users in the Authentic8 Silo platform,
    including creating, retrieving, updating, and deleting user accounts.

    The user management API allows you to:
    - List all users in an organization
    - Get detailed information about specific users
    - Add new users to organizations
    - Delete users from organizations
    - Suspend and unsuspend user accounts
    - Reset user PINs and generate temporary passwords

    Example:
        >>> from silo_sdk import UserManagementAPI, load_config
        >>> config = load_config()
        >>> users = UserManagementAPI(config)
        >>>
        >>> # List all users in an organization
        >>> user_list = users.list_users("my_org")
        >>> print(f"Found {len(user_list)} users")
        >>>
        >>> # Add a new user
        >>> result = users.add_user(
        ...     org="my_org",
        ...     username="john.doe@company.com",
        ...     email="john.doe@company.com",
        ...     given_name="John",
        ...     surname="Doe"
        ... )
    """

    def __init__(self, config: dict[str, Any]) -> None:
        """Initialize the user management API client.

        Args:
            config: Configuration dictionary containing API settings

        Raises:
            ConfigurationError: If required configuration is missing
        """
        super().__init__(config)

        # Validate required configuration
        validate_config_for_api(config, ["SYNC_TOKEN"])

        self.auth_token = config["SYNC_TOKEN"]
        self.admin_token = config.get("ADMIN_TOKEN")
        self.logger.info("Initialized UserManagementAPI client")

    @api_tag(MethodType.API_COMMAND, api_command="listusers")
    def list_users(
        self, org: str, command_id: str | None = None
    ) -> list[dict[str, Any]]:
        """List all users in an organization.

        Retrieves a list of all users belonging to the specified organization,
        including their basic information such as username, email, names, and status.

        Args:
            org: Silo organization name from which to get list of users
            command_id: Optional string to echo in API response for tracking

        Returns:
            List of user dictionaries containing user information including:
            - username: User's username (typically email)
            - email: User's email address
            - given_name: User's first name
            - surname: User's last name
            - is_suspended: Whether the user is currently suspended
            - phone: User's phone number information (if available)

            A genuinely empty organization returns ``[]`` (the API's result
            was itself an empty list). This is distinct from a malformed or
            unexpected response shape, which raises instead of silently
            returning ``[]`` — see Raises below.

        Raises:
            UserManagementAPIError: If the API request fails, or if the API
                returns a result that is not a list at all (e.g. a dict or
                string). This keeps a broken/reshaped response from being
                indistinguishable from an org with zero users.
            ValidationError: If parameters are invalid

        Example:
            >>> users = api.list_users("my_organization")
            >>> for user in users:
            ...     print(f"{user['given_name']} {user['surname']} ({user['email']})")
        """
        if not org or not isinstance(org, str):
            raise ValidationError("Organization name must be a non-empty string")

        payload = [
            {
                "command": "listusers",
                "org": org,
            }
        ]

        if command_id is not None:
            payload[0]["command_id"] = command_id

        try:
            self.logger.debug("Listing users for organization: %s", org)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, list):
                self.logger.info(
                    "Retrieved %d users from organization %s", len(result), org
                )
                return result

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to list users: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="getuser")
    def get_user(
        self,
        username: str | None = None,
        email: str | None = None,
    ) -> dict[str, Any]:
        """Retrieve details about a specific user record.

        Gets comprehensive information about a user including their profile data,
        account status, creation time, and other metadata.

        Exactly one of ``username`` or ``email`` must be provided — the API
        returns an error if both are supplied simultaneously.

        Args:
            username: Username of the user to retrieve. Mutually exclusive with email.
            email: Email address of the user to retrieve. Mutually exclusive with username.

        Returns:
            Dictionary containing detailed user information including:
            - username: User's username
            - email: User's email address
            - given_name: User's first name
            - surname: User's last name
            - is_suspended: Whether the account is suspended
            - create_ts: Account creation timestamp
            - last_authorized_ts: Last login timestamp (None if never logged in)
            - phone: User's phone numbers (list or None)
            - custom_fields: Any custom fields associated with the user

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If neither or both of username/email are provided

        Example:
            >>> user_info = api.get_user(username="john.doe@company.com")
            >>> user_info = api.get_user(email="john.doe@company.com")
            >>> print(f"User created: {user_info['create_ts']}")
        """
        if username is not None and email is not None:
            raise ValidationError("Specify either username or email, not both")
        if username is None and email is None:
            raise ValidationError("Either username or email must be provided")

        command: dict[str, Any] = {"command": "getuser"}
        if username is not None:
            command["username"] = username
        else:
            command["email"] = email

        payload = [command]

        try:
            identifier = username or email
            self.logger.debug("Getting user details for: %s", identifier)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                self.logger.debug("Retrieved user details successfully")
                return result

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to get user details: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="adduser")
    def add_user(
        self,
        org: str,
        username: str,
        email: str,
        given_name: str,
        surname: str,
        phone: str | None = None,
        custom_fields: dict[str, Any] | None = None,
        command_id: str | None = None,
    ) -> dict[str, Any]:
        """Add a new user to the organization.

        Creates a new user account in the specified organization with the provided
        information. The user will be created with default settings and can be
        configured further after creation.

        Args:
            org: Organization name to add the user to
            username: The username for the new user (typically email address)
            email: The email address for the new user
            given_name: The user's first name
            surname: The user's last name
            phone: Optional phone number for the user. The API validates
                the area code — ``555`` area codes are rejected. Use a
                real or valid test area code (e.g., ``"+18885550100"``).
                Not required by the Admin Console but validated by the API.
            custom_fields: Optional dictionary of custom field key-value pairs
            command_id: Optional command identifier for tracking

        Returns:
            Dictionary containing the result of user creation including:
            - username: The created username
            - email: The user's email address
            - bypass_code: Temporary bypass code for initial access

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> result = api.add_user(
            ...     org="my_org",
            ...     username="jane.smith@company.com",
            ...     email="jane.smith@company.com",
            ...     given_name="Jane",
            ...     surname="Smith",
            ...     phone="888-555-0100",
            ...     custom_fields={"department": "Engineering", "role": "Developer"}
            ... )
            >>> print(f"User created with bypass code: {result['bypass_code']}")
        """
        # Validate required parameters
        if not org or not isinstance(org, str):
            raise ValidationError("Organization name must be a non-empty string")

        if not username or not isinstance(username, str):
            raise ValidationError("Username must be a non-empty string")

        if not email or not isinstance(email, str):
            raise ValidationError("Email must be a non-empty string")

        if not given_name or not isinstance(given_name, str):
            raise ValidationError("Given name must be a non-empty string")

        if not surname or not isinstance(surname, str):
            raise ValidationError("Surname must be a non-empty string")

        # Build payload
        payload_data: dict[str, Any] = {
            "command": "adduser",
            "org": org,
            "username": username,
            "email": email,
            "given_name": given_name,
            "surname": surname,
        }

        # Add optional parameters
        if phone is not None:
            payload_data["phone"] = phone

        if custom_fields is not None:
            if not isinstance(custom_fields, dict):
                raise ValidationError("custom_fields must be a dictionary")
            payload_data["custom_fields"] = custom_fields

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            self.logger.debug("Adding user: %s to organization: %s", username, org)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                self.logger.info("Successfully added user: %s", username)
                return result

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to add user: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="deleteuser")
    def delete_user(self, username: str, command_id: str | None = None) -> bool:
        """Delete a user from an organization.

        Permanently removes a user account from the system. This action cannot
        be undone, and all user data will be lost.

        Args:
            username: Username of the user to be deleted
            command_id: Optional command identifier for tracking

        Returns:
            True if deletion was successful, False otherwise

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> success = api.delete_user("old.user@company.com")
            >>> if success:
            ...     print("User deleted successfully")
        """
        if not username or not isinstance(username, str):
            raise ValidationError("Username must be a non-empty string")

        payload_data = {
            "command": "deleteuser",
            "username": username,
        }

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            self.logger.debug("Deleting user: %s", username)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, str) and result == "user deleted":
                self.logger.info("Successfully deleted user: %s", username)
                return True

            self.logger.warning(
                "Unexpected result when deleting user %s: %s", username, result
            )
            return False

        except SiloError as e:
            # Known extapi quirk: deleteuser returns {"error": "user not found"}
            # even when deletion succeeded. Treat it as success.
            if "user not found" in str(e).lower():
                self.logger.info(
                    "deleteuser returned 'user not found' for %s — "
                    "treating as success (known extapi false-error)",
                    username,
                )
                return True
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(
                f"Failed to delete user: {e}"
            ) from e  # pragma: no cover
        except Exception as e:  # pragma: no cover
            raise UserManagementAPIError(f"Failed to delete user: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="suspenduser")
    def suspend_user(self, username: str, command_id: str | None = None) -> bool:
        """Suspend a user from an organization.

        Temporarily disables a user account, preventing them from accessing
        the system while preserving their account data. The user can be
        unsuspended later to restore access.

        Args:
            username: Username of the user to suspend
            command_id: Optional command identifier for tracking

        Returns:
            True if suspension was successful, False otherwise

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> success = api.suspend_user("problem.user@company.com")
            >>> if success:
            ...     print("User suspended successfully")
        """
        if not username or not isinstance(username, str):
            raise ValidationError("Username must be a non-empty string")

        payload_data = {
            "command": "suspenduser",
            "username": username,
        }

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            self.logger.debug("Suspending user: %s", username)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, str) and result == "user suspended":
                self.logger.info("Successfully suspended user: %s", username)
                return True

            self.logger.warning(
                "Unexpected result when suspending user %s: %s", username, result
            )
            return False

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to suspend user: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="unsuspenduser")
    def unsuspend_user(self, username: str, command_id: str | None = None) -> bool:
        """Unsuspend a user from an organization.

        Restores access for a previously suspended user account, allowing them
        to use the system again with their existing settings and data.

        Args:
            username: Username of the user to unsuspend
            command_id: Optional command identifier for tracking

        Returns:
            True if unsuspension was successful, False otherwise

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> success = api.unsuspend_user("restored.user@company.com")
            >>> if success:
            ...     print("User unsuspended successfully")
        """
        if not username or not isinstance(username, str):
            raise ValidationError("Username must be a non-empty string")

        payload_data = {
            "command": "unsuspenduser",
            "username": username,
        }

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            self.logger.debug("Unsuspending user: %s", username)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, str) and result == "user unsuspended":
                self.logger.info("Successfully unsuspended user: %s", username)
                return True

            self.logger.warning(
                "Unexpected result when unsuspending user %s: %s", username, result
            )
            return False

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to unsuspend user: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="reset_pin")
    def reset_pin(self, username: str, email: str | None = None) -> dict[str, str]:
        """Reset a user's PIN, creating a temporary password.

        Generates a new temporary password for a user, which they can use to
        access their account and set a new permanent PIN. This is useful for
        password recovery or initial account setup.

        Args:
            username: Username of the user to reset their PIN. Required —
                this is always the identifier used to look up the account.
            email: Optional email address sent alongside ``username`` in
                the request. This is NOT an alternative to ``username``;
                ``username`` must still be a non-empty string even when
                ``email`` is supplied.

        Returns:
            Dictionary containing the temporary password information:
            - temporary_password: The generated temporary password
            - username: The username for which the PIN was reset

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> result = api.reset_pin("user@company.com")
            >>> temp_password = result['temporary_password']
            >>> print(f"Temporary password: {temp_password}")
        """
        if not username or not isinstance(username, str):
            raise ValidationError("Username must be a non-empty string")

        payload_data = {
            "command": "reset_pin",
            "username": username,
        }

        if email is not None:
            payload_data["email"] = email

        payload = [payload_data]

        try:
            self.logger.debug("Resetting PIN for user: %s", username)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                self.logger.info("Successfully reset PIN for user: %s", username)
                return result

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to reset PIN: {e}") from e

    @api_tag(MethodType.API_COMMAND, api_command="admin.user_to_admin")
    def promote_user_to_admin(
        self,
        org: str,
        username: str | None = None,
        email: str | None = None,
        command_id: str | None = None,
    ) -> dict[str, Any]:
        """Promote a user to admin for an organization.

        Upgrades a user account to an admin role within the specified
        organization. The service account associated with the API token must
        have the required administrative privileges — those privileges will be
        applied to the promoted user.

        Exactly one of ``username`` or ``email`` must be provided.

        Args:
            org: The name of the organization the user will administer.
                Required.
            username: Username of the account to promote. Used if ``email``
                is not provided.
            email: Email of the account to promote. Used if ``username``
                is not provided.
            command_id: Optional command identifier for tracking.

        Returns:
            Dictionary containing the result of the promotion.

        Raises:
            UserManagementAPIError: If the API request fails.
            ValidationError: If parameters are invalid.

        Example:
            >>> result = api.promote_user_to_admin(
            ...     org="MyOrg",
            ...     username="admin@company.com",
            ... )
        """
        if not org or not isinstance(org, str):
            raise ValidationError("Organization name must be a non-empty string")
        if username is not None and email is not None:
            raise ValidationError("Specify either username or email, not both")
        if username is None and email is None:
            raise ValidationError("Either username or email must be provided")
        if not self.admin_token:
            raise ValidationError("ADMIN_TOKEN is required for promote_user_to_admin")

        payload_data: dict[str, Any] = {
            "command": "admin.user_to_admin",
            "org": org,
        }
        if username is not None:
            payload_data["username"] = username
        else:
            payload_data["email"] = email

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            identifier = username or email
            self.logger.debug("Promoting user %s to admin in org: %s", identifier, org)
            response = self._make_api_request("POST", "api/", self.admin_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, dict):
                self.logger.info(
                    "Successfully promoted user %s to admin in %s", identifier, org
                )
                return result
            if isinstance(result, (str, bool)):
                self.logger.info(
                    "Successfully promoted user %s to admin in %s", identifier, org
                )
                return {"status": str(result), "org": org, "username": identifier}

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to promote user to admin: {e}") from e

    @api_tag(MethodType.CONVENIENCE, wraps=["suspend_user"])
    def batch_suspend_users(self, usernames: list[str]) -> list[dict[str, Any]]:
        """Suspend multiple user accounts in a single batch request.

        Sends all ``suspenduser`` commands in one HTTP POST to the extapi using
        SYNC_TOKEN. Each username is processed sequentially by the server within
        the same batch, which is more efficient than calling :meth:`suspend_user`
        in a loop.

        Args:
            usernames: Non-empty list of usernames to suspend. Each entry is
                sent as a separate ``suspenduser`` wire command within the
                same batch POST.

        Returns:
            List of per-username result dicts (one per input username, in the
            same order). Each dict contains the ``username`` field plus either
            a ``"result"`` key (success) or an ``"error"`` key (failure for
            that username).

        Raises:
            ValidationError: If ``usernames`` is empty.
            UserManagementAPIError: If the API request fails.

        Note:
            Unlike calling :meth:`suspend_user` in a loop, this method does
            not perform per-username type validation.

        Example:
            >>> results = api.batch_suspend_users([
            ...     "alice@company.com",
            ...     "bob@company.com",
            ... ])
            >>> for r in results:
            ...     print(r["username"], r.get("result"))
        """
        if not usernames:
            raise ValidationError("usernames must be a non-empty list")

        commands = [{"command": "suspenduser", "username": u} for u in usernames]

        try:
            self.logger.debug("Batch suspending %d users", len(usernames))
            raw_results = self.batch_request(commands, auth_token=self.auth_token)
        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to batch suspend users: {e}") from e

        results: list[dict[str, Any]] = []
        for i, username in enumerate(usernames):
            entry: dict[str, Any] = {"username": username}
            if i < len(raw_results):
                raw = raw_results[i]
                if isinstance(raw, dict):
                    entry.update(raw)
                else:  # pragma: no branch — defensive for non-dict results
                    entry["result"] = raw  # type: ignore[unreachable]
            else:
                entry["error"] = "no response received"
            results.append(entry)

        self.logger.info("Batch suspend completed for %d users", len(usernames))
        return results

    @api_tag(MethodType.CONVENIENCE, wraps=["delete_user"])
    def batch_delete_users(self, usernames: list[str]) -> list[dict[str, Any]]:
        """Delete multiple user accounts in a single batch request.

        Sends all ``deleteuser`` commands in one HTTP POST to the extapi using
        SYNC_TOKEN. Each username is permanently deleted within the same batch
        — this action cannot be undone. More efficient than calling
        :meth:`delete_user` in a loop.

        Args:
            usernames: Non-empty list of usernames to delete. Each entry is
                sent as a separate ``deleteuser`` wire command within the
                same batch POST.

        Returns:
            List of per-username result dicts (one per input username, in the
            same order). Each dict contains the ``username`` field plus
            either a ``"result"`` key (success, including the soft-success
            case described below) or an ``"error"`` key (a genuine failure
            for that username). This mirrors the boolean success/failure
            contract of :meth:`delete_user` on a per-item basis, so callers
            switching a delete loop to this batch call see comparable
            outcomes for the same inputs.

        Raises:
            ValidationError: If ``usernames`` is empty.
            UserManagementAPIError: If the API request fails.

        Note:
            Unlike calling :meth:`delete_user` in a loop, this method does
            not perform per-username type validation.

            Known extapi quirk: ``deleteuser`` can return
            ``{"error": "user not found"}`` for a username even when the
            delete actually succeeded. :meth:`delete_user` already treats
            this as success (returns ``True``); this method applies the same
            translation per-entry so the two methods' result contracts stay
            consistent. Affected entries have their ``"error"`` key replaced
            with ``"result": "deleted (unconfirmed)"`` plus a ``"note"``
            explaining the quirk, instead of surfacing a spurious failure.

        Example:
            >>> results = api.batch_delete_users([
            ...     "former.employee@company.com",
            ...     "test.account@company.com",
            ... ])
            >>> for r in results:
            ...     print(r["username"], r.get("result"))
        """
        if not usernames:
            raise ValidationError("usernames must be a non-empty list")

        commands = [{"command": "deleteuser", "username": u} for u in usernames]

        try:
            self.logger.debug("Batch deleting %d users", len(usernames))
            raw_results = self.batch_request(commands, auth_token=self.auth_token)
        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to batch delete users: {e}") from e

        results: list[dict[str, Any]] = []
        for i, username in enumerate(usernames):
            entry: dict[str, Any] = {"username": username}
            if i < len(raw_results):
                raw = raw_results[i]
                if isinstance(raw, dict):
                    entry.update(raw)
                else:  # pragma: no branch — defensive for non-dict results
                    entry["result"] = raw  # type: ignore[unreachable]
            else:
                entry["error"] = "no response received"
            results.append(entry)

        # Known extapi quirk: deleteuser returns {"error": "user not found"}
        # even when the delete succeeded (see delete_user()'s handling of
        # the same quirk). Translate it here too so batch and single-item
        # deletes present a consistent result contract to callers.
        for entry in results:
            error_val = entry.get("error")
            if error_val and "user not found" in str(error_val).lower():
                entry.pop("error", None)
                entry["result"] = "deleted (unconfirmed)"
                entry["note"] = (
                    "extapi returned 'user not found' — known false-error; "
                    "treated as success for consistency with delete_user(). "
                    "Call list_users() to confirm if needed."
                )

        self.logger.info("Batch delete completed for %d users", len(usernames))
        return results

    @api_tag(MethodType.API_COMMAND, api_command="modifyuser")
    def modify_user(
        self,
        email: str,
        phone: str | None = None,
        given_name: str | None = None,
        surname: str | None = None,
        org: str | None = None,
        custom_fields: dict[str, Any] | None = None,
        command_id: str | None = None,
    ) -> dict[str, Any]:
        """Modify an existing user's attributes.

        Updates various attributes of an existing user including their phone number,
        names, organization assignment, and custom fields.

        Args:
            email: Email of the user to be modified. **This is the primary identifier**
                for the modify operation — the user is looked up by email, not username.
                Required parameter; must be a non-empty string.
            phone: New phone number for the user. The API validates the
                area code — ``555`` area codes are rejected (not required
                by the Admin Console, but validated by the API).
            given_name: New first name for the user
            surname: New last name for the user
            org: New organization to move the user to (API org name)
            custom_fields: New custom field key-value pairs
            command_id: Optional command identifier for tracking

        Note:
            Wire format parameter naming:

            - The ``modifyuser`` command uses ``"email"`` as the primary identifier
            - Other user management commands (e.g., ``getuser``, ``deleteuser``) use ``"username"``
            - All params are **flat alongside "command"**, not nested under ``"data"``

            Wire format structure::

                {
                    "command": "modifyuser",
                    "email": "user@company.com",  # ← primary identifier (flat)
                    "phone": "888-555-0100",
                    "given_name": "John",
                    "surname": "Smith",
                    "org": "my_org",
                    "custom_fields": {"department": "Engineering"}
                }

        Returns:
            Dictionary containing the result of user modification:

            - ``status``: Status message (``"user modified"`` on success)
            - ``email``: Email of the modified user

        Raises:
            UserManagementAPIError: If the API request fails
            ValidationError: If parameters are invalid

        Example:
            >>> # Modify user by email (not username)
            >>> result = api.modify_user(
            ...     email="user@company.com",  # ← primary identifier
            ...     phone="888-555-0100",
            ...     given_name="John",
            ...     surname="Smith",
            ...     custom_fields={"department": "Engineering"}
            ... )
            >>> print("User modified successfully")
        """
        if not email or not isinstance(email, str):
            raise ValidationError("Email must be a non-empty string")

        # Build payload
        payload_data: dict[str, Any] = {
            "command": "modifyuser",
            "email": email,
        }

        # Add optional parameters
        if phone is not None:
            payload_data["phone"] = phone

        if given_name is not None:
            if not isinstance(given_name, str):
                raise ValidationError("Given name must be a string")
            payload_data["given_name"] = given_name

        if surname is not None:
            if not isinstance(surname, str):
                raise ValidationError("Surname must be a string")
            payload_data["surname"] = surname

        if org is not None:
            if not isinstance(org, str):
                raise ValidationError("Organization must be a string")
            payload_data["org"] = org

        if custom_fields is not None:
            if not isinstance(custom_fields, dict):
                raise ValidationError("custom_fields must be a dictionary")
            payload_data["custom_fields"] = custom_fields

        if command_id is not None:
            payload_data["command_id"] = command_id

        payload = [payload_data]

        try:
            self.logger.debug("Modifying user: %s", email)
            response = self._make_api_request("POST", "api/", self.auth_token, payload)

            result = self._extract_api_result(response)
            if isinstance(result, str) and result == "user modified":
                self.logger.info("Successfully modified user: %s", email)
                return {"status": "user modified", "email": email}

            # Handle other response formats
            if isinstance(result, dict):
                self.logger.info("Successfully modified user: %s", email)
                return result

            raise UserManagementAPIError(f"Unexpected result format: {result}")

        except Exception as e:
            if isinstance(e, UserManagementAPIError):
                raise
            raise UserManagementAPIError(f"Failed to modify user: {e}") from e

__init__

__init__(config: dict[str, Any]) -> None

Initialize the user management API client.

Parameters:

Name Type Description Default
config dict[str, Any]

Configuration dictionary containing API settings

required

Raises:

Type Description
ConfigurationError

If required configuration is missing

Source code in silo_sdk/management/user_api.py
def __init__(self, config: dict[str, Any]) -> None:
    """Initialize the user management API client.

    Args:
        config: Configuration dictionary containing API settings

    Raises:
        ConfigurationError: If required configuration is missing
    """
    super().__init__(config)

    # Validate required configuration
    validate_config_for_api(config, ["SYNC_TOKEN"])

    self.auth_token = config["SYNC_TOKEN"]
    self.admin_token = config.get("ADMIN_TOKEN")
    self.logger.info("Initialized UserManagementAPI client")

list_users

list_users(org: str, command_id: str | None = None) -> list[dict[str, Any]]

List all users in an organization.

Retrieves a list of all users belonging to the specified organization, including their basic information such as username, email, names, and status.

Parameters:

Name Type Description Default
org str

Silo organization name from which to get list of users

required
command_id str | None

Optional string to echo in API response for tracking

None

Returns:

Type Description
list[dict[str, Any]]

List of user dictionaries containing user information including:

list[dict[str, Any]]
  • username: User's username (typically email)
list[dict[str, Any]]
  • email: User's email address
list[dict[str, Any]]
  • given_name: User's first name
list[dict[str, Any]]
  • surname: User's last name
list[dict[str, Any]]
  • is_suspended: Whether the user is currently suspended
list[dict[str, Any]]
  • phone: User's phone number information (if available)
list[dict[str, Any]]

A genuinely empty organization returns [] (the API's result

list[dict[str, Any]]

was itself an empty list). This is distinct from a malformed or

list[dict[str, Any]]

unexpected response shape, which raises instead of silently

list[dict[str, Any]]

returning [] — see Raises below.

Raises:

Type Description
UserManagementAPIError

If the API request fails, or if the API returns a result that is not a list at all (e.g. a dict or string). This keeps a broken/reshaped response from being indistinguishable from an org with zero users.

ValidationError

If parameters are invalid

Example

users = api.list_users("my_organization") for user in users: ... print(f"{user['given_name']} {user['surname']} ({user['email']})")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="listusers")
def list_users(
    self, org: str, command_id: str | None = None
) -> list[dict[str, Any]]:
    """List all users in an organization.

    Retrieves a list of all users belonging to the specified organization,
    including their basic information such as username, email, names, and status.

    Args:
        org: Silo organization name from which to get list of users
        command_id: Optional string to echo in API response for tracking

    Returns:
        List of user dictionaries containing user information including:
        - username: User's username (typically email)
        - email: User's email address
        - given_name: User's first name
        - surname: User's last name
        - is_suspended: Whether the user is currently suspended
        - phone: User's phone number information (if available)

        A genuinely empty organization returns ``[]`` (the API's result
        was itself an empty list). This is distinct from a malformed or
        unexpected response shape, which raises instead of silently
        returning ``[]`` — see Raises below.

    Raises:
        UserManagementAPIError: If the API request fails, or if the API
            returns a result that is not a list at all (e.g. a dict or
            string). This keeps a broken/reshaped response from being
            indistinguishable from an org with zero users.
        ValidationError: If parameters are invalid

    Example:
        >>> users = api.list_users("my_organization")
        >>> for user in users:
        ...     print(f"{user['given_name']} {user['surname']} ({user['email']})")
    """
    if not org or not isinstance(org, str):
        raise ValidationError("Organization name must be a non-empty string")

    payload = [
        {
            "command": "listusers",
            "org": org,
        }
    ]

    if command_id is not None:
        payload[0]["command_id"] = command_id

    try:
        self.logger.debug("Listing users for organization: %s", org)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, list):
            self.logger.info(
                "Retrieved %d users from organization %s", len(result), org
            )
            return result

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to list users: {e}") from e

get_user

get_user(username: str | None = None, email: str | None = None) -> dict[str, Any]

Retrieve details about a specific user record.

Gets comprehensive information about a user including their profile data, account status, creation time, and other metadata.

Exactly one of username or email must be provided — the API returns an error if both are supplied simultaneously.

Parameters:

Name Type Description Default
username str | None

Username of the user to retrieve. Mutually exclusive with email.

None
email str | None

Email address of the user to retrieve. Mutually exclusive with username.

None

Returns:

Type Description
dict[str, Any]

Dictionary containing detailed user information including:

dict[str, Any]
  • username: User's username
dict[str, Any]
  • email: User's email address
dict[str, Any]
  • given_name: User's first name
dict[str, Any]
  • surname: User's last name
dict[str, Any]
  • is_suspended: Whether the account is suspended
dict[str, Any]
  • create_ts: Account creation timestamp
dict[str, Any]
  • last_authorized_ts: Last login timestamp (None if never logged in)
dict[str, Any]
  • phone: User's phone numbers (list or None)
dict[str, Any]
  • custom_fields: Any custom fields associated with the user

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If neither or both of username/email are provided

Example

user_info = api.get_user(username="john.doe@company.com") user_info = api.get_user(email="john.doe@company.com") print(f"User created: {user_info['create_ts']}")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="getuser")
def get_user(
    self,
    username: str | None = None,
    email: str | None = None,
) -> dict[str, Any]:
    """Retrieve details about a specific user record.

    Gets comprehensive information about a user including their profile data,
    account status, creation time, and other metadata.

    Exactly one of ``username`` or ``email`` must be provided — the API
    returns an error if both are supplied simultaneously.

    Args:
        username: Username of the user to retrieve. Mutually exclusive with email.
        email: Email address of the user to retrieve. Mutually exclusive with username.

    Returns:
        Dictionary containing detailed user information including:
        - username: User's username
        - email: User's email address
        - given_name: User's first name
        - surname: User's last name
        - is_suspended: Whether the account is suspended
        - create_ts: Account creation timestamp
        - last_authorized_ts: Last login timestamp (None if never logged in)
        - phone: User's phone numbers (list or None)
        - custom_fields: Any custom fields associated with the user

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If neither or both of username/email are provided

    Example:
        >>> user_info = api.get_user(username="john.doe@company.com")
        >>> user_info = api.get_user(email="john.doe@company.com")
        >>> print(f"User created: {user_info['create_ts']}")
    """
    if username is not None and email is not None:
        raise ValidationError("Specify either username or email, not both")
    if username is None and email is None:
        raise ValidationError("Either username or email must be provided")

    command: dict[str, Any] = {"command": "getuser"}
    if username is not None:
        command["username"] = username
    else:
        command["email"] = email

    payload = [command]

    try:
        identifier = username or email
        self.logger.debug("Getting user details for: %s", identifier)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            self.logger.debug("Retrieved user details successfully")
            return result

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to get user details: {e}") from e

add_user

add_user(org: str, username: str, email: str, given_name: str, surname: str, phone: str | None = None, custom_fields: dict[str, Any] | None = None, command_id: str | None = None) -> dict[str, Any]

Add a new user to the organization.

Creates a new user account in the specified organization with the provided information. The user will be created with default settings and can be configured further after creation.

Parameters:

Name Type Description Default
org str

Organization name to add the user to

required
username str

The username for the new user (typically email address)

required
email str

The email address for the new user

required
given_name str

The user's first name

required
surname str

The user's last name

required
phone str | None

Optional phone number for the user. The API validates the area code — 555 area codes are rejected. Use a real or valid test area code (e.g., "+18885550100"). Not required by the Admin Console but validated by the API.

None
custom_fields dict[str, Any] | None

Optional dictionary of custom field key-value pairs

None
command_id str | None

Optional command identifier for tracking

None

Returns:

Type Description
dict[str, Any]

Dictionary containing the result of user creation including:

dict[str, Any]
  • username: The created username
dict[str, Any]
  • email: The user's email address
dict[str, Any]
  • bypass_code: Temporary bypass code for initial access

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

result = api.add_user( ... org="my_org", ... username="jane.smith@company.com", ... email="jane.smith@company.com", ... given_name="Jane", ... surname="Smith", ... phone="888-555-0100", ... custom_fields={"department": "Engineering", "role": "Developer"} ... ) print(f"User created with bypass code: {result['bypass_code']}")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="adduser")
def add_user(
    self,
    org: str,
    username: str,
    email: str,
    given_name: str,
    surname: str,
    phone: str | None = None,
    custom_fields: dict[str, Any] | None = None,
    command_id: str | None = None,
) -> dict[str, Any]:
    """Add a new user to the organization.

    Creates a new user account in the specified organization with the provided
    information. The user will be created with default settings and can be
    configured further after creation.

    Args:
        org: Organization name to add the user to
        username: The username for the new user (typically email address)
        email: The email address for the new user
        given_name: The user's first name
        surname: The user's last name
        phone: Optional phone number for the user. The API validates
            the area code — ``555`` area codes are rejected. Use a
            real or valid test area code (e.g., ``"+18885550100"``).
            Not required by the Admin Console but validated by the API.
        custom_fields: Optional dictionary of custom field key-value pairs
        command_id: Optional command identifier for tracking

    Returns:
        Dictionary containing the result of user creation including:
        - username: The created username
        - email: The user's email address
        - bypass_code: Temporary bypass code for initial access

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> result = api.add_user(
        ...     org="my_org",
        ...     username="jane.smith@company.com",
        ...     email="jane.smith@company.com",
        ...     given_name="Jane",
        ...     surname="Smith",
        ...     phone="888-555-0100",
        ...     custom_fields={"department": "Engineering", "role": "Developer"}
        ... )
        >>> print(f"User created with bypass code: {result['bypass_code']}")
    """
    # Validate required parameters
    if not org or not isinstance(org, str):
        raise ValidationError("Organization name must be a non-empty string")

    if not username or not isinstance(username, str):
        raise ValidationError("Username must be a non-empty string")

    if not email or not isinstance(email, str):
        raise ValidationError("Email must be a non-empty string")

    if not given_name or not isinstance(given_name, str):
        raise ValidationError("Given name must be a non-empty string")

    if not surname or not isinstance(surname, str):
        raise ValidationError("Surname must be a non-empty string")

    # Build payload
    payload_data: dict[str, Any] = {
        "command": "adduser",
        "org": org,
        "username": username,
        "email": email,
        "given_name": given_name,
        "surname": surname,
    }

    # Add optional parameters
    if phone is not None:
        payload_data["phone"] = phone

    if custom_fields is not None:
        if not isinstance(custom_fields, dict):
            raise ValidationError("custom_fields must be a dictionary")
        payload_data["custom_fields"] = custom_fields

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        self.logger.debug("Adding user: %s to organization: %s", username, org)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            self.logger.info("Successfully added user: %s", username)
            return result

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to add user: {e}") from e

delete_user

delete_user(username: str, command_id: str | None = None) -> bool

Delete a user from an organization.

Permanently removes a user account from the system. This action cannot be undone, and all user data will be lost.

Parameters:

Name Type Description Default
username str

Username of the user to be deleted

required
command_id str | None

Optional command identifier for tracking

None

Returns:

Type Description
bool

True if deletion was successful, False otherwise

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

success = api.delete_user("old.user@company.com") if success: ... print("User deleted successfully")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="deleteuser")
def delete_user(self, username: str, command_id: str | None = None) -> bool:
    """Delete a user from an organization.

    Permanently removes a user account from the system. This action cannot
    be undone, and all user data will be lost.

    Args:
        username: Username of the user to be deleted
        command_id: Optional command identifier for tracking

    Returns:
        True if deletion was successful, False otherwise

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> success = api.delete_user("old.user@company.com")
        >>> if success:
        ...     print("User deleted successfully")
    """
    if not username or not isinstance(username, str):
        raise ValidationError("Username must be a non-empty string")

    payload_data = {
        "command": "deleteuser",
        "username": username,
    }

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        self.logger.debug("Deleting user: %s", username)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, str) and result == "user deleted":
            self.logger.info("Successfully deleted user: %s", username)
            return True

        self.logger.warning(
            "Unexpected result when deleting user %s: %s", username, result
        )
        return False

    except SiloError as e:
        # Known extapi quirk: deleteuser returns {"error": "user not found"}
        # even when deletion succeeded. Treat it as success.
        if "user not found" in str(e).lower():
            self.logger.info(
                "deleteuser returned 'user not found' for %s — "
                "treating as success (known extapi false-error)",
                username,
            )
            return True
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(
            f"Failed to delete user: {e}"
        ) from e  # pragma: no cover
    except Exception as e:  # pragma: no cover
        raise UserManagementAPIError(f"Failed to delete user: {e}") from e

suspend_user

suspend_user(username: str, command_id: str | None = None) -> bool

Suspend a user from an organization.

Temporarily disables a user account, preventing them from accessing the system while preserving their account data. The user can be unsuspended later to restore access.

Parameters:

Name Type Description Default
username str

Username of the user to suspend

required
command_id str | None

Optional command identifier for tracking

None

Returns:

Type Description
bool

True if suspension was successful, False otherwise

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

success = api.suspend_user("problem.user@company.com") if success: ... print("User suspended successfully")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="suspenduser")
def suspend_user(self, username: str, command_id: str | None = None) -> bool:
    """Suspend a user from an organization.

    Temporarily disables a user account, preventing them from accessing
    the system while preserving their account data. The user can be
    unsuspended later to restore access.

    Args:
        username: Username of the user to suspend
        command_id: Optional command identifier for tracking

    Returns:
        True if suspension was successful, False otherwise

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> success = api.suspend_user("problem.user@company.com")
        >>> if success:
        ...     print("User suspended successfully")
    """
    if not username or not isinstance(username, str):
        raise ValidationError("Username must be a non-empty string")

    payload_data = {
        "command": "suspenduser",
        "username": username,
    }

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        self.logger.debug("Suspending user: %s", username)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, str) and result == "user suspended":
            self.logger.info("Successfully suspended user: %s", username)
            return True

        self.logger.warning(
            "Unexpected result when suspending user %s: %s", username, result
        )
        return False

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to suspend user: {e}") from e

unsuspend_user

unsuspend_user(username: str, command_id: str | None = None) -> bool

Unsuspend a user from an organization.

Restores access for a previously suspended user account, allowing them to use the system again with their existing settings and data.

Parameters:

Name Type Description Default
username str

Username of the user to unsuspend

required
command_id str | None

Optional command identifier for tracking

None

Returns:

Type Description
bool

True if unsuspension was successful, False otherwise

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

success = api.unsuspend_user("restored.user@company.com") if success: ... print("User unsuspended successfully")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="unsuspenduser")
def unsuspend_user(self, username: str, command_id: str | None = None) -> bool:
    """Unsuspend a user from an organization.

    Restores access for a previously suspended user account, allowing them
    to use the system again with their existing settings and data.

    Args:
        username: Username of the user to unsuspend
        command_id: Optional command identifier for tracking

    Returns:
        True if unsuspension was successful, False otherwise

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> success = api.unsuspend_user("restored.user@company.com")
        >>> if success:
        ...     print("User unsuspended successfully")
    """
    if not username or not isinstance(username, str):
        raise ValidationError("Username must be a non-empty string")

    payload_data = {
        "command": "unsuspenduser",
        "username": username,
    }

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        self.logger.debug("Unsuspending user: %s", username)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, str) and result == "user unsuspended":
            self.logger.info("Successfully unsuspended user: %s", username)
            return True

        self.logger.warning(
            "Unexpected result when unsuspending user %s: %s", username, result
        )
        return False

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to unsuspend user: {e}") from e

reset_pin

reset_pin(username: str, email: str | None = None) -> dict[str, str]

Reset a user's PIN, creating a temporary password.

Generates a new temporary password for a user, which they can use to access their account and set a new permanent PIN. This is useful for password recovery or initial account setup.

Parameters:

Name Type Description Default
username str

Username of the user to reset their PIN. Required — this is always the identifier used to look up the account.

required
email str | None

Optional email address sent alongside username in the request. This is NOT an alternative to username; username must still be a non-empty string even when email is supplied.

None

Returns:

Type Description
dict[str, str]

Dictionary containing the temporary password information:

dict[str, str]
  • temporary_password: The generated temporary password
dict[str, str]
  • username: The username for which the PIN was reset

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

result = api.reset_pin("user@company.com") temp_password = result['temporary_password'] print(f"Temporary password: {temp_password}")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="reset_pin")
def reset_pin(self, username: str, email: str | None = None) -> dict[str, str]:
    """Reset a user's PIN, creating a temporary password.

    Generates a new temporary password for a user, which they can use to
    access their account and set a new permanent PIN. This is useful for
    password recovery or initial account setup.

    Args:
        username: Username of the user to reset their PIN. Required —
            this is always the identifier used to look up the account.
        email: Optional email address sent alongside ``username`` in
            the request. This is NOT an alternative to ``username``;
            ``username`` must still be a non-empty string even when
            ``email`` is supplied.

    Returns:
        Dictionary containing the temporary password information:
        - temporary_password: The generated temporary password
        - username: The username for which the PIN was reset

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> result = api.reset_pin("user@company.com")
        >>> temp_password = result['temporary_password']
        >>> print(f"Temporary password: {temp_password}")
    """
    if not username or not isinstance(username, str):
        raise ValidationError("Username must be a non-empty string")

    payload_data = {
        "command": "reset_pin",
        "username": username,
    }

    if email is not None:
        payload_data["email"] = email

    payload = [payload_data]

    try:
        self.logger.debug("Resetting PIN for user: %s", username)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            self.logger.info("Successfully reset PIN for user: %s", username)
            return result

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to reset PIN: {e}") from e

promote_user_to_admin

promote_user_to_admin(org: str, username: str | None = None, email: str | None = None, command_id: str | None = None) -> dict[str, Any]

Promote a user to admin for an organization.

Upgrades a user account to an admin role within the specified organization. The service account associated with the API token must have the required administrative privileges — those privileges will be applied to the promoted user.

Exactly one of username or email must be provided.

Parameters:

Name Type Description Default
org str

The name of the organization the user will administer. Required.

required
username str | None

Username of the account to promote. Used if email is not provided.

None
email str | None

Email of the account to promote. Used if username is not provided.

None
command_id str | None

Optional command identifier for tracking.

None

Returns:

Type Description
dict[str, Any]

Dictionary containing the result of the promotion.

Raises:

Type Description
UserManagementAPIError

If the API request fails.

ValidationError

If parameters are invalid.

Example

result = api.promote_user_to_admin( ... org="MyOrg", ... username="admin@company.com", ... )

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="admin.user_to_admin")
def promote_user_to_admin(
    self,
    org: str,
    username: str | None = None,
    email: str | None = None,
    command_id: str | None = None,
) -> dict[str, Any]:
    """Promote a user to admin for an organization.

    Upgrades a user account to an admin role within the specified
    organization. The service account associated with the API token must
    have the required administrative privileges — those privileges will be
    applied to the promoted user.

    Exactly one of ``username`` or ``email`` must be provided.

    Args:
        org: The name of the organization the user will administer.
            Required.
        username: Username of the account to promote. Used if ``email``
            is not provided.
        email: Email of the account to promote. Used if ``username``
            is not provided.
        command_id: Optional command identifier for tracking.

    Returns:
        Dictionary containing the result of the promotion.

    Raises:
        UserManagementAPIError: If the API request fails.
        ValidationError: If parameters are invalid.

    Example:
        >>> result = api.promote_user_to_admin(
        ...     org="MyOrg",
        ...     username="admin@company.com",
        ... )
    """
    if not org or not isinstance(org, str):
        raise ValidationError("Organization name must be a non-empty string")
    if username is not None and email is not None:
        raise ValidationError("Specify either username or email, not both")
    if username is None and email is None:
        raise ValidationError("Either username or email must be provided")
    if not self.admin_token:
        raise ValidationError("ADMIN_TOKEN is required for promote_user_to_admin")

    payload_data: dict[str, Any] = {
        "command": "admin.user_to_admin",
        "org": org,
    }
    if username is not None:
        payload_data["username"] = username
    else:
        payload_data["email"] = email

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        identifier = username or email
        self.logger.debug("Promoting user %s to admin in org: %s", identifier, org)
        response = self._make_api_request("POST", "api/", self.admin_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, dict):
            self.logger.info(
                "Successfully promoted user %s to admin in %s", identifier, org
            )
            return result
        if isinstance(result, (str, bool)):
            self.logger.info(
                "Successfully promoted user %s to admin in %s", identifier, org
            )
            return {"status": str(result), "org": org, "username": identifier}

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to promote user to admin: {e}") from e

batch_suspend_users

batch_suspend_users(usernames: list[str]) -> list[dict[str, Any]]

Suspend multiple user accounts in a single batch request.

Sends all suspenduser commands in one HTTP POST to the extapi using SYNC_TOKEN. Each username is processed sequentially by the server within the same batch, which is more efficient than calling :meth:suspend_user in a loop.

Parameters:

Name Type Description Default
usernames list[str]

Non-empty list of usernames to suspend. Each entry is sent as a separate suspenduser wire command within the same batch POST.

required

Returns:

Type Description
list[dict[str, Any]]

List of per-username result dicts (one per input username, in the

list[dict[str, Any]]

same order). Each dict contains the username field plus either

list[dict[str, Any]]

a "result" key (success) or an "error" key (failure for

list[dict[str, Any]]

that username).

Raises:

Type Description
ValidationError

If usernames is empty.

UserManagementAPIError

If the API request fails.

Note

Unlike calling :meth:suspend_user in a loop, this method does not perform per-username type validation.

Example

results = api.batch_suspend_users([ ... "alice@company.com", ... "bob@company.com", ... ]) for r in results: ... print(r["username"], r.get("result"))

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.CONVENIENCE, wraps=["suspend_user"])
def batch_suspend_users(self, usernames: list[str]) -> list[dict[str, Any]]:
    """Suspend multiple user accounts in a single batch request.

    Sends all ``suspenduser`` commands in one HTTP POST to the extapi using
    SYNC_TOKEN. Each username is processed sequentially by the server within
    the same batch, which is more efficient than calling :meth:`suspend_user`
    in a loop.

    Args:
        usernames: Non-empty list of usernames to suspend. Each entry is
            sent as a separate ``suspenduser`` wire command within the
            same batch POST.

    Returns:
        List of per-username result dicts (one per input username, in the
        same order). Each dict contains the ``username`` field plus either
        a ``"result"`` key (success) or an ``"error"`` key (failure for
        that username).

    Raises:
        ValidationError: If ``usernames`` is empty.
        UserManagementAPIError: If the API request fails.

    Note:
        Unlike calling :meth:`suspend_user` in a loop, this method does
        not perform per-username type validation.

    Example:
        >>> results = api.batch_suspend_users([
        ...     "alice@company.com",
        ...     "bob@company.com",
        ... ])
        >>> for r in results:
        ...     print(r["username"], r.get("result"))
    """
    if not usernames:
        raise ValidationError("usernames must be a non-empty list")

    commands = [{"command": "suspenduser", "username": u} for u in usernames]

    try:
        self.logger.debug("Batch suspending %d users", len(usernames))
        raw_results = self.batch_request(commands, auth_token=self.auth_token)
    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to batch suspend users: {e}") from e

    results: list[dict[str, Any]] = []
    for i, username in enumerate(usernames):
        entry: dict[str, Any] = {"username": username}
        if i < len(raw_results):
            raw = raw_results[i]
            if isinstance(raw, dict):
                entry.update(raw)
            else:  # pragma: no branch — defensive for non-dict results
                entry["result"] = raw  # type: ignore[unreachable]
        else:
            entry["error"] = "no response received"
        results.append(entry)

    self.logger.info("Batch suspend completed for %d users", len(usernames))
    return results

batch_delete_users

batch_delete_users(usernames: list[str]) -> list[dict[str, Any]]

Delete multiple user accounts in a single batch request.

Sends all deleteuser commands in one HTTP POST to the extapi using SYNC_TOKEN. Each username is permanently deleted within the same batch — this action cannot be undone. More efficient than calling :meth:delete_user in a loop.

Parameters:

Name Type Description Default
usernames list[str]

Non-empty list of usernames to delete. Each entry is sent as a separate deleteuser wire command within the same batch POST.

required

Returns:

Type Description
list[dict[str, Any]]

List of per-username result dicts (one per input username, in the

list[dict[str, Any]]

same order). Each dict contains the username field plus

list[dict[str, Any]]

either a "result" key (success, including the soft-success

list[dict[str, Any]]

case described below) or an "error" key (a genuine failure

list[dict[str, Any]]

for that username). This mirrors the boolean success/failure

list[dict[str, Any]]

contract of :meth:delete_user on a per-item basis, so callers

list[dict[str, Any]]

switching a delete loop to this batch call see comparable

list[dict[str, Any]]

outcomes for the same inputs.

Raises:

Type Description
ValidationError

If usernames is empty.

UserManagementAPIError

If the API request fails.

Note

Unlike calling :meth:delete_user in a loop, this method does not perform per-username type validation.

Known extapi quirk: deleteuser can return {"error": "user not found"} for a username even when the delete actually succeeded. :meth:delete_user already treats this as success (returns True); this method applies the same translation per-entry so the two methods' result contracts stay consistent. Affected entries have their "error" key replaced with "result": "deleted (unconfirmed)" plus a "note" explaining the quirk, instead of surfacing a spurious failure.

Example

results = api.batch_delete_users([ ... "former.employee@company.com", ... "test.account@company.com", ... ]) for r in results: ... print(r["username"], r.get("result"))

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.CONVENIENCE, wraps=["delete_user"])
def batch_delete_users(self, usernames: list[str]) -> list[dict[str, Any]]:
    """Delete multiple user accounts in a single batch request.

    Sends all ``deleteuser`` commands in one HTTP POST to the extapi using
    SYNC_TOKEN. Each username is permanently deleted within the same batch
    — this action cannot be undone. More efficient than calling
    :meth:`delete_user` in a loop.

    Args:
        usernames: Non-empty list of usernames to delete. Each entry is
            sent as a separate ``deleteuser`` wire command within the
            same batch POST.

    Returns:
        List of per-username result dicts (one per input username, in the
        same order). Each dict contains the ``username`` field plus
        either a ``"result"`` key (success, including the soft-success
        case described below) or an ``"error"`` key (a genuine failure
        for that username). This mirrors the boolean success/failure
        contract of :meth:`delete_user` on a per-item basis, so callers
        switching a delete loop to this batch call see comparable
        outcomes for the same inputs.

    Raises:
        ValidationError: If ``usernames`` is empty.
        UserManagementAPIError: If the API request fails.

    Note:
        Unlike calling :meth:`delete_user` in a loop, this method does
        not perform per-username type validation.

        Known extapi quirk: ``deleteuser`` can return
        ``{"error": "user not found"}`` for a username even when the
        delete actually succeeded. :meth:`delete_user` already treats
        this as success (returns ``True``); this method applies the same
        translation per-entry so the two methods' result contracts stay
        consistent. Affected entries have their ``"error"`` key replaced
        with ``"result": "deleted (unconfirmed)"`` plus a ``"note"``
        explaining the quirk, instead of surfacing a spurious failure.

    Example:
        >>> results = api.batch_delete_users([
        ...     "former.employee@company.com",
        ...     "test.account@company.com",
        ... ])
        >>> for r in results:
        ...     print(r["username"], r.get("result"))
    """
    if not usernames:
        raise ValidationError("usernames must be a non-empty list")

    commands = [{"command": "deleteuser", "username": u} for u in usernames]

    try:
        self.logger.debug("Batch deleting %d users", len(usernames))
        raw_results = self.batch_request(commands, auth_token=self.auth_token)
    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to batch delete users: {e}") from e

    results: list[dict[str, Any]] = []
    for i, username in enumerate(usernames):
        entry: dict[str, Any] = {"username": username}
        if i < len(raw_results):
            raw = raw_results[i]
            if isinstance(raw, dict):
                entry.update(raw)
            else:  # pragma: no branch — defensive for non-dict results
                entry["result"] = raw  # type: ignore[unreachable]
        else:
            entry["error"] = "no response received"
        results.append(entry)

    # Known extapi quirk: deleteuser returns {"error": "user not found"}
    # even when the delete succeeded (see delete_user()'s handling of
    # the same quirk). Translate it here too so batch and single-item
    # deletes present a consistent result contract to callers.
    for entry in results:
        error_val = entry.get("error")
        if error_val and "user not found" in str(error_val).lower():
            entry.pop("error", None)
            entry["result"] = "deleted (unconfirmed)"
            entry["note"] = (
                "extapi returned 'user not found' — known false-error; "
                "treated as success for consistency with delete_user(). "
                "Call list_users() to confirm if needed."
            )

    self.logger.info("Batch delete completed for %d users", len(usernames))
    return results

modify_user

modify_user(email: str, phone: str | None = None, given_name: str | None = None, surname: str | None = None, org: str | None = None, custom_fields: dict[str, Any] | None = None, command_id: str | None = None) -> dict[str, Any]

Modify an existing user's attributes.

Updates various attributes of an existing user including their phone number, names, organization assignment, and custom fields.

Parameters:

Name Type Description Default
email str

Email of the user to be modified. This is the primary identifier for the modify operation — the user is looked up by email, not username. Required parameter; must be a non-empty string.

required
phone str | None

New phone number for the user. The API validates the area code — 555 area codes are rejected (not required by the Admin Console, but validated by the API).

None
given_name str | None

New first name for the user

None
surname str | None

New last name for the user

None
org str | None

New organization to move the user to (API org name)

None
custom_fields dict[str, Any] | None

New custom field key-value pairs

None
command_id str | None

Optional command identifier for tracking

None
Note

Wire format parameter naming:

  • The modifyuser command uses "email" as the primary identifier
  • Other user management commands (e.g., getuser, deleteuser) use "username"
  • All params are flat alongside "command", not nested under "data"

Wire format structure::

{
    "command": "modifyuser",
    "email": "user@company.com",  # ← primary identifier (flat)
    "phone": "888-555-0100",
    "given_name": "John",
    "surname": "Smith",
    "org": "my_org",
    "custom_fields": {"department": "Engineering"}
}

Returns:

Type Description
dict[str, Any]

Dictionary containing the result of user modification:

dict[str, Any]
  • status: Status message ("user modified" on success)
dict[str, Any]
  • email: Email of the modified user

Raises:

Type Description
UserManagementAPIError

If the API request fails

ValidationError

If parameters are invalid

Example

Modify user by email (not username)

result = api.modify_user( ... email="user@company.com", # ← primary identifier ... phone="888-555-0100", ... given_name="John", ... surname="Smith", ... custom_fields={"department": "Engineering"} ... ) print("User modified successfully")

Source code in silo_sdk/management/user_api.py
@api_tag(MethodType.API_COMMAND, api_command="modifyuser")
def modify_user(
    self,
    email: str,
    phone: str | None = None,
    given_name: str | None = None,
    surname: str | None = None,
    org: str | None = None,
    custom_fields: dict[str, Any] | None = None,
    command_id: str | None = None,
) -> dict[str, Any]:
    """Modify an existing user's attributes.

    Updates various attributes of an existing user including their phone number,
    names, organization assignment, and custom fields.

    Args:
        email: Email of the user to be modified. **This is the primary identifier**
            for the modify operation — the user is looked up by email, not username.
            Required parameter; must be a non-empty string.
        phone: New phone number for the user. The API validates the
            area code — ``555`` area codes are rejected (not required
            by the Admin Console, but validated by the API).
        given_name: New first name for the user
        surname: New last name for the user
        org: New organization to move the user to (API org name)
        custom_fields: New custom field key-value pairs
        command_id: Optional command identifier for tracking

    Note:
        Wire format parameter naming:

        - The ``modifyuser`` command uses ``"email"`` as the primary identifier
        - Other user management commands (e.g., ``getuser``, ``deleteuser``) use ``"username"``
        - All params are **flat alongside "command"**, not nested under ``"data"``

        Wire format structure::

            {
                "command": "modifyuser",
                "email": "user@company.com",  # ← primary identifier (flat)
                "phone": "888-555-0100",
                "given_name": "John",
                "surname": "Smith",
                "org": "my_org",
                "custom_fields": {"department": "Engineering"}
            }

    Returns:
        Dictionary containing the result of user modification:

        - ``status``: Status message (``"user modified"`` on success)
        - ``email``: Email of the modified user

    Raises:
        UserManagementAPIError: If the API request fails
        ValidationError: If parameters are invalid

    Example:
        >>> # Modify user by email (not username)
        >>> result = api.modify_user(
        ...     email="user@company.com",  # ← primary identifier
        ...     phone="888-555-0100",
        ...     given_name="John",
        ...     surname="Smith",
        ...     custom_fields={"department": "Engineering"}
        ... )
        >>> print("User modified successfully")
    """
    if not email or not isinstance(email, str):
        raise ValidationError("Email must be a non-empty string")

    # Build payload
    payload_data: dict[str, Any] = {
        "command": "modifyuser",
        "email": email,
    }

    # Add optional parameters
    if phone is not None:
        payload_data["phone"] = phone

    if given_name is not None:
        if not isinstance(given_name, str):
            raise ValidationError("Given name must be a string")
        payload_data["given_name"] = given_name

    if surname is not None:
        if not isinstance(surname, str):
            raise ValidationError("Surname must be a string")
        payload_data["surname"] = surname

    if org is not None:
        if not isinstance(org, str):
            raise ValidationError("Organization must be a string")
        payload_data["org"] = org

    if custom_fields is not None:
        if not isinstance(custom_fields, dict):
            raise ValidationError("custom_fields must be a dictionary")
        payload_data["custom_fields"] = custom_fields

    if command_id is not None:
        payload_data["command_id"] = command_id

    payload = [payload_data]

    try:
        self.logger.debug("Modifying user: %s", email)
        response = self._make_api_request("POST", "api/", self.auth_token, payload)

        result = self._extract_api_result(response)
        if isinstance(result, str) and result == "user modified":
            self.logger.info("Successfully modified user: %s", email)
            return {"status": "user modified", "email": email}

        # Handle other response formats
        if isinstance(result, dict):
            self.logger.info("Successfully modified user: %s", email)
            return result

        raise UserManagementAPIError(f"Unexpected result format: {result}")

    except Exception as e:
        if isinstance(e, UserManagementAPIError):
            raise
        raise UserManagementAPIError(f"Failed to modify user: {e}") from e